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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 18:40:33 +02:00
parent 14d8fe6478
commit acf172469e
15 changed files with 1746 additions and 296 deletions

View file

@ -730,10 +730,84 @@ public sealed class MeshExtractor {
};
}
/// <summary>
/// Per-source-surface-array-index accumulator for
/// <see cref="PrepareCellStructMeshData"/>. Retail's
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0 allocates one
/// <c>MeshBatchType</c> triangle-attribute record and one
/// <c>isStippledOrAlphaedMask</c> 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
/// <see cref="Batch"/>, in source polygon order, regardless of the
/// resolved Surface DID, texture format, or that polygon's own
/// <c>sides_type</c>/stippling value.
/// </summary>
private sealed class CellSurfaceSlot {
public CellSurfaceSlot(Surface surface, uint surfaceId) {
Surface = surface;
SurfaceId = surfaceId;
}
/// <summary>Resolved once per slot; every candidate targeting this slot shares it.</summary>
public Surface Surface { get; }
public uint SurfaceId { get; }
/// <summary>
/// Retail's <c>isStippledOrAlphaedMask</c> byte for this slot:
/// <see cref="CellStructSideCandidates.InitialSurfaceMask"/> once,
/// then <see cref="CellStructSideCandidates.ApplyStipplingMaskBit"/>
/// per polygon candidate touching the slot (contract §3.2).
/// </summary>
public int Mask;
/// <summary>
/// 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.
/// </summary>
public TextureBatchData? Batch;
/// <summary>(Width, Height, TextureFormat) storage-grouping key for <see cref="Batch"/>, set alongside it.</summary>
public (int Width, int Height, TextureFormat Format) Format;
}
/// <summary>
/// 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
/// <c>CPolygon::sides_type</c>
/// (<see cref="CellStructSideCandidates.GetCandidates"/>,
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0, contract §3.4);
/// <c>NoPos</c>/<c>NoNeg</c> mean "this side's UV-index array is absent"
/// only (<c>CPolygon::UnPack</c> @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 — <see cref="CellSurfaceSlot"/> is that
/// per-slot accumulator. A slot whose resolved <c>Surface.Type</c> is
/// untextured (<see cref="RetailUntexturedSurfacePolicy.IsUntextured"/>)
/// is fully constructed but NOT emitted into the prepared output,
/// matching <c>RenderDeviceD3D::DrawEnvCell</c> @0x0059F170 →
/// <c>D3DPolyRender::DrawMesh(..., arg4=1)</c> @0x0059D4A0's built-EnvCell
/// admission test <c>(Surface.Type &amp; (BASE1_IMAGE|BASE1_CLIPMAP)) != 0</c>
/// (contract §4). Retires the AP-234 approximation this method used to
/// carry (docs/architecture/retail-divergence-register.md).
/// </summary>
public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, IReadOnlyList<ushort> surfaceOverrides, Matrix4x4 transform, CancellationToken ct) {
var vertices = new List<VertexPositionNormalTexture>();
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<TextureBatchData>>();
var slots = new Dictionary<int, CellSurfaceSlot>();
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<Surface>(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<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
var renderSurfaceId = surfaceTexture.Textures.First();
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
if (!_dats.HighRes.TryGet<RenderSurface>(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<Surface>(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<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
var renderSurfaceId = surfaceTexture.Textures.First();
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
if (!_dats.HighRes.TryGet<RenderSurface>(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<Palette>(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<Palette>(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<Palette>(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<Palette>(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<TextureBatchData>();
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<CellStructSideCandidate> 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<TextureBatchData>();
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 {
};
}
/// <summary>
/// Builds one candidate's fan vertices/indices for a CellStruct polygon
/// and appends them to <paramref name="indices"/>. Vertex identity and
/// UV-absence fallback follow contract §3.5 as arbitrated on the binary:
/// <c>copyVert</c> @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, <c>ConstructMesh</c>
/// @0x0059E691 <c>xor ebx,ebx</c>), 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
/// <see cref="CellStructSideCandidates.TriangleFanIndices"/> exactly
/// (contract §3.4's forward/reversed table), not a re-derived formula.
/// </summary>
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<VertexPositionNormalTexture> vertices, List<ushort> indices,
bool useNegUv, bool invertNormal, bool reverseWinding,
bool useNegUv, bool uvAbsent, bool invertNormal, bool reverseWinding,
Matrix4x4 transform, ref bool hasWrappingUVs) {
var polyIndices = new List<ushort>();
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]);
}
}

View file

@ -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 {
/// <summary>
/// CPU-side texture info for deduplication during background preparation.
/// </summary>
/// <remarks>
/// OH2/S1 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md):
/// for a CellStruct/EnvCell shell batch (<see cref="IsCellShell"/> true),
/// <see cref="CullMode"/> is FIXED retail raster state applied AFTER
/// geometry has already been fan-expanded per side/copy candidate —
/// <c>RenderMeshSubset</c> @0x0059CA10 always draws a constructed cell
/// shell subset <c>D3DCULL_CW</c> — it is NOT the authored
/// <c>Polygon.SidesType</c> any more. The subset/material OWNER for a cell
/// batch is instead <see cref="SourceSurfaceIndex"/>, the retail source
/// surface-array index (contract §3.6): two cell batches can share a
/// resolved Surface DID/<see cref="TextureKey"/> and still be two distinct
/// subsets, or vice versa. For an ordinary GfxObj batch,
/// <see cref="CullMode"/> keeps its historical meaning and the four
/// cell-only fields below stay at their neutral (non-cell) defaults —
/// <see cref="AcDream.Content.MeshExtractor.PrepareGfxObjMeshData"/> never
/// sets them.
/// </remarks>
public class TextureBatchData {
public TextureKey Key { get; set; }
public byte[] TextureData { get; set; } = Array.Empty<byte>();
@ -155,4 +173,73 @@ public class TextureBatchData {
public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; }
/// <summary>
/// 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.
/// </summary>
public int SourceSurfaceIndex { get; set; } = -1;
/// <summary>
/// Retail's <c>isStippledOrAlphaedMask</c> byte for this surface slot
/// (contract §3.2): <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0's
/// per-surface initial mask
/// (<see cref="CellStructSideCandidates.InitialSurfaceMask"/>) plus
/// every polygon's positive-surface stippling OR
/// (<see cref="CellStructSideCandidates.ApplyStipplingMaskBit"/>). 0
/// (unused) for a non-cell (GfxObj) batch.
/// </summary>
public byte RetailSurfaceMask { get; set; }
/// <summary>
/// The resolved cell surface's raw <c>Surface.Type</c> bits (contract
/// §2.2) — evidence for the built-EnvCell
/// <c>(Type &amp; (BASE1_IMAGE|BASE1_CLIPMAP)) != 0</c> admission
/// decision (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059F170 →
/// <c>D3DPolyRender::DrawMesh</c> @0x0059D4A0, contract §4) that
/// already happened before this batch was ever emitted into
/// <see cref="ObjectMeshData.TextureBatches"/>. 0 (unused) for a
/// non-cell (GfxObj) batch.
/// </summary>
public uint RawSurfaceType { get; set; }
/// <summary>
/// True for a CellStruct/EnvCell shell batch built by
/// <see cref="AcDream.Content.MeshExtractor.PrepareCellStructMeshData"/>.
/// Ordinary GfxObj batches leave this false.
/// </summary>
public bool IsCellShell { get; set; }
}
/// <summary>
/// OH2/S1 chunk-2 (contract §9 item 6): the one place that recovers a
/// prepared CellStruct mesh's surface-array-index subset order.
/// <see cref="ObjectMeshData.TextureBatches"/> groups
/// <see cref="TextureBatchData"/> by (Width, Height, Format) for
/// atlas/texture-dedup STORAGE only (contract §3.6 point 5) — the
/// SEMANTIC subset order is always ascending
/// <see cref="TextureBatchData.SourceSurfaceIndex"/>, 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 <c>TextureBatches</c> directly.
/// </summary>
public static class CellSurfaceSubsets {
/// <summary>
/// Every <see cref="TextureBatchData"/> in <paramref name="mesh"/> with
/// <see cref="TextureBatchData.IsCellShell"/> set, ordered ascending by
/// <see cref="TextureBatchData.SourceSurfaceIndex"/>. An untextured
/// slot that retail's built-EnvCell admission skipped
/// (<see cref="AcDream.Core.Meshing.RetailUntexturedSurfacePolicy"/>)
/// was never emitted into <see cref="ObjectMeshData.TextureBatches"/>
/// in the first place, so it is absent here too — this enumerates
/// DRAWABLE subsets, not every constructed slot.
/// </summary>
public static IEnumerable<TextureBatchData> InAscendingSurfaceOrder(ObjectMeshData mesh) =>
mesh.TextureBatches.Values
.SelectMany(batches => batches)
.Where(batch => batch.IsCellShell)
.OrderBy(batch => batch.SourceSurfaceIndex);
}

View file

@ -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;
}

View file

@ -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 &amp; (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.
/// </summary>
public const uint CurrentBakeToolVersion = 7;
public const uint CurrentBakeToolVersion = 8;
}
/// <summary>

View file

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

View file

@ -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)

View file

@ -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()
{