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