acdream/src/AcDream.Content/ObjectMeshData.cs
Erik e2543d0ef0 fix(content): S1 review round - retail default sides shape, mask hoist, upload order
Campaign OVERHAUL S1 review fixes (two Opus lens reviews, findings verified
by the lead against the decomp):

- a raw sides_type outside 0/1/2 constructs retail's default single-side
  shape (ConstructMesh @0x0059DFA0 loop bounds default to 1) instead of
  dropping the polygon; still counted as a data anomaly (corpus has none);
- the positive-surface stippling mask OR runs once per polygon before the
  degenerate-fan guard, as retail's count loop does (pseudo-C 426859-426866);
- untextured slots keep their mask accounting but bake no texture and no
  vertices (contract §9 item 3); the dev pak shrinks by 114 KB;
- failed surface-override / Surface / texture-dependency lookups are
  attempted and logged once per slot, not once per candidate;
- cell-shell batches upload in ascending source surface index, retail's
  built-EnvCell subset draw order (ConstructMesh attribute-range scan,
  DrawMesh @0x0059D4A0); ordinary GfxObj meshes keep storage order;
- CellMesh.HasDrawableGeometry documented as the admission rule without
  texture-dependency resolution (a conservative superset of emission);
- the stippling/surface equivalence sweep's cell half is pinned at zero
  again; InAscendingSurfaceOrder is marked bake/upload/test-only;
- plan §5: reviewer findings are verified by the lead, one skeptic at most
  for a blocking finding, never more than five agents per step.

Three new Content tests pin the mask hoist, the vertex-free untextured
slot, and the single-side fallback. Content 213/213, Core Meshing and
Conformance green, App hermetic 6,757/6,757, Release build 0/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 19:37:25 +02:00

250 lines
11 KiB
C#

