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:
parent
14d8fe6478
commit
acf172469e
15 changed files with 1746 additions and 296 deletions
|
|
@ -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;
|
- the same authored vertex and UV may be shared within one sign lane;
|
||||||
- positive- and negative-normal copies cannot alias;
|
- positive- and negative-normal copies cannot alias;
|
||||||
- a null UV map uses UV index `0`, rather than suppressing the candidate;
|
- a null UV map uses UV index `0`, rather than suppressing the candidate.
|
||||||
- `copyVert @0x0059C080` writes zero UV coordinates when the UV pointer is
|
**Arbitrated on the PDB-paired binary 2026-09-02 (S1 chunk A2 review):**
|
||||||
absent or the index is out of range (`pseudo-C:424797-424829`);
|
`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`
|
- `copyVert` multiplies the authored normal by the selected `+1/-1`
|
||||||
(`424791-424793`).
|
(`424791-424793`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 & (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) {
|
public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, IReadOnlyList<ushort> surfaceOverrides, Matrix4x4 transform, CancellationToken ct) {
|
||||||
var vertices = new List<VertexPositionNormalTexture>();
|
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 batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
|
||||||
|
var slots = new Dictionary<int, CellSurfaceSlot>();
|
||||||
|
|
||||||
var min = new Vector3(float.MaxValue);
|
var min = new Vector3(float.MaxValue);
|
||||||
var max = new Vector3(float.MinValue);
|
var max = new Vector3(float.MinValue);
|
||||||
|
|
@ -744,279 +818,346 @@ public sealed class MeshExtractor {
|
||||||
}
|
}
|
||||||
var boundingBox = new BoundingBox(min, max);
|
var boundingBox = new BoundingBox(min, max);
|
||||||
|
|
||||||
foreach (var poly in cellStruct.Polygons.Values) {
|
int unknownSidesTypePolygons = 0;
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
if (poly.VertexIds.Count < 3) continue;
|
|
||||||
|
|
||||||
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
|
bool TryResolveSlot(int slot, out Surface surface, out uint surfaceId) {
|
||||||
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
|
if (slot < surfaceOverrides.Count) {
|
||||||
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
|
surfaceId = 0x08000000u | surfaceOverrides[slot];
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
|
else {
|
||||||
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
|
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 (!_dats.Portal.TryGet<Surface>(surfaceId, out surface!)) {
|
||||||
if (surfaceIdx < 0) return;
|
// 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;
|
// Resolves and decodes the texture payload for ONE surface slot,
|
||||||
if (surfaceIdx < surfaceOverrides.Count) {
|
// exactly once. Identical decode logic to the ordinary-GfxObj path
|
||||||
surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx];
|
// (PrepareGfxObjMeshData) — deliberately duplicated rather than
|
||||||
}
|
// shared, so a future change to one extraction path cannot silently
|
||||||
else {
|
// change the other's behavior (contract §5.2's "Core and Content do
|
||||||
_logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}");
|
// not retain divergent CellStruct interpretations" is about NOT
|
||||||
return;
|
// 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)) {
|
texWidth = renderSurface.Width;
|
||||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
texHeight = renderSurface.Height;
|
||||||
Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})");
|
paletteId = renderSurface.DefaultPaletteId;
|
||||||
return;
|
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)) {
|
||||||
int texWidth, texHeight;
|
textureData = cachedData;
|
||||||
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);
|
|
||||||
textureFormat = TextureFormat.RGBA8;
|
textureFormat = TextureFormat.RGBA8;
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
textureDataIsCached = true;
|
textureDataIsCached = true;
|
||||||
}
|
}
|
||||||
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
|
else {
|
||||||
var renderSurfaceId = surfaceTexture.Textures.First();
|
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
||||||
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
|
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||||
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;
|
|
||||||
textureFormat = TextureFormat.RGBA8;
|
textureFormat = TextureFormat.RGBA8;
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
textureDataIsCached = true;
|
|
||||||
|
textureData = _decodedTextureCache.GetOrCreate(
|
||||||
|
decodedTextureKey,
|
||||||
|
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
||||||
|
out textureDataIsCached);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
textureFormat = TextureFormat.RGBA8;
|
||||||
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
textureData = renderSurface.SourceData;
|
||||||
textureFormat = TextureFormat.RGBA8;
|
switch (renderSurface.Format) {
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
||||||
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
textureData = _decodedTextureCache.GetOrCreate(
|
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||||
decodedTextureKey,
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
break;
|
||||||
out textureDataIsCached);
|
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
||||||
}
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
else {
|
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||||
textureFormat = TextureFormat.RGBA8;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
textureData = renderSurface.SourceData;
|
break;
|
||||||
switch (renderSurface.Format) {
|
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
|
||||||
textureData = new byte[texWidth * texHeight * 4];
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
break;
|
break;
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
||||||
textureData = new byte[texWidth * texHeight * 4];
|
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
|
||||||
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
||||||
break;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
break;
|
||||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
|
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
||||||
textureData = new byte[texWidth * texHeight * 4];
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
break;
|
break;
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
||||||
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
textureData = new byte[texWidth * texHeight * 4];
|
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||||
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
break;
|
||||||
break;
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
||||||
textureData = new byte[texWidth * texHeight * 4];
|
textureData = new byte[texWidth * texHeight * 4];
|
||||||
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
if (surface.Type.HasFlag(SurfaceType.Additive)) {
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||||
break;
|
}
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
else {
|
||||||
textureData = new byte[texWidth * texHeight * 4];
|
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
||||||
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
}
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
break;
|
break;
|
||||||
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
default: return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isClipMap && textureData != null) {
|
if (!TextureHelpers.IsCompressedFormat(renderSurface.Format))
|
||||||
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
|
{
|
||||||
if (textureDataIsCached) {
|
textureData = _decodedTextureCache.RetainOrUse(
|
||||||
var clonedData = new byte[textureData.Length];
|
decodedTextureKey,
|
||||||
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
textureData,
|
||||||
textureData = clonedData;
|
out textureDataIsCached);
|
||||||
textureDataIsCached = false;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < textureData.Length; i += 4) {
|
if (isClipMap && textureData != null) {
|
||||||
if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) {
|
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
|
||||||
textureData[i + 3] = 0;
|
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)
|
else {
|
||||||
Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})");
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
||||||
return;
|
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);
|
isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive);
|
||||||
isTransparent = isSolid ? surface.ColorValue.Alpha < 255 :
|
isTransparent = isSolid ? surface.ColorValue.Alpha < 255 :
|
||||||
(surface.Type.HasFlag(SurfaceType.Translucent) ||
|
(surface.Type.HasFlag(SurfaceType.Translucent) ||
|
||||||
surface.Type.HasFlag(SurfaceType.Base1ClipMap) ||
|
surface.Type.HasFlag(SurfaceType.Base1ClipMap) ||
|
||||||
((uint)surface.Type & 0x100) != 0 || // Alpha
|
((uint)surface.Type & 0x100) != 0 || // Alpha
|
||||||
((uint)surface.Type & 0x200) != 0 || // InvAlpha
|
((uint)surface.Type & 0x200) != 0 || // InvAlpha
|
||||||
isAdditive ||
|
isAdditive ||
|
||||||
(surface.Translucency > 0.0f && surface.Translucency < 1.0f) ||
|
(surface.Translucency > 0.0f && surface.Translucency < 1.0f) ||
|
||||||
textureFormat == TextureFormat.A8 ||
|
textureFormat == TextureFormat.A8 ||
|
||||||
textureFormat == TextureFormat.Rgba32f ||
|
textureFormat == TextureFormat.Rgba32f ||
|
||||||
isDxt3or5 ||
|
isDxt3or5 ||
|
||||||
(sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 ||
|
(sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 ||
|
||||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 ||
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 ||
|
||||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
|
||||||
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
|
||||||
|
|
||||||
var format = (texWidth, texHeight, textureFormat);
|
state.Batch = new TextureBatchData {
|
||||||
var key = new TextureKey {
|
Key = new TextureKey {
|
||||||
SurfaceId = surfaceId,
|
SurfaceId = surfaceId,
|
||||||
PaletteId = paletteId,
|
PaletteId = paletteId,
|
||||||
Stippling = poly.Stippling,
|
// Contract §3.6 point 3 / §8.2: the subset owner is the
|
||||||
IsSolid = isSolid
|
// 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)) {
|
foreach (var poly in cellStruct.Polygons.Values) {
|
||||||
batches = new List<TextureBatchData>();
|
ct.ThrowIfCancellationRequested();
|
||||||
batchesByFormat[format] = batches;
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
}
|
|
||||||
|
|
||||||
var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType);
|
// OH2/S1 chunk-1: side candidates come ONLY from sides_type
|
||||||
if (batch == null) {
|
// (CellStructSideCandidates.GetCandidates, contract §3.4). This
|
||||||
batch = new TextureBatchData {
|
// DatReaderWriter "CullMode" field IS the raw retail
|
||||||
Key = key,
|
// sides_type integer (0/1/2), not a GPU cull enum — see
|
||||||
CullMode = poly.SidesType,
|
// CellStructSideCandidates' own remarks. NoPos/NoNeg (below,
|
||||||
TextureData = textureData!,
|
// IsUvAbsent) govern UV-array absence only (§3.5) and no
|
||||||
UploadPixelFormat = uploadPixelFormat,
|
// longer suppress a candidate.
|
||||||
UploadPixelType = uploadPixelType,
|
ReadOnlySpan<CellStructSideCandidate> candidates =
|
||||||
Translucency =
|
CellStructSideCandidates.GetCandidates((int)poly.SidesType);
|
||||||
TranslucencyKindExtensions.FromSurfaceType(
|
if (candidates.Length == 0) {
|
||||||
surface.Type),
|
// Contract §3.4 closing paragraph: only 0/1/2 are
|
||||||
IsTransparent = isTransparent,
|
// retail-defined. Quarantine/report, don't invent a shape.
|
||||||
IsAdditive = isAdditive
|
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
|
// Contract §3.2: the per-polygon mask OR always targets the
|
||||||
bool batchHasWrappingUVs = batch.HasWrappingUVs;
|
// 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(
|
BuildCellStructPolygonIndices(
|
||||||
poly,
|
poly,
|
||||||
cellStruct,
|
cellStruct,
|
||||||
UVLookup,
|
vertexLookup,
|
||||||
vertices,
|
vertices,
|
||||||
batch.Indices,
|
slotState.Batch.Indices,
|
||||||
useNegUv,
|
useNegUv,
|
||||||
|
uvAbsent,
|
||||||
invertNormal,
|
invertNormal,
|
||||||
reverseWinding,
|
candidate.ReverseWinding,
|
||||||
transform,
|
transform,
|
||||||
ref batchHasWrappingUVs);
|
ref hasWrappingUVs);
|
||||||
batch.HasWrappingUVs = batchHasWrappingUVs;
|
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 {
|
return new ObjectMeshData {
|
||||||
ObjectId = id,
|
ObjectId = id,
|
||||||
IsSetup = false,
|
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,
|
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,
|
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) {
|
Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||||
|
|
||||||
var polyIndices = new List<ushort>();
|
var polyIndices = new List<ushort>();
|
||||||
|
|
||||||
for (int i = 0; i < poly.VertexIds.Count; i++) {
|
for (int i = 0; i < poly.VertexIds.Count; i++) {
|
||||||
ushort vertId = (ushort)poly.VertexIds[i];
|
ushort vertId = (ushort)poly.VertexIds[i];
|
||||||
ushort uvIdx = 0;
|
|
||||||
|
|
||||||
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
// Retail's UV-index selection, arbitrated 2026-09-02 on the
|
||||||
uvIdx = poly.NegUVIndices[i];
|
// PDB-paired binary (ConstructMesh @0x0059E683-0x0059E693):
|
||||||
else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
// mov edi,[uv-index array]; test edi,edi; je -> xor ebx,ebx
|
||||||
uvIdx = poly.PosUVIndices[i];
|
// 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 (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
||||||
|
|
||||||
if (uvIdx >= vertex.UVs.Count) {
|
bool uvInRange = uvIdxSigned >= 0 && uvIdxSigned < vertex.UVs.Count;
|
||||||
uvIdx = 0;
|
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) {
|
if (!hasWrappingUVs && uvInRange) {
|
||||||
var uvCheck = vertex.UVs.Count > 0
|
if (uv.X < 0f || uv.X > 1f || uv.Y < 0f || uv.Y > 1f) {
|
||||||
? 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) {
|
|
||||||
hasWrappingUVs = true;
|
hasWrappingUVs = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!UVLookup.TryGetValue(key, out var idx)) {
|
if (!vertexLookup.TryGetValue(key, out var idx)) {
|
||||||
var uv = vertex.UVs.Count > 0
|
|
||||||
? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V)
|
|
||||||
: Vector2.Zero;
|
|
||||||
|
|
||||||
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
||||||
if (invertNormal) {
|
if (invertNormal) {
|
||||||
|
|
@ -1078,24 +1248,20 @@ public sealed class MeshExtractor {
|
||||||
normal,
|
normal,
|
||||||
uv
|
uv
|
||||||
));
|
));
|
||||||
UVLookup[key] = idx;
|
vertexLookup[key] = idx;
|
||||||
}
|
}
|
||||||
polyIndices.Add(idx);
|
polyIndices.Add(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reverseWinding) {
|
// CellStructSideCandidates.TriangleFanIndices is the single source
|
||||||
for (int i = 2; i < polyIndices.Count; i++) {
|
// of truth for retail's forward/reversed fan order (contract §3.4);
|
||||||
indices.Add(polyIndices[i]);
|
// this loop drives it rather than re-deriving the index arithmetic.
|
||||||
indices.Add(polyIndices[i - 1]);
|
int triangleCount = polyIndices.Count - 2;
|
||||||
indices.Add(polyIndices[0]);
|
for (int t = 0; t < triangleCount; t++) {
|
||||||
}
|
var (a, b, c) = CellStructSideCandidates.TriangleFanIndices(t, reverseWinding);
|
||||||
}
|
indices.Add(polyIndices[a]);
|
||||||
else {
|
indices.Add(polyIndices[b]);
|
||||||
for (int i = 2; i < polyIndices.Count; i++) {
|
indices.Add(polyIndices[c]);
|
||||||
indices.Add(polyIndices[0]);
|
|
||||||
indices.Add(polyIndices[i - 1]);
|
|
||||||
indices.Add(polyIndices[i]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
|
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
|
||||||
|
|
@ -143,6 +144,23 @@ public class MeshBatchData {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CPU-side texture info for deduplication during background preparation.
|
/// CPU-side texture info for deduplication during background preparation.
|
||||||
/// </summary>
|
/// </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 class TextureBatchData {
|
||||||
public TextureKey Key { get; set; }
|
public TextureKey Key { get; set; }
|
||||||
public byte[] TextureData { get; set; } = Array.Empty<byte>();
|
public byte[] TextureData { get; set; } = Array.Empty<byte>();
|
||||||
|
|
@ -155,4 +173,73 @@ public class TextureBatchData {
|
||||||
public bool IsTransparent { get; set; }
|
public bool IsTransparent { get; set; }
|
||||||
public bool IsAdditive { get; set; }
|
public bool IsAdditive { get; set; }
|
||||||
public bool HasWrappingUVs { 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 & (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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,12 @@ public static class ObjectMeshDataSerializer {
|
||||||
w.Write(batch.IsTransparent);
|
w.Write(batch.IsTransparent);
|
||||||
w.Write(batch.IsAdditive);
|
w.Write(batch.IsAdditive);
|
||||||
w.Write(batch.HasWrappingUVs);
|
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(
|
private static TextureBatchData ReadTextureBatchData(
|
||||||
|
|
@ -241,6 +247,11 @@ public static class ObjectMeshDataSerializer {
|
||||||
batch.IsTransparent = r.ReadBoolean();
|
batch.IsTransparent = r.ReadBoolean();
|
||||||
batch.IsAdditive = r.ReadBoolean();
|
batch.IsAdditive = r.ReadBoolean();
|
||||||
batch.HasWrappingUVs = 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;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,18 @@ public static class PakFormat {
|
||||||
/// 7 replaces the synthetic vertex-AABB GfxObj view sphere with retail's
|
/// 7 replaces the synthetic vertex-AABB GfxObj view sphere with retail's
|
||||||
/// authored DrawingBSP root sphere. The binary format remains version 2,
|
/// authored DrawingBSP root sphere. The binary format remains version 2,
|
||||||
/// but every prepared GfxObj render record must be regenerated because
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint CurrentBakeToolVersion = 7;
|
public const uint CurrentBakeToolVersion = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
267
src/AcDream.Core/Meshing/CellStructSideCandidates.cs
Normal file
267
src/AcDream.Core/Meshing/CellStructSideCandidates.cs
Normal 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 & 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 & NO_POS_UVS) != 0</c> (bit 4) and
|
||||||
|
/// <c>neg_uv_indices</c> when <c>(stippling & NO_NEG_UVS) != 0</c>
|
||||||
|
/// (bit 8) — see §2.3. Absence means <c>copyVert</c> @0x0059C080 writes
|
||||||
|
/// UV index/coordinates of zero (pseudo-C 424797-424829); it never
|
||||||
|
/// removes the candidate. Callers must construct the candidate from
|
||||||
|
/// <see cref="GetCandidates"/> regardless of this result and only use
|
||||||
|
/// it to pick the zero-UV fallback path.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsUvAbsent(CellStructSideCandidate candidate, StipplingType stippling)
|
||||||
|
{
|
||||||
|
var absenceBit = candidate.UvSlot == CellStructPolygonSurfaceSide.Positive
|
||||||
|
? StipplingType.NoPos
|
||||||
|
: StipplingType.NoNeg;
|
||||||
|
return (stippling & absenceBit) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpha-family surfaces take mask precedence over clip-map, which takes
|
||||||
|
// precedence over translucent. Contract §3.2: "if (type & 0x10300) != 0:
|
||||||
|
// mask = 2" — 0x10300 = Additive(0x10000) | InvAlpha(0x200) | Alpha(0x100).
|
||||||
|
private const SurfaceType AlphaFamilyMask =
|
||||||
|
SurfaceType.Alpha | SurfaceType.InvAlpha | SurfaceType.Additive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's initial per-surface mask byte
|
||||||
|
/// (<c>MeshBuffer::isStippledOrAlphaedMask</c>), derived solely from the
|
||||||
|
/// surface's own raw <c>Type</c> flags, per contract §3.2
|
||||||
|
/// (pseudo-C 426788-426818) with this exact branch precedence:
|
||||||
|
/// alpha/invalpha/additive (mask 2) is checked first, then clip-map
|
||||||
|
/// (mask 8), then translucent (mask 4); anything else starts at 0.
|
||||||
|
/// This is a per-surface fact, computed once per surface before any
|
||||||
|
/// polygon is processed — <see cref="ApplyStipplingMaskBit"/> is the
|
||||||
|
/// separate per-polygon update layered on top of it.
|
||||||
|
/// </summary>
|
||||||
|
public static int InitialSurfaceMask(SurfaceType type)
|
||||||
|
{
|
||||||
|
if ((type & AlphaFamilyMask) != 0) return 2;
|
||||||
|
if ((type & SurfaceType.Base1ClipMap) != 0) return 8;
|
||||||
|
if ((type & SurfaceType.Translucent) != 0) return 4;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's per-polygon mask update, per contract §3.2
|
||||||
|
/// (pseudo-C 426864-426871): for each polygon, retail ORs bit 1 into
|
||||||
|
/// ONLY the positive surface's mask when the polygon's raw
|
||||||
|
/// <c>stippling</c> byte, reinterpreted as a SIGNED byte, is greater
|
||||||
|
/// than zero (a signed <c>SETG</c> comparison, not a raw-nonzero test).
|
||||||
|
/// This is deliberately broader than the low two stipple-side bits:
|
||||||
|
/// every defined nonzero <see cref="StipplingType"/> value — including
|
||||||
|
/// <c>NoPos</c>/<c>NoNeg</c> — is positive as a signed byte and sets
|
||||||
|
/// bit 0; only corrupt raw values in 0x80..0xFF (negative as a signed
|
||||||
|
/// byte) do not. The update is unconditionally aimed at the POSITIVE
|
||||||
|
/// surface regardless of which specific stippling bits are set — it is
|
||||||
|
/// not "positive-vs-negative-stippling" semantics, it is "which surface
|
||||||
|
/// slot does this candidate own": pass a candidate whose
|
||||||
|
/// <see cref="CellStructSideCandidate.SurfaceSlot"/> is
|
||||||
|
/// <see cref="CellStructPolygonSurfaceSide.Negative"/> (the
|
||||||
|
/// <c>ST_BOTH</c> negative candidate) and this method leaves
|
||||||
|
/// <paramref name="currentMask"/> untouched, even for a positive raw
|
||||||
|
/// stippling value — retail never ORs this bit into the negative
|
||||||
|
/// surface's mask.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="DatReaderWriter.Types.Polygon.Stippling"/> is backed by
|
||||||
|
/// an unsigned <see cref="byte"/> (Chorizite.DatReaderWriter 2.1.7,
|
||||||
|
/// verified by reflection 2026-09-02), unlike retail's signed
|
||||||
|
/// <c>char stippling</c> (contract §2.1). This method performs the
|
||||||
|
/// signed reinterpretation internally so callers can pass
|
||||||
|
/// <c>poly.Stippling</c> directly without knowing about the signed-byte
|
||||||
|
/// nuance.
|
||||||
|
/// </remarks>
|
||||||
|
public static int ApplyStipplingMaskBit(
|
||||||
|
int currentMask,
|
||||||
|
CellStructPolygonSurfaceSide candidateSurfaceSlot,
|
||||||
|
StipplingType stippling)
|
||||||
|
{
|
||||||
|
if (candidateSurfaceSlot != CellStructPolygonSurfaceSide.Positive) return currentMask;
|
||||||
|
|
||||||
|
var signedStippling = unchecked((sbyte)(byte)stippling);
|
||||||
|
return signedStippling > 0 ? currentMask | 1 : currentMask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,10 @@ public static class ContentMigrationCatalog
|
||||||
6,
|
6,
|
||||||
7,
|
7,
|
||||||
"GfxObj portal admission now uses the authored DrawingBSP sphere"),
|
"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)
|
public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,11 @@ public sealed class LauncherInstallRecordStore
|
||||||
{
|
{
|
||||||
// Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion
|
// Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion
|
||||||
// Version 7 keeps pak format 2 and regenerates every GfxObj render record
|
// Version 7 keeps pak format 2 and regenerates every GfxObj render record
|
||||||
// with retail's authored DrawingBSP view sphere. It is a mandatory full
|
// with retail's authored DrawingBSP view sphere. Version 8 (OH2/S1) keeps
|
||||||
// rebuild from recipe 6.
|
// pak format 2 and regenerates every CellStruct/EnvCell render record
|
||||||
public const uint CurrentBakeToolVersion = 7;
|
// 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()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -222,9 +222,166 @@ public sealed class MeshExtractorSolidFaceExtractionTests
|
||||||
Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length);
|
Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private static void RegisterCellTexturedSurface(FakeMeshExtractorDats dats)
|
||||||
|
{
|
||||||
|
dats.Register(TexturedSurfaceId, new Surface
|
||||||
|
{
|
||||||
|
Type = SurfaceType.Base1Image,
|
||||||
|
OrigTextureId = SurfaceTextureId,
|
||||||
|
});
|
||||||
|
dats.Register(SurfaceTextureId, new SurfaceTexture
|
||||||
|
{
|
||||||
|
Textures = new List<QualifiedDataId<RenderSurface>> { RenderSurfaceId },
|
||||||
|
});
|
||||||
|
dats.Register(RenderSurfaceId, new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 1,
|
||||||
|
Height = 1,
|
||||||
|
Format = PixelFormat.PFID_A8R8G8B8,
|
||||||
|
SourceData = new byte[] { 10, 20, 30, 255 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PrepareCellStructMeshData_LandblockSide_PreservesRetailTriangleFanWinding()
|
public void PrepareCellStructMeshData_LandblockSide_PreservesRetailTriangleFanWinding()
|
||||||
{
|
{
|
||||||
|
var dats = new FakeMeshExtractorDats();
|
||||||
|
RegisterCellTexturedSurface(dats);
|
||||||
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
||||||
|
|
||||||
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
||||||
|
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<ObjectMeshData>(
|
||||||
|
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<QualifiedDataId<RenderSurface>> { 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<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
// 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<ObjectMeshData>(
|
||||||
|
extractor.PrepareCellStructMeshData(
|
||||||
|
id: 1, cellStruct, surfaceOverrides: [10, 20], Matrix4x4.Identity, CancellationToken.None));
|
||||||
|
|
||||||
|
List<TextureBatchData> 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();
|
var dats = new FakeMeshExtractorDats();
|
||||||
dats.Register(SolidSurfaceId, new Surface
|
dats.Register(SolidSurfaceId, new Surface
|
||||||
{
|
{
|
||||||
|
|
@ -241,40 +398,279 @@ public sealed class MeshExtractorSolidFaceExtractionTests
|
||||||
Matrix4x4.Identity,
|
Matrix4x4.Identity,
|
||||||
CancellationToken.None));
|
CancellationToken.None));
|
||||||
|
|
||||||
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
|
Assert.Empty(mesh.TextureBatches);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
var dats = new FakeMeshExtractorDats();
|
||||||
dats.Register(SolidSurfaceId, new Surface
|
RegisterCellTexturedSurface(dats);
|
||||||
|
var cellStruct = new CellStruct
|
||||||
{
|
{
|
||||||
Type = SurfaceType.Base1Solid,
|
VertexArray = new VertexArray
|
||||||
ColorValue = new ColorARGB { Alpha = 255, Red = 64, Green = 96, Blue = 128 },
|
{
|
||||||
});
|
Vertices = new Dictionary<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
[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);
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
||||||
|
|
||||||
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
||||||
extractor.PrepareCellStructMeshData(
|
extractor.PrepareCellStructMeshData(
|
||||||
id: 1,
|
id: 1, cellStruct, surfaceOverrides: [2], Matrix4x4.Identity, CancellationToken.None));
|
||||||
BuildQuadCellStruct(RetailCullMode.None),
|
|
||||||
surfaceOverrides: [1],
|
|
||||||
Matrix4x4.Identity,
|
|
||||||
CancellationToken.None));
|
|
||||||
|
|
||||||
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
|
TextureBatchData batch = Assert.Single(Assert.Single(mesh.TextureBatches).Value);
|
||||||
Assert.Equal(RetailCullMode.None, batch.CullMode);
|
Assert.False(batch.Key.IsSolid);
|
||||||
Assert.Equal(
|
Assert.Equal(4, mesh.Vertices.Length);
|
||||||
[0, 1, 2, 0, 2, 3, 6, 5, 4, 7, 6, 4],
|
Assert.All(mesh.Vertices, vertex => Assert.Equal(new Vector2(0.5f, 0.5f), vertex.UV));
|
||||||
batch.Indices);
|
}
|
||||||
Assert.Equal(8, mesh.Vertices.Length);
|
|
||||||
Assert.All(mesh.Vertices.Take(4), vertex => Assert.Equal(Vector3.UnitZ, vertex.Normal));
|
[Fact]
|
||||||
Assert.All(mesh.Vertices.Skip(4), vertex => Assert.Equal(-Vector3.UnitZ, vertex.Normal));
|
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<ObjectMeshData>(
|
||||||
|
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<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
[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<ObjectMeshData>(
|
||||||
|
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<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
[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<ObjectMeshData>(
|
||||||
|
extractor.PrepareCellStructMeshData(
|
||||||
|
id: 1, cellStruct, surfaceOverrides: [2, 2], Matrix4x4.Identity, CancellationToken.None));
|
||||||
|
|
||||||
|
List<TextureBatchData> 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<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
[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<ObjectMeshData>(
|
||||||
|
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<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
[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<ObjectMeshData>(
|
||||||
|
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<QualifiedDataId<RenderSurface>> { 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<QualifiedDataId<RenderSurface>> { 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<ushort, SWVertex>
|
||||||
|
{
|
||||||
|
[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<ushort, Polygon>
|
||||||
|
{
|
||||||
|
// 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<ObjectMeshData>(
|
||||||
|
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<int> order = CellSurfaceSubsets.InAscendingSurfaceOrder(mesh)
|
||||||
|
.Select(b => b.SourceSurfaceIndex)
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal([2, 5], order);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RegisterTexturedQuad(
|
private static void RegisterTexturedQuad(
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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(
|
private static void AssertTextureBatchesEqual(
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,54 @@ public class ObjectMeshDataSerializerTests {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static ObjectMeshData WithCellShellSubset() {
|
||||||
|
var data = EmptyObject();
|
||||||
|
data.ObjectId = 0x0700_0001u;
|
||||||
|
data.TextureBatches[(64, 64, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
||||||
|
new() {
|
||||||
|
Key = new TextureKey { SurfaceId = 0x08000BFFu, PaletteId = 0, Stippling = StipplingType.None, IsSolid = false },
|
||||||
|
TextureData = new byte[] { 1, 2, 3, 4 },
|
||||||
|
Indices = new List<ushort> { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static ObjectMeshData WithGfxObjNeutralDefaults() {
|
||||||
|
var data = EmptyObject();
|
||||||
|
data.ObjectId = 0x0700_0002u;
|
||||||
|
data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
||||||
|
new() {
|
||||||
|
Key = new TextureKey { SurfaceId = 0x08000001u, PaletteId = 0, Stippling = StipplingType.Positive, IsSolid = true },
|
||||||
|
TextureData = new byte[] { 9, 9, 9, 9 },
|
||||||
|
Indices = new List<ushort> { 0, 1, 2 },
|
||||||
|
CullMode = CullMode.None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
public static IEnumerable<object[]> AllFixtures() {
|
public static IEnumerable<object[]> AllFixtures() {
|
||||||
yield return new object[] { EmptyObject() };
|
yield return new object[] { EmptyObject() };
|
||||||
yield return new object[] { VerticesAndIndicesOnly() };
|
yield return new object[] { VerticesAndIndicesOnly() };
|
||||||
|
|
@ -186,6 +234,21 @@ public class ObjectMeshDataSerializerTests {
|
||||||
yield return new object[] { WithNullableFieldsAbsent() };
|
yield return new object[] { WithNullableFieldsAbsent() };
|
||||||
yield return new object[] { WithEdgeLines() };
|
yield return new object[] { WithEdgeLines() };
|
||||||
yield return new object[] { WithNestedEnvCellGeometry() };
|
yield return new object[] { WithNestedEnvCellGeometry() };
|
||||||
|
yield return new object[] { WithCellShellSubset() };
|
||||||
|
yield return new object[] { WithGfxObjNeutralDefaults() };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithGfxObjNeutralDefaults_Fixture_HasClassLevelNeutralDefaults() {
|
||||||
|
// Sanity check on the fixture itself (not the serializer): proves
|
||||||
|
// the "neutral default" claim is a property of TextureBatchData's
|
||||||
|
// own field initializers, not something MeshExtractor's GfxObj path
|
||||||
|
// has to opt into.
|
||||||
|
TextureBatchData batch = Assert.Single(Assert.Single(WithGfxObjNeutralDefaults().TextureBatches).Value);
|
||||||
|
Assert.Equal(-1, batch.SourceSurfaceIndex);
|
||||||
|
Assert.Equal((byte)0, batch.RetailSurfaceMask);
|
||||||
|
Assert.Equal(0u, batch.RawSurfaceType);
|
||||||
|
Assert.False(batch.IsCellShell);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- round-trip tests ----------------------------------------------------
|
// ---- round-trip tests ----------------------------------------------------
|
||||||
|
|
@ -286,6 +349,43 @@ public class ObjectMeshDataSerializerTests {
|
||||||
Assert.True(iB < iC, $"markerB (width=1,height=100) must precede markerC (width=100,height=1): iB={iB} iC={iC}");
|
Assert.True(iB < iC, $"markerB (width=1,height=100) must precede markerC (width=100,height=1): iB={iB} iC={iC}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- truncated/corrupt new-field rejection (OH2/S1 chunk-2, contract §10.5) ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(5)]
|
||||||
|
[InlineData(10)]
|
||||||
|
[InlineData(20)]
|
||||||
|
[InlineData(46)]
|
||||||
|
[InlineData(60)]
|
||||||
|
public void Read_TruncatedTail_ThrowsDeterministically(int bytesRemoved) {
|
||||||
|
// WithCellShellSubset's one small batch is the object's only
|
||||||
|
// populated collection, so the LAST ~55 bytes of its serialized
|
||||||
|
// form span exactly: the four new OH2 fields (SourceSurfaceIndex
|
||||||
|
// int32, RetailSurfaceMask byte, RawSurfaceType uint32, IsCellShell
|
||||||
|
// bool -- 10 bytes) immediately followed by the fixed
|
||||||
|
// BoundingBox+SortCenter+DIDDegrade+SelectionSphere-flag+EdgeLines-
|
||||||
|
// count tail every ObjectMeshData writes (45 bytes for this
|
||||||
|
// fixture). Sweeping removal depths across that whole span proves
|
||||||
|
// every cut inside or around the new fields fails closed --
|
||||||
|
// EndOfStreamException from the underlying BinaryReader, never a
|
||||||
|
// silently substituted default or a misread of a later field as one
|
||||||
|
// of the new ones.
|
||||||
|
var data = WithCellShellSubset();
|
||||||
|
using var full = new MemoryStream();
|
||||||
|
ObjectMeshDataSerializer.Write(data, full);
|
||||||
|
byte[] fullBytes = full.ToArray();
|
||||||
|
|
||||||
|
byte[] truncated = fullBytes[..^bytesRemoved];
|
||||||
|
|
||||||
|
Assert.Throws<EndOfStreamException>(() => ObjectMeshDataSerializer.Read(truncated));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_EmptyStream_ThrowsDeterministically() {
|
||||||
|
Assert.Throws<EndOfStreamException>(() => ObjectMeshDataSerializer.Read(Array.Empty<byte>()));
|
||||||
|
}
|
||||||
|
|
||||||
private static int IndexOfSequence(byte[] haystack, byte[] needle) {
|
private static int IndexOfSequence(byte[] haystack, byte[] needle) {
|
||||||
for (int i = 0; i <= haystack.Length - needle.Length; i++) {
|
for (int i = 0; i <= haystack.Length - needle.Length; i++) {
|
||||||
bool match = true;
|
bool match = true;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,18 @@ public class PakFormatTests {
|
||||||
Assert.Equal(64, PakHeader.Size);
|
Assert.Equal(64, PakHeader.Size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OH2/S1 contract §8.3 point 5: the recipe 7 -> 8 bump (exact
|
||||||
|
/// CellStruct surface-index subset construction) is a serialized
|
||||||
|
/// PAYLOAD/recipe change, not a container framing change — this pin
|
||||||
|
/// makes that decision explicit and test-visible rather than implicit.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FormatVersion_StaysAtTwo_AcrossTheOH2RecipeBump() {
|
||||||
|
Assert.Equal(2u, PakFormat.CurrentFormatVersion);
|
||||||
|
Assert.Equal(8u, PakFormat.CurrentBakeToolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TocEntry_Size_Is24Bytes() {
|
public void TocEntry_Size_Is24Bytes() {
|
||||||
Assert.Equal(24, PakTocEntry.Size);
|
Assert.Equal(24, PakTocEntry.Size);
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,42 @@ public sealed class PreparedAssetSourceTests : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OH2/S1 contract §10.5: an OLDER-recipe (7) package must be rejected
|
||||||
|
/// by a CURRENT-recipe (8) consumer through the catalog-identity check
|
||||||
|
/// alone, BEFORE any TextureBatchData payload field is ever
|
||||||
|
/// deserialized -- so an older pak can never be silently misread as
|
||||||
|
/// carrying the new SourceSurfaceIndex/RetailSurfaceMask/RawSurfaceType/
|
||||||
|
/// IsCellShell fields.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_RejectsOlderRecipePackageBeforePayloadDecode()
|
||||||
|
{
|
||||||
|
// PakWriter stamps BakeToolVersion to PakFormat.CurrentBakeToolVersion
|
||||||
|
// unconditionally (the header-template default-0 footgun guard), so
|
||||||
|
// an "older recipe" file has to be produced by patching the on-disk
|
||||||
|
// header dword directly -- the same technique
|
||||||
|
// PakRoundTripTests.Reader_RejectsWrongFormatVersion uses for the
|
||||||
|
// sibling FormatVersion field. BakeToolVersion is offset 36 per
|
||||||
|
// PakHeader's normative layout.
|
||||||
|
string path = WritePak(
|
||||||
|
(PakAssetType.EnvCellMesh, 1u, Mesh(1u)));
|
||||||
|
|
||||||
|
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite))
|
||||||
|
{
|
||||||
|
fs.Position = 36;
|
||||||
|
Span<byte> buf = stackalloc byte[4];
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
|
||||||
|
buf, PakFormat.CurrentBakeToolVersion - 1);
|
||||||
|
fs.Write(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidDataException error = Assert.Throws<InvalidDataException>(
|
||||||
|
() => new PakPreparedAssetSource(path, Identity));
|
||||||
|
Assert.Contains("does not match the installed DAT set", error.Message);
|
||||||
|
Assert.Contains("Re-bake", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Read_PreCanceledTokenPropagatesWithoutStartingRead()
|
public void Read_PreCanceledTokenPropagatesWithoutStartingRead()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
using AcDream.Core.Meshing;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Meshing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the pure parts of
|
||||||
|
/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md
|
||||||
|
/// §10.1 for the OH2/S1 chunk-1 descriptor: candidate construction
|
||||||
|
/// (§3.4), UV absence (§3.5), and the per-surface mask (§3.2/§3.3).
|
||||||
|
/// DAT resolution, subset aggregation, and draw admission are a later
|
||||||
|
/// chunk (contract §9) and are out of scope here.
|
||||||
|
/// </summary>
|
||||||
|
public class CellStructSideCandidatesTests
|
||||||
|
{
|
||||||
|
// ---- §3.4 candidate construction: exact count, order, sign, winding ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SidesSingle_YieldsOnePositiveForwardCandidate()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(0).ToArray();
|
||||||
|
|
||||||
|
var candidate = Assert.Single(candidates);
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, candidate.SurfaceSlot);
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, candidate.UvSlot);
|
||||||
|
Assert.Equal(0, candidate.CopyOrdinal);
|
||||||
|
Assert.Equal(1, candidate.NormalSign);
|
||||||
|
Assert.False(candidate.ReverseWinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SidesDouble_YieldsTwoPositiveCandidates_SecondCopyReversedWithNegativeNormal()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(1).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(2, candidates.Length);
|
||||||
|
|
||||||
|
var first = candidates[0];
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, first.SurfaceSlot);
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, first.UvSlot);
|
||||||
|
Assert.Equal(0, first.CopyOrdinal);
|
||||||
|
Assert.Equal(1, first.NormalSign);
|
||||||
|
Assert.False(first.ReverseWinding);
|
||||||
|
|
||||||
|
var second = candidates[1];
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, second.SurfaceSlot);
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, second.UvSlot);
|
||||||
|
Assert.Equal(1, second.CopyOrdinal);
|
||||||
|
Assert.Equal(-1, second.NormalSign);
|
||||||
|
Assert.True(second.ReverseWinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SidesBoth_YieldsPositiveThenNegativeCandidate_NegativeSideNotReversed()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(2).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(2, candidates.Length);
|
||||||
|
|
||||||
|
var positive = candidates[0];
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, positive.SurfaceSlot);
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Positive, positive.UvSlot);
|
||||||
|
Assert.Equal(0, positive.CopyOrdinal);
|
||||||
|
Assert.Equal(1, positive.NormalSign);
|
||||||
|
Assert.False(positive.ReverseWinding);
|
||||||
|
|
||||||
|
// The counterintuitive, binding fact from contract §3.4: ST_BOTH's
|
||||||
|
// negative candidate gets a negative normal but is NOT index-reversed
|
||||||
|
// (retail reverses on nonzero COPY ordinal, not side ordinal).
|
||||||
|
var negative = candidates[1];
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Negative, negative.SurfaceSlot);
|
||||||
|
Assert.Equal(CellStructPolygonSurfaceSide.Negative, negative.UvSlot);
|
||||||
|
Assert.Equal(0, negative.CopyOrdinal);
|
||||||
|
Assert.Equal(-1, negative.NormalSign);
|
||||||
|
Assert.False(negative.ReverseWinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(99)]
|
||||||
|
public void UnknownSidesValue_YieldsNoCandidates(int rawSidesType)
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(rawSidesType);
|
||||||
|
|
||||||
|
Assert.True(candidates.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- exact fan index order (§3.4 "Fan index order" column) ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0, 1, 2)]
|
||||||
|
[InlineData(1, 0, 2, 3)]
|
||||||
|
[InlineData(5, 0, 6, 7)]
|
||||||
|
public void ForwardWinding_ProducesForwardFanIndices(int triangleIndex, int a, int b, int c)
|
||||||
|
{
|
||||||
|
var (fa, fb, fc) = CellStructSideCandidates.TriangleFanIndices(triangleIndex, reverseWinding: false);
|
||||||
|
|
||||||
|
Assert.Equal((a, b, c), (fa, fb, fc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 2, 1, 0)]
|
||||||
|
[InlineData(1, 3, 2, 0)]
|
||||||
|
[InlineData(5, 7, 6, 0)]
|
||||||
|
public void ReversedWinding_ProducesReversedFanIndices(int triangleIndex, int a, int b, int c)
|
||||||
|
{
|
||||||
|
var (fa, fb, fc) = CellStructSideCandidates.TriangleFanIndices(triangleIndex, reverseWinding: true);
|
||||||
|
|
||||||
|
Assert.Equal((a, b, c), (fa, fb, fc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- §3.5 UV absence: candidate survives, UV becomes zero ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoPosStippling_MakesPositiveSlotCandidateUvAbsent_ButCandidateStillPresent()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(0).ToArray();
|
||||||
|
var candidate = Assert.Single(candidates);
|
||||||
|
|
||||||
|
Assert.True(CellStructSideCandidates.IsUvAbsent(candidate, StipplingType.NoPos));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoNegStippling_MakesNegativeSlotCandidateUvAbsent_ButCandidateStillPresent()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(2).ToArray();
|
||||||
|
var negative = candidates[1];
|
||||||
|
|
||||||
|
// The candidate exists regardless of NoNeg — GetCandidates() and
|
||||||
|
// IsUvAbsent() are deliberately decoupled so absence can never drop
|
||||||
|
// a construction candidate (contract §3.5, §9 item 1).
|
||||||
|
Assert.True(CellStructSideCandidates.IsUvAbsent(negative, StipplingType.NoNeg));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoNegStippling_DoesNotMakePositiveSlotCandidateUvAbsent()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(2).ToArray();
|
||||||
|
var positive = candidates[0];
|
||||||
|
|
||||||
|
Assert.False(CellStructSideCandidates.IsUvAbsent(positive, StipplingType.NoNeg));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoUvBits_LeavesUvPresent()
|
||||||
|
{
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(0).ToArray();
|
||||||
|
var candidate = Assert.Single(candidates);
|
||||||
|
|
||||||
|
Assert.False(CellStructSideCandidates.IsUvAbsent(candidate, StipplingType.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- §3.2 per-surface initial mask: exact precedence ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClipMapSurface_HasInitialMaskEight()
|
||||||
|
{
|
||||||
|
Assert.Equal(8, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Base1ClipMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlphaSurface_HasInitialMaskTwo()
|
||||||
|
{
|
||||||
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Alpha));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvAlphaSurface_HasInitialMaskTwo()
|
||||||
|
{
|
||||||
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.InvAlpha));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdditiveSurface_HasInitialMaskTwo()
|
||||||
|
{
|
||||||
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Additive));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TranslucentSurface_HasInitialMaskFour()
|
||||||
|
{
|
||||||
|
Assert.Equal(4, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Translucent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlphaFamily_TakesPrecedenceOverClipMap()
|
||||||
|
{
|
||||||
|
var type = SurfaceType.Alpha | SurfaceType.Base1ClipMap;
|
||||||
|
|
||||||
|
Assert.Equal(2, CellStructSideCandidates.InitialSurfaceMask(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClipMap_TakesPrecedenceOverTranslucent()
|
||||||
|
{
|
||||||
|
var type = SurfaceType.Base1ClipMap | SurfaceType.Translucent;
|
||||||
|
|
||||||
|
Assert.Equal(8, CellStructSideCandidates.InitialSurfaceMask(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlainSolidSurface_HasInitialMaskZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, CellStructSideCandidates.InitialSurfaceMask(SurfaceType.Base1Solid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- untextured surfaces are constructed as candidates; this layer
|
||||||
|
// never conflates construction with the (later, out-of-scope) built-
|
||||||
|
// EnvCell (Surface.Type & 6) != 0 draw-admission test ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntexturedSolidSurfaceType_IsUntextured_ButCandidateIsStillConstructed()
|
||||||
|
{
|
||||||
|
// 0x1 = Base1Solid only.
|
||||||
|
var type = (SurfaceType)0x1;
|
||||||
|
Assert.True(RetailUntexturedSurfacePolicy.IsUntextured(type));
|
||||||
|
|
||||||
|
// GetCandidates never takes a Surface.Type — construction and
|
||||||
|
// draw admission are independent per contract §9 item 1.
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(0);
|
||||||
|
Assert.False(candidates.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntexturedSolidTranslucentSurfaceType_IsUntextured_ButCandidateIsStillConstructed()
|
||||||
|
{
|
||||||
|
// 0x11 = Base1Solid | Translucent — the canonical cathedral NoPos
|
||||||
|
// surface type from contract §10.3.
|
||||||
|
var type = (SurfaceType)0x11;
|
||||||
|
Assert.True(RetailUntexturedSurfacePolicy.IsUntextured(type));
|
||||||
|
Assert.Equal(4, CellStructSideCandidates.InitialSurfaceMask(type));
|
||||||
|
|
||||||
|
var candidates = CellStructSideCandidates.GetCandidates(0);
|
||||||
|
Assert.False(candidates.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- §3.2 per-polygon stippling mask update: signed-byte SETG,
|
||||||
|
// aimed only at the positive surface ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PositiveStippling_OrsBitOneOnThePositiveSurfaceCandidate()
|
||||||
|
{
|
||||||
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 0,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
||||||
|
stippling: StipplingType.Positive);
|
||||||
|
|
||||||
|
Assert.Equal(1, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NegativeStippling_LeavesTheNegativeSurfaceCandidateMaskUnchanged()
|
||||||
|
{
|
||||||
|
// StipplingType.Negative (raw 2) is still positive as a signed
|
||||||
|
// byte, but retail ORs this bit only into the POSITIVE surface's
|
||||||
|
// mask (contract §3.2) — a candidate whose SurfaceSlot is Negative
|
||||||
|
// never receives it, regardless of the stippling value.
|
||||||
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 4,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Negative,
|
||||||
|
stippling: StipplingType.Negative);
|
||||||
|
|
||||||
|
Assert.Equal(4, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoPosOrNoNegStippling_StillOrsBitOneOnThePositiveSurfaceCandidate()
|
||||||
|
{
|
||||||
|
// "Deliberately broader than the low two stipple-side bits": every
|
||||||
|
// defined nonzero StipplingType value, including NoPos/NoNeg, is
|
||||||
|
// positive as a signed byte (contract §3.2).
|
||||||
|
var maskFromNoPos = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 0,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
||||||
|
stippling: StipplingType.NoPos);
|
||||||
|
var maskFromNoNeg = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 0,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
||||||
|
stippling: StipplingType.NoNeg);
|
||||||
|
|
||||||
|
Assert.Equal(1, maskFromNoPos);
|
||||||
|
Assert.Equal(1, maskFromNoNeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroStippling_DoesNotOrBitOne()
|
||||||
|
{
|
||||||
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 0,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
||||||
|
stippling: StipplingType.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CorruptRawStipplingValue_NegativeAsSignedByte_DoesNotOrBitOne()
|
||||||
|
{
|
||||||
|
// Raw 0x80..0xFF is negative as a signed byte, so the SETG check
|
||||||
|
// fails even though the raw unsigned byte is nonzero.
|
||||||
|
var stippling = (StipplingType)0x80;
|
||||||
|
|
||||||
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 0,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
||||||
|
stippling: stippling);
|
||||||
|
|
||||||
|
Assert.Equal(0, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreservesUnrelatedMaskBits_WhenOringBitOne()
|
||||||
|
{
|
||||||
|
var mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
||||||
|
currentMask: 8,
|
||||||
|
candidateSurfaceSlot: CellStructPolygonSurfaceSide.Positive,
|
||||||
|
stippling: StipplingType.Positive);
|
||||||
|
|
||||||
|
Assert.Equal(9, mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,4 +27,30 @@ public sealed class ContentMigrationCatalogTests
|
||||||
Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecipeSevenToEightRequiresOneExplicitFullRebuild()
|
||||||
|
{
|
||||||
|
// OH2/S1: exact CellStruct surface-index subset construction.
|
||||||
|
ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(7, 8);
|
||||||
|
|
||||||
|
Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind);
|
||||||
|
Assert.Equal(7u, plan.FromRecipeVersion);
|
||||||
|
Assert.Equal(8u, plan.TargetRecipeVersion);
|
||||||
|
Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(plan.EffectiveDatIds);
|
||||||
|
Assert.Empty(plan.EffectiveLandblocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnyOlderRecipeToEightCollapsesToOneFullRebuild()
|
||||||
|
{
|
||||||
|
ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 8);
|
||||||
|
|
||||||
|
Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind);
|
||||||
|
Assert.Equal(8u, plan.TargetRecipeVersion);
|
||||||
|
Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("DrawingBSP", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("CellStruct", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue