From acf172469ee4c0db6ee778fd430976f8a0862fd6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 2 Sep 2026 18:40:33 +0200 Subject: [PATCH] feat(content): S1 exact CellStruct surface-index construction, recipe 8 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 --- .../oh2-cellstruct-surface-contract.md | 14 +- src/AcDream.Content/MeshExtractor.cs | 698 +++++++++++------- src/AcDream.Content/ObjectMeshData.cs | 87 +++ .../Pak/ObjectMeshDataSerializer.cs | 11 + src/AcDream.Content/Pak/PakFormat.cs | 13 +- .../Meshing/CellStructSideCandidates.cs | 267 +++++++ .../Installation/ContentMigrationCatalog.cs | 4 + .../LauncherInstallRecordStore.cs | 8 +- .../MeshExtractorSolidFaceExtractionTests.cs | 440 ++++++++++- .../ObjectMeshDataEquality.cs | 4 + .../ObjectMeshDataSerializerTests.cs | 100 +++ tests/AcDream.Content.Tests/PakFormatTests.cs | 12 + .../PreparedAssetSourceTests.cs | 36 + .../Meshing/CellStructSideCandidatesTests.cs | 322 ++++++++ .../ContentMigrationCatalogTests.cs | 26 + 15 files changed, 1746 insertions(+), 296 deletions(-) create mode 100644 src/AcDream.Core/Meshing/CellStructSideCandidates.cs create mode 100644 tests/AcDream.Core.Tests/Meshing/CellStructSideCandidatesTests.cs diff --git a/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md b/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md index bbf7abb1..1b70ac15 100644 --- a/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md +++ b/docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md @@ -261,9 +261,17 @@ The inverted-normal lane adds the maximum-UV-span offset before that product - the same authored vertex and UV may be shared within one sign lane; - positive- and negative-normal copies cannot alias; -- a null UV map uses UV index `0`, rather than suppressing the candidate; -- `copyVert @0x0059C080` writes zero UV coordinates when the UV pointer is - absent or the index is out of range (`pseudo-C:424797-424829`); +- a null UV map uses UV index `0`, rather than suppressing the candidate. + **Arbitrated on the PDB-paired binary 2026-09-02 (S1 chunk A2 review):** + `ConstructMesh @0x0059E683-0x0059E693` loads the polygon's UV-index array + pointer, and on null executes `xor ebx,ebx`; otherwise + `movsx ebx, byte ptr [edx+edi]` (the element is a SIGNED char). `ebx` is + the index `copyVert` consumes. So an absent array is exactly "vertex UV + slot 0", not a zero coordinate; +- `copyVert @0x0059C080` writes zero UV coordinates only when that index is + negative, when it is `>= CVertex::num_uvs`, or when the VERTEX has no UV + array (`edi[4] == 0`) (`pseudo-C:424797-424829`; Ghidra confirms the three + conditions). It never clamps an out-of-range index to slot 0; - `copyVert` multiplies the authored normal by the selected `+1/-1` (`424791-424793`). diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index c50eb5b1..e7cdc9b5 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -730,10 +730,84 @@ public sealed class MeshExtractor { }; } + /// + /// Per-source-surface-array-index accumulator for + /// . Retail's + /// D3DPolyRender::ConstructMesh @0x0059DFA0 allocates one + /// MeshBatchType triangle-attribute record and one + /// isStippledOrAlphaedMask byte per EnvCell SURFACE-ARRAY SLOT + /// (contract §3.2-§3.3), not per (TextureKey, sides_type) tuple. This + /// class is that per-slot accumulator: every candidate from every + /// polygon that targets this slot contributes to the SAME + /// , in source polygon order, regardless of the + /// resolved Surface DID, texture format, or that polygon's own + /// sides_type/stippling value. + /// + private sealed class CellSurfaceSlot { + public CellSurfaceSlot(Surface surface, uint surfaceId) { + Surface = surface; + SurfaceId = surfaceId; + } + + /// Resolved once per slot; every candidate targeting this slot shares it. + public Surface Surface { get; } + public uint SurfaceId { get; } + + /// + /// Retail's isStippledOrAlphaedMask byte for this slot: + /// once, + /// then + /// per polygon candidate touching the slot (contract §3.2). + /// + public int Mask; + + /// + /// Null until the first candidate for this slot resolves/decodes its + /// texture (lazy, exactly once per slot). Stays null forever if + /// texture resolution failed a dependency lookup (already logged via + /// the existing "[tex-skip]"/LogWarning diagnostics) — the slot then + /// contributes zero geometry. + /// + public TextureBatchData? Batch; + + /// (Width, Height, TextureFormat) storage-grouping key for , set alongside it. + public (int Width, int Height, TextureFormat Format) Format; + } + + /// + /// Retail's exact built-EnvCell surface/subset construction, per + /// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md + /// (OH2/S1 chunk 1-2). Side candidates come ONLY from + /// CPolygon::sides_type + /// (, + /// D3DPolyRender::ConstructMesh @0x0059DFA0, contract §3.4); + /// NoPos/NoNeg mean "this side's UV-index array is absent" + /// only (CPolygon::UnPack @0x00538650, contract §2.3/§3.5) and + /// never suppress a candidate. The subset/material owner is the SOURCE + /// SURFACE-ARRAY INDEX (contract §3.6), not the resolved Surface DID, + /// texture format, or stippling — is that + /// per-slot accumulator. A slot whose resolved Surface.Type is + /// untextured () + /// is fully constructed but NOT emitted into the prepared output, + /// matching RenderDeviceD3D::DrawEnvCell @0x0059F170 → + /// D3DPolyRender::DrawMesh(..., arg4=1) @0x0059D4A0's built-EnvCell + /// admission test (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 + /// (contract §4). Retires the AP-234 approximation this method used to + /// carry (docs/architecture/retail-divergence-register.md). + /// public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, IReadOnlyList surfaceOverrides, Matrix4x4 transform, CancellationToken ct) { var vertices = new List(); - var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); + // Vertex identity per contract §3.5: dedupe within one NORMAL-SIGN + // LANE on (authored vertex id, UV index); positive- and + // negative-normal copies never alias. Every branch row in the + // retail table (§3.4) has UvSlot == SurfaceSlot, and a looked-up UV + // coordinate is fully determined by (vertexId, uvIndex) regardless + // of which array (pos_uv_indices/neg_uv_indices) supplied that + // index — so NormalSign alone (encoded here as the boolean + // "negativeLane") is a sufficient, exact lane key. + var vertexLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool negativeLane), ushort>(); var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); + var slots = new Dictionary(); var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); @@ -744,279 +818,346 @@ public sealed class MeshExtractor { } var boundingBox = new BoundingBox(min, max); - foreach (var poly in cellStruct.Polygons.Values) { - ct.ThrowIfCancellationRequested(); - if (poly.VertexIds.Count < 3) continue; + int unknownSidesTypePolygons = 0; - // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this - // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a - // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, - // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still - // suppress hidden portal/cap faces before they reach our mesh. - // - // #426: unlike PrepareGfxObjMeshData (ordinary objects — fixed to - // emit every NoPos-flagged positive side, since NoPos means "no - // UVs", not "no face"), this NoPos gate is INTENTIONALLY kept. - // Cell-wall geometry draws through retail's - // RenderDeviceD3D::DrawEnvCell (@0x0059f170), which calls - // D3DPolyRender::DrawMesh with arg4=1 and skips every UNTEXTURED - // subset — approximated here by the polygon's own NoPos flag - // rather than by resolving Surface.Type - // (Base1Image/Base1ClipMap, see RetailUntexturedSurfacePolicy) - // before this decision is made. See - // docs/architecture/retail-divergence-register.md AP-234. Do NOT - // remove this gate to mirror the GfxObj fix — that would draw - // solid-colour cell-wall faces retail never shows. - bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); - bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); - - if (hasPos) - AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); - if (hasPos && poly.SidesType == CullMode.None) { - AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); + bool TryResolveSlot(int slot, out Surface surface, out uint surfaceId) { + if (slot < surfaceOverrides.Count) { + surfaceId = 0x08000000u | surfaceOverrides[slot]; } - else if (hasNeg && poly.SidesType == CullMode.Clockwise) { - AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); + else { + surface = default!; + surfaceId = 0; + _logger.LogWarning($"Failed to find surface override for index {slot} in CellStruct id=0x{id:X16}"); + return false; } - void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { - if (surfaceIdx < 0) return; + if (!_dats.Portal.TryGet(surfaceId, out surface!)) { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> slot {slot} dropped (cellstruct id=0x{id:X16})"); + return false; + } + return true; + } - uint surfaceId; - if (surfaceIdx < surfaceOverrides.Count) { - surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx]; - } - else { - _logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}"); - return; + // Resolves and decodes the texture payload for ONE surface slot, + // exactly once. Identical decode logic to the ordinary-GfxObj path + // (PrepareGfxObjMeshData) — deliberately duplicated rather than + // shared, so a future change to one extraction path cannot silently + // change the other's behavior (contract §5.2's "Core and Content do + // not retain divergent CellStruct interpretations" is about NOT + // re-implementing the retail SIDE/SUBSET algorithm twice; the DAT + // texture-decode plumbing itself is unrelated to that rule). + void ResolveSlotBatch(CellSurfaceSlot state) { + Surface surface = state.Surface; + uint surfaceId = state.SurfaceId; + + int texWidth, texHeight; + byte[] textureData; + TextureFormat textureFormat; + UploadPixelFormat? uploadPixelFormat = null; + UploadPixelType? uploadPixelType = null; + // #426 / contract §4: "solid" (untextured) is a SURFACE fact. + bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + bool textureDataIsCached = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = UploadPixelFormat.Rgba; + textureDataIsCached = true; + } + else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + var renderSurfaceId = surfaceTexture.Textures.First(); + if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { + if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped"); + return; + } + renderSurface = hrRenderSurface; } - if (!_dats.Portal.TryGet(surfaceId, out var surface)) { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})"); - return; + texWidth = renderSurface.Width; + texHeight = renderSurface.Height; + paletteId = renderSurface.DefaultPaletteId; + sourceFormat = renderSurface.Format; + isDxt3or5 = renderSurface.Format is + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or + DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + var decodedTextureKey = CreateDecodedTextureKey( + renderSurfaceId, + renderSurface.Format, + isClipMap, + surface.Type.HasFlag(SurfaceType.Additive)); + + if (CanPreserveCompressedTexture( + renderSurface.Format, + isClipMap, + surface.Translucency)) { + textureData = renderSurface.SourceData; + textureFormat = ToTextureFormat(renderSurface.Format); } - - int texWidth, texHeight; - byte[] textureData; - TextureFormat textureFormat; - UploadPixelFormat? uploadPixelFormat = null; - UploadPixelType? uploadPixelType = null; - // #426: "solid" (untextured) is a SURFACE fact, not a - // polygon-stippling fact — see RetailUntexturedSurfacePolicy. - // The old `NoPos ||` term conflated "this polygon's positive - // side has no UVs" with "this surface is untextured"; it also - // wrongly classified a NEG-side batch by the POS-side's NoPos - // flag, since this method is shared by both sides. - bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); - bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); - uint paletteId = 0; - bool isDxt3or5 = false; - bool textureDataIsCached = false; - DatReaderWriter.Enums.PixelFormat? sourceFormat = null; - var isAdditive = false; - var isTransparent = false; - - if (isSolid) { - texWidth = texHeight = 32; - textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + else if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) { + textureData = cachedData; textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; textureDataIsCached = true; } - else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { - var renderSurfaceId = surfaceTexture.Textures.First(); - if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { - if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped"); - return; - } - renderSurface = hrRenderSurface; - } - - texWidth = renderSurface.Width; - texHeight = renderSurface.Height; - paletteId = renderSurface.DefaultPaletteId; - sourceFormat = renderSurface.Format; - isDxt3or5 = renderSurface.Format is - DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or - DatReaderWriter.Enums.PixelFormat.PFID_DXT5; - var decodedTextureKey = CreateDecodedTextureKey( - renderSurfaceId, - renderSurface.Format, - isClipMap, - surface.Type.HasFlag(SurfaceType.Additive)); - - if (CanPreserveCompressedTexture( - renderSurface.Format, - isClipMap, - surface.Translucency)) { - textureData = renderSurface.SourceData; - textureFormat = ToTextureFormat(renderSurface.Format); - } - else if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) { - textureData = cachedData; + else { + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; textureFormat = TextureFormat.RGBA8; uploadPixelFormat = UploadPixelFormat.Rgba; - textureDataIsCached = true; + + textureData = _decodedTextureCache.GetOrCreate( + decodedTextureKey, + () => DecodeCompressedTexture(renderSurface, texWidth, texHeight), + out textureDataIsCached); } else { - if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { - isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = UploadPixelFormat.Rgba; - - textureData = _decodedTextureCache.GetOrCreate( - decodedTextureKey, - () => DecodeCompressedTexture(renderSurface, texWidth, texHeight), - out textureDataIsCached); - } - else { - textureFormat = TextureFormat.RGBA8; - textureData = renderSurface.SourceData; - switch (renderSurface.Format) { - case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: - if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) return; - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_P8: - if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) return; - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A8: - case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: - textureData = new byte[texWidth * texHeight * 4]; - if (surface.Type.HasFlag(SurfaceType.Additive)) { - TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - } - else { - TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - } - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - default: return; - } - } - - if (!TextureHelpers.IsCompressedFormat(renderSurface.Format)) - { - textureData = _decodedTextureCache.RetainOrUse( - decodedTextureKey, - textureData, - out textureDataIsCached); + textureFormat = TextureFormat.RGBA8; + textureData = renderSurface.SourceData; + switch (renderSurface.Format) { + case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) return; + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_P8: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) return; + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A8: + case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: + textureData = new byte[texWidth * texHeight * 4]; + if (surface.Type.HasFlag(SurfaceType.Additive)) { + TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + else { + TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + uploadPixelFormat = UploadPixelFormat.Rgba; + break; + default: return; } } - if (isClipMap && textureData != null) { - // If we got this from the cache, we need to clone it so we don't scale the cached raw data - if (textureDataIsCached) { - var clonedData = new byte[textureData.Length]; - System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); - textureData = clonedData; - textureDataIsCached = false; - } + if (!TextureHelpers.IsCompressedFormat(renderSurface.Format)) + { + textureData = _decodedTextureCache.RetainOrUse( + decodedTextureKey, + textureData, + out textureDataIsCached); + } + } - for (int i = 0; i < textureData.Length; i += 4) { - if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { - textureData[i + 3] = 0; - } + if (isClipMap && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + if (textureDataIsCached) { + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + textureDataIsCached = false; + } + + for (int i = 0; i < textureData.Length; i += 4) { + if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { + textureData[i + 3] = 0; } } } - else { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})"); - return; - } + } + else { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})"); + return; + } - isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); - isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : - (surface.Type.HasFlag(SurfaceType.Translucent) || - surface.Type.HasFlag(SurfaceType.Base1ClipMap) || - ((uint)surface.Type & 0x100) != 0 || // Alpha - ((uint)surface.Type & 0x200) != 0 || // InvAlpha - isAdditive || - (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || - textureFormat == TextureFormat.A8 || - textureFormat == TextureFormat.Rgba32f || - isDxt3or5 || - (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); + isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); + isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : + (surface.Type.HasFlag(SurfaceType.Translucent) || + surface.Type.HasFlag(SurfaceType.Base1ClipMap) || + ((uint)surface.Type & 0x100) != 0 || // Alpha + ((uint)surface.Type & 0x200) != 0 || // InvAlpha + isAdditive || + (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || + textureFormat == TextureFormat.A8 || + textureFormat == TextureFormat.Rgba32f || + isDxt3or5 || + (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); - var format = (texWidth, texHeight, textureFormat); - var key = new TextureKey { + state.Batch = new TextureBatchData { + Key = new TextureKey { SurfaceId = surfaceId, PaletteId = paletteId, - Stippling = poly.Stippling, - IsSolid = isSolid - }; + // Contract §3.6 point 3 / §8.2: the subset owner is the + // SLOT, not the TextureKey, so per-polygon Stippling + // must not be able to split (or wrongly merge) a slot's + // one texture payload. Fixed None here; the real, + // per-polygon-aggregated retail mask lives in + // RetailSurfaceMask instead. + Stippling = StipplingType.None, + IsSolid = isSolid, + }, + TextureData = textureData!, + UploadPixelFormat = uploadPixelFormat, + UploadPixelType = uploadPixelType, + Translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type), + IsTransparent = isTransparent, + IsAdditive = isAdditive, + }; + state.Format = (texWidth, texHeight, textureFormat); + } - if (!batchesByFormat.TryGetValue(format, out var batches)) { - batches = new List(); - batchesByFormat[format] = batches; - } + foreach (var poly in cellStruct.Polygons.Values) { + ct.ThrowIfCancellationRequested(); + if (poly.VertexIds.Count < 3) continue; - var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); - if (batch == null) { - batch = new TextureBatchData { - Key = key, - CullMode = poly.SidesType, - TextureData = textureData!, - UploadPixelFormat = uploadPixelFormat, - UploadPixelType = uploadPixelType, - Translucency = - TranslucencyKindExtensions.FromSurfaceType( - surface.Type), - IsTransparent = isTransparent, - IsAdditive = isAdditive + // OH2/S1 chunk-1: side candidates come ONLY from sides_type + // (CellStructSideCandidates.GetCandidates, contract §3.4). This + // DatReaderWriter "CullMode" field IS the raw retail + // sides_type integer (0/1/2), not a GPU cull enum — see + // CellStructSideCandidates' own remarks. NoPos/NoNeg (below, + // IsUvAbsent) govern UV-array absence only (§3.5) and no + // longer suppress a candidate. + ReadOnlySpan candidates = + CellStructSideCandidates.GetCandidates((int)poly.SidesType); + if (candidates.Length == 0) { + // Contract §3.4 closing paragraph: only 0/1/2 are + // retail-defined. Quarantine/report, don't invent a shape. + unknownSidesTypePolygons++; + continue; + } + + foreach (var candidate in candidates) { + short surfaceIdxRaw = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive + ? poly.PosSurface + : poly.NegSurface; + if (surfaceIdxRaw < 0) continue; + int slot = surfaceIdxRaw; + + if (!slots.TryGetValue(slot, out var slotState)) { + if (!TryResolveSlot(slot, out var surface, out var surfaceId)) continue; + slotState = new CellSurfaceSlot(surface, surfaceId) { + Mask = CellStructSideCandidates.InitialSurfaceMask(surface.Type), }; - batches.Add(batch); + slots[slot] = slotState; } - // Helper for CellStruct vertices - bool batchHasWrappingUVs = batch.HasWrappingUVs; + // Contract §3.2: the per-polygon mask OR always targets the + // POSITIVE surface slot regardless of which specific + // stippling bits are set — ApplyStipplingMaskBit already + // encodes that via candidate.SurfaceSlot (a no-op when this + // candidate's SurfaceSlot is Negative). + slotState.Mask = CellStructSideCandidates.ApplyStipplingMaskBit( + slotState.Mask, candidate.SurfaceSlot, poly.Stippling); + + if (slotState.Batch is null) { + ResolveSlotBatch(slotState); + } + if (slotState.Batch is null) continue; // texture resolution failed a dependency lookup; already logged. + + bool useNegUv = candidate.UvSlot == CellStructPolygonSurfaceSide.Negative; + bool invertNormal = candidate.NormalSign < 0; + bool uvAbsent = CellStructSideCandidates.IsUvAbsent(candidate, poly.Stippling); + + bool hasWrappingUVs = slotState.Batch.HasWrappingUVs; BuildCellStructPolygonIndices( poly, cellStruct, - UVLookup, + vertexLookup, vertices, - batch.Indices, + slotState.Batch.Indices, useNegUv, + uvAbsent, invertNormal, - reverseWinding, + candidate.ReverseWinding, transform, - ref batchHasWrappingUVs); - batch.HasWrappingUVs = batchHasWrappingUVs; + ref hasWrappingUVs); + slotState.Batch.HasWrappingUVs = hasWrappingUVs; } } + int skippedUntexturedSlots = 0; + foreach (var slot in slots.Keys.OrderBy(s => s)) { + var state = slots[slot]; + if (state.Batch is null) continue; + + var batch = state.Batch; + batch.SourceSurfaceIndex = slot; + batch.RawSurfaceType = (uint)state.Surface.Type; + batch.RetailSurfaceMask = (byte)state.Mask; + batch.IsCellShell = true; + // Contract §3.6 + EnvCellRenderer.Rhi.cs's + // ResolveRetailCellShellCullMode: geometry is already + // fan-expanded per side/copy candidate (both fan directions + // materialized as real triangles, see BuildCellStructPolygonIndices), + // so every constructed cell shell subset draws with retail's + // fixed D3DCULL_CW (RenderMeshSubset @0x0059CA10). The authored + // sides_type is NOT stored here any more. + batch.CullMode = CullMode.Clockwise; + + // Contract §4: built-EnvCell DrawMesh(arg4=1) admits a subset + // iff (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0. An + // untextured slot is fully constructed above (its mask/ + // triangle-ownership facts exist) but is not emitted into the + // prepared output. + if (RetailUntexturedSurfacePolicy.IsUntextured(state.Surface.Type)) { + skippedUntexturedSlots++; + continue; + } + + if (!batchesByFormat.TryGetValue(state.Format, out var list)) { + list = new List(); + batchesByFormat[state.Format] = list; + } + list.Add(batch); + } + + if (unknownSidesTypePolygons > 0) { + _logger.LogWarning( + "CellStruct id=0x{Id:X16}: {Count} polygon(s) had an unrecognized raw sides_type (only 0/1/2 are retail-defined per OH2 contract §3.4); zero geometry candidates were constructed for them.", + id, unknownSidesTypePolygons); + } + if (skippedUntexturedSlots > 0) { + _logger.LogDebug( + "CellStruct id=0x{Id:X16}: {Count} surface slot(s) constructed but not emitted — untextured under retail's built-EnvCell admission (Surface.Type & 6) == 0 (OH2 contract §4).", + id, skippedUntexturedSlots); + } + return new ObjectMeshData { ObjectId = id, IsSetup = false, @@ -1028,44 +1169,73 @@ public sealed class MeshExtractor { }; } + /// + /// Builds one candidate's fan vertices/indices for a CellStruct polygon + /// and appends them to . Vertex identity and + /// UV-absence fallback follow contract §3.5 as arbitrated on the binary: + /// copyVert @0x0059C080 multiplies the authored normal by the + /// selected sign; an absent polygon UV-index array means UV INDEX 0 + /// (the caller zeroes the index register, ConstructMesh + /// @0x0059E691 xor ebx,ebx), and the coordinate is zeroed only + /// when the vertex has no UV array, the index is negative, or the index + /// is out of the vertex's range. Fan winding is + /// exactly + /// (contract §3.4's forward/reversed table), not a re-derived formula. + /// private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, - Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, + Dictionary<(ushort vertId, ushort uvIdx, bool negativeLane), ushort> vertexLookup, List vertices, List indices, - bool useNegUv, bool invertNormal, bool reverseWinding, + bool useNegUv, bool uvAbsent, bool invertNormal, bool reverseWinding, Matrix4x4 transform, ref bool hasWrappingUVs) { var polyIndices = new List(); for (int i = 0; i < poly.VertexIds.Count; i++) { ushort vertId = (ushort)poly.VertexIds[i]; - ushort uvIdx = 0; - if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) - uvIdx = poly.NegUVIndices[i]; - else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) - uvIdx = poly.PosUVIndices[i]; + // Retail's UV-index selection, arbitrated 2026-09-02 on the + // PDB-paired binary (ConstructMesh @0x0059E683-0x0059E693): + // mov edi,[uv-index array]; test edi,edi; je -> xor ebx,ebx + // else movsx ebx, byte ptr [edx+edi] + // so an ABSENT polygon UV-index array (NoPos/NoNeg, contract + // §3.5) yields UV INDEX 0 — copyVert @0x0059C080 then reads the + // vertex's own UV slot 0 like any other index. copyVert zeroes + // the coordinate only when the index is negative (the array + // element is a SIGNED char: movsx), the index is >= the vertex's + // uv count, or the vertex has no UV array at all. It never + // clamps an out-of-range index to slot 0. + int uvIdxSigned = 0; + if (!uvAbsent) { + if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + uvIdxSigned = unchecked((sbyte)(byte)poly.NegUVIndices[i]); + else if (!useNegUv && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + uvIdxSigned = unchecked((sbyte)(byte)poly.PosUVIndices[i]); + } if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; - if (uvIdx >= vertex.UVs.Count) { - uvIdx = 0; - } + bool uvInRange = uvIdxSigned >= 0 && uvIdxSigned < vertex.UVs.Count; + Vector2 uv = uvInRange + ? new Vector2(vertex.UVs[uvIdxSigned].U, vertex.UVs[uvIdxSigned].V) + : Vector2.Zero; - var key = (vertId, uvIdx, invertNormal); + // Vertex-identity key (contract §3.5): (sign lane, UV index, + // authored vertex id). Retail keys on the same signed index it + // feeds copyVert, so an absent-array read and a real index-0 + // read share one key AND one coordinate (vertex UV slot 0); + // there is no writer-order question. A negative (corrupt) index + // is kept distinct from slot 0 by folding it to a reserved key + // value rather than aliasing real slot-0 data. + ushort uvKey = uvIdxSigned >= 0 ? (ushort)uvIdxSigned : ushort.MaxValue; + var key = (vertId, uvKey, invertNormal); - if (!hasWrappingUVs) { - var uvCheck = vertex.UVs.Count > 0 - ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) - : Vector2.Zero; - if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { + if (!hasWrappingUVs && uvInRange) { + if (uv.X < 0f || uv.X > 1f || uv.Y < 0f || uv.Y > 1f) { hasWrappingUVs = true; } } - if (!UVLookup.TryGetValue(key, out var idx)) { - var uv = vertex.UVs.Count > 0 - ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) - : Vector2.Zero; + if (!vertexLookup.TryGetValue(key, out var idx)) { var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform)); if (invertNormal) { @@ -1078,24 +1248,20 @@ public sealed class MeshExtractor { normal, uv )); - UVLookup[key] = idx; + vertexLookup[key] = idx; } polyIndices.Add(idx); } - if (reverseWinding) { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[i]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[0]); - } - } - else { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[0]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[i]); - } + // CellStructSideCandidates.TriangleFanIndices is the single source + // of truth for retail's forward/reversed fan order (contract §3.4); + // this loop drives it rather than re-deriving the index arithmetic. + int triangleCount = polyIndices.Count - 2; + for (int t = 0; t < triangleCount; t++) { + var (a, b, c) = CellStructSideCandidates.TriangleFanIndices(t, reverseWinding); + indices.Add(polyIndices[a]); + indices.Add(polyIndices[b]); + indices.Add(polyIndices[c]); } } diff --git a/src/AcDream.Content/ObjectMeshData.cs b/src/AcDream.Content/ObjectMeshData.cs index 7fce14fc..b3d79a39 100644 --- a/src/AcDream.Content/ObjectMeshData.cs +++ b/src/AcDream.Content/ObjectMeshData.cs @@ -5,6 +5,7 @@ using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using System; using System.Collections.Generic; +using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using BoundingBox = Chorizite.Core.Lib.BoundingBox; @@ -143,6 +144,23 @@ public class MeshBatchData { /// /// CPU-side texture info for deduplication during background preparation. /// +/// +/// OH2/S1 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md): +/// for a CellStruct/EnvCell shell batch ( true), +/// is FIXED retail raster state applied AFTER +/// geometry has already been fan-expanded per side/copy candidate — +/// RenderMeshSubset @0x0059CA10 always draws a constructed cell +/// shell subset D3DCULL_CW — it is NOT the authored +/// Polygon.SidesType any more. The subset/material OWNER for a cell +/// batch is instead , the retail source +/// surface-array index (contract §3.6): two cell batches can share a +/// resolved Surface DID/ and still be two distinct +/// subsets, or vice versa. For an ordinary GfxObj batch, +/// keeps its historical meaning and the four +/// cell-only fields below stay at their neutral (non-cell) defaults — +/// never +/// sets them. +/// public class TextureBatchData { public TextureKey Key { get; set; } public byte[] TextureData { get; set; } = Array.Empty(); @@ -155,4 +173,73 @@ public class TextureBatchData { public bool IsTransparent { get; set; } public bool IsAdditive { get; set; } public bool HasWrappingUVs { get; set; } + + /// + /// Retail source surface-array index this subset was constructed from + /// (contract §3.6) — the CellStruct subset/material OWNER, not the + /// resolved Surface DID or texture identity. -1 for a non-cell (GfxObj) + /// batch, where this concept does not apply. + /// + public int SourceSurfaceIndex { get; set; } = -1; + + /// + /// Retail's isStippledOrAlphaedMask byte for this surface slot + /// (contract §3.2): D3DPolyRender::ConstructMesh @0x0059DFA0's + /// per-surface initial mask + /// () plus + /// every polygon's positive-surface stippling OR + /// (). 0 + /// (unused) for a non-cell (GfxObj) batch. + /// + public byte RetailSurfaceMask { get; set; } + + /// + /// The resolved cell surface's raw Surface.Type bits (contract + /// §2.2) — evidence for the built-EnvCell + /// (Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 admission + /// decision (RenderDeviceD3D::DrawEnvCell @0x0059F170 → + /// D3DPolyRender::DrawMesh @0x0059D4A0, contract §4) that + /// already happened before this batch was ever emitted into + /// . 0 (unused) for a + /// non-cell (GfxObj) batch. + /// + public uint RawSurfaceType { get; set; } + + /// + /// True for a CellStruct/EnvCell shell batch built by + /// . + /// Ordinary GfxObj batches leave this false. + /// + public bool IsCellShell { get; set; } +} + +/// +/// OH2/S1 chunk-2 (contract §9 item 6): the one place that recovers a +/// prepared CellStruct mesh's surface-array-index subset order. +/// groups +/// by (Width, Height, Format) for +/// atlas/texture-dedup STORAGE only (contract §3.6 point 5) — the +/// SEMANTIC subset order is always ascending +/// , recoverable +/// independent of that storage grouping or of dictionary/list iteration +/// order. Downstream consumers (App draw dispatch, later OVERHAUL slices +/// such as OH7's ordered draw stream) must read cell subset order through +/// this helper rather than iterating TextureBatches directly. +/// +public static class CellSurfaceSubsets { + /// + /// Every in with + /// set, ordered ascending by + /// . An untextured + /// slot that retail's built-EnvCell admission skipped + /// () + /// was never emitted into + /// in the first place, so it is absent here too — this enumerates + /// DRAWABLE subsets, not every constructed slot. + /// + public static IEnumerable InAscendingSurfaceOrder(ObjectMeshData mesh) => + mesh.TextureBatches.Values + .SelectMany(batches => batches) + .Where(batch => batch.IsCellShell) + .OrderBy(batch => batch.SourceSurfaceIndex); } diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs index 277b0a70..03fc7c2b 100644 --- a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs +++ b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs @@ -222,6 +222,12 @@ public static class ObjectMeshDataSerializer { w.Write(batch.IsTransparent); w.Write(batch.IsAdditive); w.Write(batch.HasWrappingUVs); + // OH2/S1 chunk-2 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md + // §8/§10.5): fixed order, appended after HasWrappingUVs. Recipe 8. + w.Write(batch.SourceSurfaceIndex); + w.Write(batch.RetailSurfaceMask); + w.Write(batch.RawSurfaceType); + w.Write(batch.IsCellShell); } private static TextureBatchData ReadTextureBatchData( @@ -241,6 +247,11 @@ public static class ObjectMeshDataSerializer { batch.IsTransparent = r.ReadBoolean(); batch.IsAdditive = r.ReadBoolean(); batch.HasWrappingUVs = r.ReadBoolean(); + // OH2/S1 chunk-2: fixed order, appended after HasWrappingUVs. + batch.SourceSurfaceIndex = r.ReadInt32(); + batch.RetailSurfaceMask = r.ReadByte(); + batch.RawSurfaceType = r.ReadUInt32(); + batch.IsCellShell = r.ReadBoolean(); return batch; } diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index b24b223b..4b20b9d3 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -35,9 +35,18 @@ public static class PakFormat { /// 7 replaces the synthetic vertex-AABB GfxObj view sphere with retail's /// authored DrawingBSP root sphere. The binary format remains version 2, /// but every prepared GfxObj render record must be regenerated because - /// the sphere participates in portal-view admission. + /// the sphere participates in portal-view admission. Version 8 (OH2/S1, + /// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md) + /// replaces CellStruct/EnvCell extraction with retail's exact + /// surface-array-index subset construction: side candidates come only + /// from sides_type (not NoPos/NoNeg), the subset/material owner is the + /// source surface-array index rather than TextureKey/stippling/sides, + /// and built-EnvCell admission is + /// (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 applied after + /// surface resolution. The binary format remains version 2, but every + /// prepared CellStruct/EnvCell render record must be regenerated. /// - public const uint CurrentBakeToolVersion = 7; + public const uint CurrentBakeToolVersion = 8; } /// diff --git a/src/AcDream.Core/Meshing/CellStructSideCandidates.cs b/src/AcDream.Core/Meshing/CellStructSideCandidates.cs new file mode 100644 index 00000000..3039f703 --- /dev/null +++ b/src/AcDream.Core/Meshing/CellStructSideCandidates.cs @@ -0,0 +1,267 @@ +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. + /// + /// + /// Unknown/out-of-range values (anything other than 0, 1, or 2) yield + /// zero candidates. The installed DAT corpus and the retail header + /// define only 0/1/2 (contract §3.4 closing paragraph); retail's own + /// branching would fall through to the single-side shape for other + /// values, but the port deliberately does not invent that fourth public + /// semantic — an unrecognized raw value stays data for the caller to + /// quarantine/report, not a silently-guessed geometry shape. + /// + public static ReadOnlySpan GetCandidates(int rawSidesType) => rawSidesType switch + { + 0 => SingleCandidates, + 1 => DoubleCandidates, + 2 => BothCandidates, + _ => ReadOnlySpan.Empty, + }; + + /// + /// 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; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs index fe7aa9d8..9d77f786 100644 --- a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs +++ b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs @@ -53,6 +53,10 @@ public static class ContentMigrationCatalog 6, 7, "GfxObj portal admission now uses the authored DrawingBSP sphere"), + [8] = FullRebuild( + 7, + 8, + "exact CellStruct surface-index subset construction"), }; public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 085507f3..25e36958 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -36,9 +36,11 @@ public sealed class LauncherInstallRecordStore { // Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion // Version 7 keeps pak format 2 and regenerates every GfxObj render record - // with retail's authored DrawingBSP view sphere. It is a mandatory full - // rebuild from recipe 6. - public const uint CurrentBakeToolVersion = 7; + // with retail's authored DrawingBSP view sphere. Version 8 (OH2/S1) keeps + // pak format 2 and regenerates every CellStruct/EnvCell render record + // with retail's exact surface-array-index subset construction. Both are + // mandatory full rebuilds from their predecessor recipe. + public const uint CurrentBakeToolVersion = 8; private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs index aa88fed6..1e046349 100644 --- a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs +++ b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs @@ -222,9 +222,166 @@ public sealed class MeshExtractorSolidFaceExtractionTests Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length); } + /// + /// OH2/S1 chunk-2 (2026-09-02): these two winding tests used to register + /// an UNTEXTURED (Base1Solid) surface. Under the exact contract, an + /// untextured surface slot is constructed but never EMITTED (built- + /// EnvCell admission, contract §4) — so the winding assertions need a + /// TEXTURED surface to have a batch to inspect at all. Geometry/winding + /// output is independent of texturing, so switching to Base1Image does + /// not change what these tests actually pin (verified by hand against + /// CellStructSideCandidates.TriangleFanIndices before this change). + /// + private static void RegisterCellTexturedSurface(FakeMeshExtractorDats dats) + { + dats.Register(TexturedSurfaceId, new Surface + { + Type = SurfaceType.Base1Image, + OrigTextureId = SurfaceTextureId, + }); + dats.Register(SurfaceTextureId, new SurfaceTexture + { + Textures = new List> { RenderSurfaceId }, + }); + dats.Register(RenderSurfaceId, new RenderSurface + { + Width = 1, + Height = 1, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = new byte[] { 10, 20, 30, 255 }, + }); + } + [Fact] public void PrepareCellStructMeshData_LandblockSide_PreservesRetailTriangleFanWinding() { + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, + BuildQuadCellStruct(RetailCullMode.Landblock), + surfaceOverrides: [2], + Matrix4x4.Identity, + CancellationToken.None)); + + TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); + // Contract §3.6 + EnvCellRenderer.Rhi.cs's + // ResolveRetailCellShellCullMode: cell shells now always draw + // fixed D3DCULL_CW; the authored sides_type (ST_SINGLE here) no + // longer flows into CullMode. + Assert.Equal(RetailCullMode.Clockwise, batch.CullMode); + Assert.True(batch.IsCellShell); + Assert.Equal(0, batch.SourceSurfaceIndex); + Assert.Equal([0, 1, 2, 0, 2, 3], batch.Indices); + Assert.Equal(4, mesh.Vertices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); + } + + [Fact] + public void PrepareCellStructMeshData_NoneSide_ExpandsReversedFaceExactlyLikeRetailConstructMesh() + { + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, + BuildQuadCellStruct(RetailCullMode.None), + surfaceOverrides: [2], + Matrix4x4.Identity, + CancellationToken.None)); + + TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); + Assert.Equal(RetailCullMode.Clockwise, batch.CullMode); + Assert.True(batch.IsCellShell); + Assert.Equal(0, batch.SourceSurfaceIndex); + Assert.Equal( + [0, 1, 2, 0, 2, 3, 6, 5, 4, 7, 6, 4], + batch.Indices); + Assert.Equal(8, mesh.Vertices.Length); + Assert.All(mesh.Vertices.Take(4), vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); + Assert.All(mesh.Vertices.Skip(4), vertex => Assert.Equal(-Vector3.UnitZ, vertex.Normal)); + } + + [Fact] + public void PrepareCellStructMeshData_BothSide_NegativeCandidateIsNotWindingReversed() + { + // Contract §3.4's binding, counterintuitive fact: ST_BOTH's negative + // candidate changes the SIDE ordinal, not the COPY ordinal, so its + // fan order stays FORWARD even though its normal is negated. Only + // ST_DOUBLE's second COPY reverses winding. Two DISTINCT surface + // slots (0 and 1) so this also exercises "two subsets, one per + // side" for ST_BOTH. + var dats = new FakeMeshExtractorDats(); + dats.Register(0x0800000Au, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = SurfaceTextureId }); + dats.Register(0x08000014u, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = SurfaceTextureId }); + dats.Register(SurfaceTextureId, new SurfaceTexture + { + Textures = new List> { RenderSurfaceId }, + }); + dats.Register(RenderSurfaceId, new RenderSurface + { + Width = 1, + Height = 1, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = new byte[] { 1, 2, 3, 255 }, + }); + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, + [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, + }, + }, + Polygons = new Dictionary + { + // Raw sides_type 2 == ST_BOTH. DatReaderWriter names this + // CullMode member "Clockwise" too, but on Polygon.SidesType + // it means ST_BOTH, NOT a GPU cull state (see + // CellStructSideCandidates' remarks) -- unrelated to the + // FIXED batch.CullMode this method now writes. + [0] = new() { SidesType = RetailCullMode.Clockwise, PosSurface = 0, NegSurface = 1, VertexIds = [0, 1, 2, 3] }, + }, + }; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [10, 20], Matrix4x4.Identity, CancellationToken.None)); + + List batches = mesh.TextureBatches.Values.SelectMany(b => b).OrderBy(b => b.SourceSurfaceIndex).ToList(); + Assert.Equal(2, batches.Count); + + TextureBatchData positive = batches[0]; + Assert.Equal(0, positive.SourceSurfaceIndex); + Assert.Equal([0, 1, 2, 0, 2, 3], positive.Indices); + + TextureBatchData negative = batches[1]; + Assert.Equal(1, negative.SourceSurfaceIndex); + // NOT reversed: forward fan order over the negative-lane vertices. + Assert.Equal([4, 5, 6, 4, 6, 7], negative.Indices); + + Assert.All(mesh.Vertices.Take(4), vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); + Assert.All(mesh.Vertices.Skip(4), vertex => Assert.Equal(-Vector3.UnitZ, vertex.Normal)); + } + + [Fact] + public void PrepareCellStructMeshData_UntexturedSlot_ConstructedButNotEmitted() + { + // Contract §4: RenderDeviceD3D::DrawEnvCell (arg4=1) admits a + // subset iff (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0. A + // Base1Solid surface fails that test, so the slot is fully + // constructed (mask/ownership facts exist) but the mesh must carry + // ZERO texture batches for it. var dats = new FakeMeshExtractorDats(); dats.Register(SolidSurfaceId, new Surface { @@ -241,40 +398,279 @@ public sealed class MeshExtractorSolidFaceExtractionTests Matrix4x4.Identity, CancellationToken.None)); - TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); - Assert.Equal(RetailCullMode.Landblock, batch.CullMode); - Assert.Equal([0, 1, 2, 0, 2, 3], batch.Indices); - Assert.Equal(4, mesh.Vertices.Length); - Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); + Assert.Empty(mesh.TextureBatches); } [Fact] - public void PrepareCellStructMeshData_NoneSide_ExpandsReversedFaceExactlyLikeRetailConstructMesh() + public void PrepareCellStructMeshData_TexturedNoPos_ReadsVertexUvSlotZero() { + // Contract §3.5 as arbitrated on the PDB-paired binary (2026-09-02, + // ConstructMesh @0x0059E683-0x0059E693): when the polygon's UV-index + // array is absent (NoPos) the caller ZEROES THE INDEX (xor ebx,ebx) + // and copyVert @0x0059C080 reads the vertex's own UV slot 0 like any + // other index. This fixture carries a NON-zero UV at slot 0 to prove + // the read-through; the zero-coordinate cases are the two tests + // below (no vertex UV array; out-of-range index). var dats = new FakeMeshExtractorDats(); - dats.Register(SolidSurfaceId, new Surface + RegisterCellTexturedSurface(dats); + var cellStruct = new CellStruct { - Type = SurfaceType.Base1Solid, - ColorValue = new ColorARGB { Alpha = 255, Red = 64, Green = 96, Blue = 128 }, - }); + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + }, + }, + Polygons = new Dictionary + { + [0] = new() { SidesType = RetailCullMode.Landblock, Stippling = StipplingType.NoPos, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3] }, + }, + }; var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); ObjectMeshData mesh = Assert.IsType( extractor.PrepareCellStructMeshData( - id: 1, - BuildQuadCellStruct(RetailCullMode.None), - surfaceOverrides: [1], - Matrix4x4.Identity, - CancellationToken.None)); + id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); - Assert.Equal(RetailCullMode.None, batch.CullMode); - Assert.Equal( - [0, 1, 2, 0, 2, 3, 6, 5, 4, 7, 6, 4], - batch.Indices); - Assert.Equal(8, mesh.Vertices.Length); - Assert.All(mesh.Vertices.Take(4), vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal)); - Assert.All(mesh.Vertices.Skip(4), vertex => Assert.Equal(-Vector3.UnitZ, vertex.Normal)); + Assert.False(batch.Key.IsSolid); + Assert.Equal(4, mesh.Vertices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(new Vector2(0.5f, 0.5f), vertex.UV)); + } + + [Fact] + public void PrepareCellStructMeshData_TexturedNoPos_VertexWithoutUvArray_EmitsZeroUVs() + { + // copyVert @0x0059C080 zeroes the coordinate when the vertex has no + // UV array (edi[4] == 0) — the only absent-array case that yields + // (0,0). Same polygon shape as the read-through test, no vertex UVs. + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var cellStruct = BuildQuadCellStruct(RetailCullMode.Landblock); + cellStruct.Polygons[0].Stippling = StipplingType.NoPos; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); + + Assert.Equal(4, mesh.Vertices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector2.Zero, vertex.UV)); + } + + [Fact] + public void PrepareCellStructMeshData_OutOfRangeUvIndex_EmitsZeroUVs_NotSlotZero() + { + // copyVert @0x0059C080: index >= the vertex's uv count -> zero + // coordinate. Retail never clamps to slot 0. Each vertex has one + // UV (slot 0, non-zero) and the polygon asks for slot 5. + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ, UVs = { new Vec2Duv { U = 0.5f, V = 0.5f } } }, + }, + }, + Polygons = new Dictionary + { + [0] = new() { SidesType = RetailCullMode.Landblock, Stippling = default, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3], PosUVIndices = [5, 5, 5, 5] }, + }, + }; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); + + Assert.Equal(4, mesh.Vertices.Length); + Assert.All(mesh.Vertices, vertex => Assert.Equal(Vector2.Zero, vertex.UV)); + } + + [Fact] + public void PrepareCellStructMeshData_TwoSlotsSameSurfaceDid_RemainTwoDistinctSubsets() + { + // Contract §3.6 point 2: the subset owner is the SOURCE + // SURFACE-ARRAY INDEX, not the resolved Surface DID -- two + // different slots that happen to resolve to the SAME override + // value (and therefore the same Surface DID) must stay two + // separate subsets. + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, + [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, + [4] = new() { Origin = new Vector3(2, 0, 0), Normal = Vector3.UnitZ }, + [5] = new() { Origin = new Vector3(3, 0, 0), Normal = Vector3.UnitZ }, + [6] = new() { Origin = new Vector3(3, 1, 0), Normal = Vector3.UnitZ }, + }, + }, + Polygons = new Dictionary + { + [0] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2, 3] }, + [1] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 1, NegSurface = -1, VertexIds = [4, 5, 6] }, + }, + }; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + // Both slots resolve to override value 2 -> the SAME Surface DID. + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [2, 2], Matrix4x4.Identity, CancellationToken.None)); + + List batches = mesh.TextureBatches.Values.SelectMany(b => b).OrderBy(b => b.SourceSurfaceIndex).ToList(); + Assert.Equal(2, batches.Count); + Assert.Equal(0, batches[0].SourceSurfaceIndex); + Assert.Equal(1, batches[1].SourceSurfaceIndex); + Assert.Equal(batches[0].Key.SurfaceId, batches[1].Key.SurfaceId); + Assert.NotSame(batches[0], batches[1]); + } + + [Fact] + public void PrepareCellStructMeshData_DifferentStipplingOnOneSlot_StaysOneSubsetWithAggregatedMask() + { + // Contract §3.6 point 3 + §3.2: two polygons on the SAME slot with + // DIFFERENT stippling values must remain ONE subset, and the + // slot's final mask is the OR of every polygon's positive-surface + // stippling bit. + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, + [3] = new() { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, + }, + }, + Polygons = new Dictionary + { + [0] = new() { SidesType = RetailCullMode.Landblock, Stippling = StipplingType.None, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2] }, + [1] = new() { SidesType = RetailCullMode.Landblock, Stippling = StipplingType.Positive, PosSurface = 0, NegSurface = -1, VertexIds = [0, 2, 3] }, + }, + }; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); + + TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); + // Base1Image alone carries no alpha/clip/translucent bits -> initial + // mask 0; the second polygon's nonzero (signed-positive) stippling + // ORs bit 0 in. The first polygon's None (0) stippling does not. + Assert.Equal((byte)1, batch.RetailSurfaceMask); + Assert.Equal(6, batch.Indices.Count); // both triangles landed in the one subset + } + + [Fact] + public void PrepareCellStructMeshData_DifferentSidesValuesOnOneSlot_StaysOneSubset() + { + // Contract §3.6 point 3: two polygons on the SAME slot with + // DIFFERENT sides_type values (ST_SINGLE and ST_DOUBLE here) must + // stay one subset, in source polygon order. + var dats = new FakeMeshExtractorDats(); + RegisterCellTexturedSurface(dats); + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, + }, + }, + Polygons = new Dictionary + { + [0] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2] }, + [1] = new() { SidesType = RetailCullMode.None, PosSurface = 0, NegSurface = -1, VertexIds = [0, 1, 2] }, + }, + }; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None)); + + TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value); + Assert.Equal(0, batch.SourceSurfaceIndex); + // poly0 (ST_SINGLE): one forward triangle. poly1 (ST_DOUBLE): one + // forward + one reversed copy. 3 triangles total, in polygon order. + Assert.Equal(9, batch.Indices.Count); + } + + [Fact] + public void PrepareCellStructMeshData_SubsetOrder_IsAscendingSurfaceIndexAcrossTextureFormats() + { + // Contract §3.6 point 1/5: subset order is ascending SOURCE SURFACE + // INDEX, independent of the (Width,Height,Format) storage + // grouping. Two slots resolve to DIFFERENT texture dimensions (so + // they land in different TextureBatches dictionary buckets), and + // the higher slot number is authored/processed FIRST. + var dats = new FakeMeshExtractorDats(); + dats.Register(0x08000005u, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = 0x05000005u }); + dats.Register(0x05000005u, new SurfaceTexture { Textures = new List> { 0x06000005u } }); + dats.Register(0x06000005u, new RenderSurface { Width = 8, Height = 8, Format = PixelFormat.PFID_A8R8G8B8, SourceData = new byte[8 * 8 * 4] }); + + dats.Register(0x08000002u, new Surface { Type = SurfaceType.Base1Image, OrigTextureId = SurfaceTextureId }); + dats.Register(SurfaceTextureId, new SurfaceTexture { Textures = new List> { RenderSurfaceId } }); + dats.Register(RenderSurfaceId, new RenderSurface { Width = 1, Height = 1, Format = PixelFormat.PFID_A8R8G8B8, SourceData = new byte[] { 1, 2, 3, 255 } }); + + var cellStruct = new CellStruct + { + VertexArray = new VertexArray + { + Vertices = new Dictionary + { + [0] = new() { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, + [1] = new() { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, + [2] = new() { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, + }, + }, + Polygons = new Dictionary + { + // Higher slot (5, the wide texture) authored/iterated FIRST. + [0] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 5, NegSurface = -1, VertexIds = [0, 1, 2] }, + [1] = new() { SidesType = RetailCullMode.Landblock, PosSurface = 2, NegSurface = -1, VertexIds = [0, 1, 2] }, + }, + }; + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData mesh = Assert.IsType( + extractor.PrepareCellStructMeshData( + id: 1, cellStruct, surfaceOverrides: [0, 0, 2, 0, 0, 5], Matrix4x4.Identity, CancellationToken.None)); + + // At least two distinct (Width,Height,Format) buckets prove this + // isn't accidentally passing because everything landed in one list. + Assert.True(mesh.TextureBatches.Count >= 2); + + List order = CellSurfaceSubsets.InAscendingSurfaceOrder(mesh) + .Select(b => b.SourceSurfaceIndex) + .ToList(); + Assert.Equal([2, 5], order); } private static void RegisterTexturedQuad( diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs index e93cd8f2..9bcdb6b7 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs @@ -100,6 +100,10 @@ public static class ObjectMeshDataEquality { Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}"); Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}"); Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}"); + Assert.True(expected.SourceSurfaceIndex == actual.SourceSurfaceIndex, $"{path}.SourceSurfaceIndex: expected {expected.SourceSurfaceIndex}, got {actual.SourceSurfaceIndex}"); + Assert.True(expected.RetailSurfaceMask == actual.RetailSurfaceMask, $"{path}.RetailSurfaceMask: expected {expected.RetailSurfaceMask}, got {actual.RetailSurfaceMask}"); + Assert.True(expected.RawSurfaceType == actual.RawSurfaceType, $"{path}.RawSurfaceType: expected {expected.RawSurfaceType}, got {actual.RawSurfaceType}"); + Assert.True(expected.IsCellShell == actual.IsCellShell, $"{path}.IsCellShell: expected {expected.IsCellShell}, got {actual.IsCellShell}"); } private static void AssertTextureBatchesEqual( diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs index 6702ef6c..c974125d 100644 --- a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs +++ b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs @@ -176,6 +176,54 @@ public class ObjectMeshDataSerializerTests { return data; } + /// + /// OH2/S1 chunk-2: a cell-shell batch with non-default values for all + /// four new fields (SourceSurfaceIndex, RetailSurfaceMask, + /// RawSurfaceType, IsCellShell), proving the round-trip preserves them + /// and not just their zero/-1 neutral defaults. + /// + private static ObjectMeshData WithCellShellSubset() { + var data = EmptyObject(); + data.ObjectId = 0x0700_0001u; + data.TextureBatches[(64, 64, TextureFormat.RGBA8)] = new List { + new() { + Key = new TextureKey { SurfaceId = 0x08000BFFu, PaletteId = 0, Stippling = StipplingType.None, IsSolid = false }, + TextureData = new byte[] { 1, 2, 3, 4 }, + Indices = new List { 0, 1, 2 }, + CullMode = CullMode.Clockwise, + Translucency = TranslucencyKind.Opaque, + IsTransparent = false, + IsAdditive = false, + HasWrappingUVs = false, + SourceSurfaceIndex = 7, + RetailSurfaceMask = 0b0000_1101, + RawSurfaceType = 0x11u, + IsCellShell = true, + }, + }; + return data; + } + + /// + /// An ordinary (non-cell) GfxObj-shaped batch — the four new fields are + /// left entirely unset, exercising their class-level neutral defaults + /// (SourceSurfaceIndex = -1, RetailSurfaceMask = 0, RawSurfaceType = 0, + /// IsCellShell = false) through the same round-trip path. + /// + private static ObjectMeshData WithGfxObjNeutralDefaults() { + var data = EmptyObject(); + data.ObjectId = 0x0700_0002u; + data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { + new() { + Key = new TextureKey { SurfaceId = 0x08000001u, PaletteId = 0, Stippling = StipplingType.Positive, IsSolid = true }, + TextureData = new byte[] { 9, 9, 9, 9 }, + Indices = new List { 0, 1, 2 }, + CullMode = CullMode.None, + }, + }; + return data; + } + public static IEnumerable AllFixtures() { yield return new object[] { EmptyObject() }; yield return new object[] { VerticesAndIndicesOnly() }; @@ -186,6 +234,21 @@ public class ObjectMeshDataSerializerTests { yield return new object[] { WithNullableFieldsAbsent() }; yield return new object[] { WithEdgeLines() }; yield return new object[] { WithNestedEnvCellGeometry() }; + yield return new object[] { WithCellShellSubset() }; + yield return new object[] { WithGfxObjNeutralDefaults() }; + } + + [Fact] + public void WithGfxObjNeutralDefaults_Fixture_HasClassLevelNeutralDefaults() { + // Sanity check on the fixture itself (not the serializer): proves + // the "neutral default" claim is a property of TextureBatchData's + // own field initializers, not something MeshExtractor's GfxObj path + // has to opt into. + TextureBatchData batch = Assert.Single(Assert.Single(WithGfxObjNeutralDefaults().TextureBatches).Value); + Assert.Equal(-1, batch.SourceSurfaceIndex); + Assert.Equal((byte)0, batch.RetailSurfaceMask); + Assert.Equal(0u, batch.RawSurfaceType); + Assert.False(batch.IsCellShell); } // ---- round-trip tests ---------------------------------------------------- @@ -286,6 +349,43 @@ public class ObjectMeshDataSerializerTests { Assert.True(iB < iC, $"markerB (width=1,height=100) must precede markerC (width=100,height=1): iB={iB} iC={iC}"); } + // ---- truncated/corrupt new-field rejection (OH2/S1 chunk-2, contract §10.5) ---- + + [Theory] + [InlineData(1)] + [InlineData(5)] + [InlineData(10)] + [InlineData(20)] + [InlineData(46)] + [InlineData(60)] + public void Read_TruncatedTail_ThrowsDeterministically(int bytesRemoved) { + // WithCellShellSubset's one small batch is the object's only + // populated collection, so the LAST ~55 bytes of its serialized + // form span exactly: the four new OH2 fields (SourceSurfaceIndex + // int32, RetailSurfaceMask byte, RawSurfaceType uint32, IsCellShell + // bool -- 10 bytes) immediately followed by the fixed + // BoundingBox+SortCenter+DIDDegrade+SelectionSphere-flag+EdgeLines- + // count tail every ObjectMeshData writes (45 bytes for this + // fixture). Sweeping removal depths across that whole span proves + // every cut inside or around the new fields fails closed -- + // EndOfStreamException from the underlying BinaryReader, never a + // silently substituted default or a misread of a later field as one + // of the new ones. + var data = WithCellShellSubset(); + using var full = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, full); + byte[] fullBytes = full.ToArray(); + + byte[] truncated = fullBytes[..^bytesRemoved]; + + Assert.Throws(() => ObjectMeshDataSerializer.Read(truncated)); + } + + [Fact] + public void Read_EmptyStream_ThrowsDeterministically() { + Assert.Throws(() => ObjectMeshDataSerializer.Read(Array.Empty())); + } + private static int IndexOfSequence(byte[] haystack, byte[] needle) { for (int i = 0; i <= haystack.Length - needle.Length; i++) { bool match = true; diff --git a/tests/AcDream.Content.Tests/PakFormatTests.cs b/tests/AcDream.Content.Tests/PakFormatTests.cs index 4eb1181d..1d425385 100644 --- a/tests/AcDream.Content.Tests/PakFormatTests.cs +++ b/tests/AcDream.Content.Tests/PakFormatTests.cs @@ -9,6 +9,18 @@ public class PakFormatTests { Assert.Equal(64, PakHeader.Size); } + /// + /// OH2/S1 contract §8.3 point 5: the recipe 7 -> 8 bump (exact + /// CellStruct surface-index subset construction) is a serialized + /// PAYLOAD/recipe change, not a container framing change — this pin + /// makes that decision explicit and test-visible rather than implicit. + /// + [Fact] + public void FormatVersion_StaysAtTwo_AcrossTheOH2RecipeBump() { + Assert.Equal(2u, PakFormat.CurrentFormatVersion); + Assert.Equal(8u, PakFormat.CurrentBakeToolVersion); + } + [Fact] public void TocEntry_Size_Is24Bytes() { Assert.Equal(24, PakTocEntry.Size); diff --git a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs index 5cd7f242..ee5dfbbe 100644 --- a/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs +++ b/tests/AcDream.Content.Tests/PreparedAssetSourceTests.cs @@ -135,6 +135,42 @@ public sealed class PreparedAssetSourceTests : IDisposable } } + /// + /// OH2/S1 contract §10.5: an OLDER-recipe (7) package must be rejected + /// by a CURRENT-recipe (8) consumer through the catalog-identity check + /// alone, BEFORE any TextureBatchData payload field is ever + /// deserialized -- so an older pak can never be silently misread as + /// carrying the new SourceSurfaceIndex/RetailSurfaceMask/RawSurfaceType/ + /// IsCellShell fields. + /// + [Fact] + public void Constructor_RejectsOlderRecipePackageBeforePayloadDecode() + { + // PakWriter stamps BakeToolVersion to PakFormat.CurrentBakeToolVersion + // unconditionally (the header-template default-0 footgun guard), so + // an "older recipe" file has to be produced by patching the on-disk + // header dword directly -- the same technique + // PakRoundTripTests.Reader_RejectsWrongFormatVersion uses for the + // sibling FormatVersion field. BakeToolVersion is offset 36 per + // PakHeader's normative layout. + string path = WritePak( + (PakAssetType.EnvCellMesh, 1u, Mesh(1u))); + + using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) + { + fs.Position = 36; + Span buf = stackalloc byte[4]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( + buf, PakFormat.CurrentBakeToolVersion - 1); + fs.Write(buf); + } + + InvalidDataException error = Assert.Throws( + () => new PakPreparedAssetSource(path, Identity)); + Assert.Contains("does not match the installed DAT set", error.Message); + Assert.Contains("Re-bake", error.Message); + } + [Fact] public void Read_PreCanceledTokenPropagatesWithoutStartingRead() { diff --git a/tests/AcDream.Core.Tests/Meshing/CellStructSideCandidatesTests.cs b/tests/AcDream.Core.Tests/Meshing/CellStructSideCandidatesTests.cs new file mode 100644 index 00000000..73496744 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/CellStructSideCandidatesTests.cs @@ -0,0 +1,322 @@ +using AcDream.Core.Meshing; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Tests.Meshing; + +/// +/// 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. +/// +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); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs index 0ff88665..21a73592 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/ContentMigrationCatalogTests.cs @@ -27,4 +27,30 @@ public sealed class ContentMigrationCatalogTests Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase); Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void RecipeSevenToEightRequiresOneExplicitFullRebuild() + { + // OH2/S1: exact CellStruct surface-index subset construction. + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(7, 8); + + Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); + Assert.Equal(7u, plan.FromRecipeVersion); + Assert.Equal(8u, plan.TargetRecipeVersion); + Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Empty(plan.EffectiveDatIds); + Assert.Empty(plan.EffectiveLandblocks); + } + + [Fact] + public void AnyOlderRecipeToEightCollapsesToOneFullRebuild() + { + ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 8); + + Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind); + Assert.Equal(8u, plan.TargetRecipeVersion); + Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase); + } }