using Chorizite.Core.Lib;
using Chorizite.Core.Render.Enums;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
namespace AcDream.Content;
/// <summary>
/// Vertex format for scenery mesh rendering: position, normal, UV.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct VertexPositionNormalTexture {
public Vector3 Position;
public Vector3 Normal;
public Vector2 UV;
public static int Size => 8 * sizeof(float); // 3+3+2 = 8 floats = 32 bytes
public VertexPositionNormalTexture(Vector3 position, Vector3 normal, Vector2 uv) {
Position = position;
Normal = normal;
UV = uv;
}
}
/// <summary>
/// Staged data for a particle emitter to be created on the GL thread.
/// </summary>
public struct StagedEmitter {
public ParticleEmitter Emitter;
public uint PartIndex;
public Matrix4x4 Offset;
}
/// <summary>
/// CPU-side mesh data prepared on a background thread.
/// Contains vertex data and per-batch index/texture info, but NO GPU resources.
/// </summary>
public class ObjectMeshData {
private long _estimatedUploadBytes = -1;
public ulong ObjectId { get; set; }
public bool IsSetup { get; set; }
public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty<VertexPositionNormalTexture>();
public List<MeshBatchData> Batches { get; set; } = new();
/// <summary>
/// #125 (2026-06-12): GL upload-retry counter. A failed upload through
/// the App-side upload path (it returns null from its
/// catch) used to be dropped permanently — the staged item was consumed,
/// no render data was produced, and the prepared data lingered in the CPU
/// cache where <c>PrepareMeshDataAsync</c>'s cache-hit short-circuit
/// returned it without ever re-staging it for upload (session-sticky
/// invisible mesh, one [wb-error] line). The drain loop now re-stages a
/// failed upload for the NEXT frame up to the App-side upload retry
/// limit (<c>MaxUploadRetries</c>). The counter lives on the mesh-data
/// object so
/// it resets to 0 naturally whenever the id is re-prepared (fresh object),
/// and bounds a deterministic GL failure to a few loud lines instead of a
/// silent permanent drop OR an unbounded per-frame retry storm. Retail
/// loads content synchronously and has no such failure mode — this
/// converges our async pipeline toward that guarantee.
/// </summary>
public int UploadAttempts;
/// <summary>For EnvCell: the geometry of the cell itself.</summary>
public ObjectMeshData? EnvCellGeometry { get; set; }
/// <summary>For Setup objects: parts with their local transforms.</summary>
public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new();
/// <summary>Particle emitters from physics scripts.</summary>
public List<StagedEmitter> ParticleEmitters { get; set; } = new();
/// <summary>Per-format texture atlas data (to be uploaded to GPU on main thread).</summary>
public Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> TextureBatches { get; set; } = new();
/// <summary>Local bounding box.</summary>
public BoundingBox BoundingBox { get; set; }
/// <summary>Approximate center point used for depth sorting / transparency ordering.</summary>
public Vector3 SortCenter { get; set; }
/// <summary>DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo.</summary>
public uint DIDDegrade { get; set; }
/// <summary>Sphere used for mouse selection.</summary>
public Sphere? SelectionSphere { get; set; }
/// <summary>Edge line vertices for Environment wireframe rendering.</summary>
public Vector3[] EdgeLines { get; set; } = Array.Empty<Vector3>();
/// <summary>
/// Returns the immutable prepared payload's CPU-to-GPU work estimate.
/// Mesh extraction finishes populating the object before publishing it to
/// App, so this value can be cached once and reused by the CPU cache,
/// staging queue, retry path, and frame-budget planner without repeatedly
/// walking every material batch under their locks.
/// </summary>
public long GetEstimatedUploadBytes()
{
long cached = System.Threading.Volatile.Read(ref _estimatedUploadBytes);
if (cached >= 0)
return cached;
long bytes = IsSetup ? 1024L : 0L;
bytes = checked(bytes + (long)Vertices.Length * VertexPositionNormalTexture.Size);
foreach (List<TextureBatchData> batches in TextureBatches.Values)
{
foreach (TextureBatchData batch in batches)
{
bytes = checked(bytes + batch.TextureData.LongLength);
bytes = checked(bytes + (long)batch.Indices.Count * sizeof(ushort));
}
}
if (EnvCellGeometry is not null)
bytes = checked(bytes + EnvCellGeometry.GetEstimatedUploadBytes());
System.Threading.Interlocked.CompareExchange(ref _estimatedUploadBytes, bytes, -1);
return System.Threading.Volatile.Read(ref _estimatedUploadBytes);
}
}
/// <summary>
/// CPU-side data for a single rendering batch (indices + texture reference).
/// </summary>
public class MeshBatchData {
public ushort[] Indices { get; set; } = Array.Empty<ushort>();
public (int Width, int Height, TextureFormat Format) TextureFormat { get; set; }
public TextureKey TextureKey { get; set; }
public int TextureIndex { get; set; }
public byte[] TextureData { get; set; } = Array.Empty<byte>();
public UploadPixelFormat? UploadPixelFormat { get; set; }
public UploadPixelType? UploadPixelType { get; set; }
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
}
/// <summary>
/// CPU-side texture info for deduplication during background preparation.
/// </summary>
/// <remarks>
/// OH2/S1 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md):
/// for a CellStruct/EnvCell shell batch (<see cref="IsCellShell"/> true),
/// <see cref="CullMode"/> is FIXED retail raster state applied AFTER
/// geometry has already been fan-expanded per side/copy candidate —
/// <c>RenderMeshSubset</c> @0x0059CA10 always draws a constructed cell
/// shell subset <c>D3DCULL_CW</c> — it is NOT the authored
/// <c>Polygon.SidesType</c> any more. The subset/material OWNER for a cell
/// batch is instead <see cref="SourceSurfaceIndex"/>, the retail source
/// surface-array index (contract §3.6): two cell batches can share a
/// resolved Surface DID/<see cref="TextureKey"/> and still be two distinct
/// subsets, or vice versa. For an ordinary GfxObj batch,
/// <see cref="CullMode"/> keeps its historical meaning and the four
/// cell-only fields below stay at their neutral (non-cell) defaults —
/// <see cref="AcDream.Content.MeshExtractor.PrepareGfxObjMeshData"/> never
/// sets them.
/// </remarks>
public class TextureBatchData {
public TextureKey Key { get; set; }
public byte[] TextureData { get; set; } = Array.Empty<byte>();
public UploadPixelFormat? UploadPixelFormat { get; set; }
public UploadPixelType? UploadPixelType { get; set; }
public List<ushort> Indices { get; set; } = new();
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public TranslucencyKind Translucency { get; set; } =
TranslucencyKind.Opaque;
public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; }
/// <summary>
/// Retail source surface-array index this subset was constructed from
/// (contract §3.6) — the CellStruct subset/material OWNER, not the
/// resolved Surface DID or texture identity. -1 for a non-cell (GfxObj)
/// batch, where this concept does not apply.
/// </summary>
public int SourceSurfaceIndex { get; set; } = -1;
/// <summary>
/// Retail's <c>isStippledOrAlphaedMask</c> byte for this surface slot
/// (contract §3.2): <c>D3DPolyRender::ConstructMesh</c> @0x0059DFA0's
/// per-surface initial mask
/// (<see cref="CellStructSideCandidates.InitialSurfaceMask"/>) plus
/// every polygon's positive-surface stippling OR
/// (<see cref="CellStructSideCandidates.ApplyStipplingMaskBit"/>). 0
/// (unused) for a non-cell (GfxObj) batch.
/// </summary>
public byte RetailSurfaceMask { get; set; }
/// <summary>
/// The resolved cell surface's raw <c>Surface.Type</c> bits (contract
/// §2.2) — evidence for the built-EnvCell
/// <c>(Type &amp; (BASE1_IMAGE|BASE1_CLIPMAP)) != 0</c> admission
/// decision (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059F170 →
/// <c>D3DPolyRender::DrawMesh</c> @0x0059D4A0, contract §4) that
/// already happened before this batch was ever emitted into
/// <see cref="ObjectMeshData.TextureBatches"/>. 0 (unused) for a
/// non-cell (GfxObj) batch.
/// </summary>
public uint RawSurfaceType { get; set; }
/// <summary>
/// True for a CellStruct/EnvCell shell batch built by
/// <see cref="AcDream.Content.MeshExtractor.PrepareCellStructMeshData"/>.
/// Ordinary GfxObj batches leave this false.
/// </summary>
public bool IsCellShell { get; set; }
}
/// <summary>
/// OH2/S1 chunk-2 (contract §9 item 6): the one place that recovers a
/// prepared CellStruct mesh's surface-array-index subset order.
/// <see cref="ObjectMeshData.TextureBatches"/> groups
/// <see cref="TextureBatchData"/> by (Width, Height, Format) for
/// atlas/texture-dedup STORAGE only (contract §3.6 point 5) — the
/// SEMANTIC subset order is always ascending
/// <see cref="TextureBatchData.SourceSurfaceIndex"/>, recoverable
/// independent of that storage grouping or of dictionary/list iteration
/// order. Downstream consumers (App draw dispatch, later OVERHAUL slices
/// such as OH7's ordered draw stream) must read cell subset order through
/// this helper rather than iterating <c>TextureBatches</c> directly.
/// </summary>
public static class CellSurfaceSubsets {
/// <summary>
/// Every <see cref="TextureBatchData"/> in <paramref name="mesh"/> with
/// <see cref="TextureBatchData.IsCellShell"/> set, ordered ascending by
/// <see cref="TextureBatchData.SourceSurfaceIndex"/>. An untextured
/// slot that retail's built-EnvCell admission skipped
/// (<see cref="AcDream.Core.Meshing.RetailUntexturedSurfacePolicy"/>)
/// was never emitted into <see cref="ObjectMeshData.TextureBatches"/>
/// in the first place, so it is absent here too — this enumerates
/// DRAWABLE subsets, not every constructed slot.
/// This LINQ form allocates and is for bake, upload, and test time only
/// (App uploads consume it once per mesh in
/// <c>ObjectMeshManager.OrderedUploadBatches</c>). A per-frame consumer
/// must read a precomputed order (the uploaded batch list already is one),
/// never call this per frame.
/// </summary>
public static IEnumerable<TextureBatchData> InAscendingSurfaceOrder(ObjectMeshData mesh) =>
mesh.TextureBatches.Values
.SelectMany(batches => batches)
.Where(batch => batch.IsCellShell)
.OrderBy(batch => batch.SourceSurfaceIndex);
}