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>
250 lines
11 KiB
C#
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 & (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);
|
|
}
|