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>
1563 lines
80 KiB
C#
1563 lines
80 KiB
C#
using AcDream.Core.Rendering.Wb;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Content.Vfx;
|
|
using BCnEncoder.Decoder;
|
|
using BCnEncoder.ImageSharp;
|
|
using BCnEncoder.Shared;
|
|
using Chorizite.Core.Lib;
|
|
using Chorizite.Core.Render.Enums;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
using Microsoft.Extensions.Logging;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using System.Threading;
|
|
using CullMode = DatReaderWriter.Enums.CullMode;
|
|
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
|
|
|
|
namespace AcDream.Content;
|
|
|
|
/// <summary>
|
|
/// MP1a (2026-07-05): the GL-free CPU half of the former ObjectMeshManager —
|
|
/// dat read → polygon walk → vertex/index build → inline texture decode →
|
|
/// <see cref="ObjectMeshData"/>. Extracted VERBATIM so the MP1b bake tool
|
|
/// and the live client run the SAME extraction code (byte-identical output
|
|
/// is the pak conformance foundation). ObjectMeshManager (App) retains the
|
|
/// queue/worker lifecycle and all GL upload; it delegates here.
|
|
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.1.
|
|
/// </summary>
|
|
public sealed class MeshExtractor {
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly ILogger _logger;
|
|
private readonly RetailPhysicsScriptLoader _physicsScripts;
|
|
|
|
// Canonical decoded pixels are immutable and bounded by bytes as well as
|
|
// count. A count-only cache can retain hundreds of MiB of large RGBA data.
|
|
private readonly DecodedTextureCache _decodedTextureCache = new(
|
|
maximumBytes: 64L * 1024 * 1024,
|
|
maximumEntries: 128);
|
|
private readonly ThreadLocal<BcDecoder> _bcDecoder = new(() => new BcDecoder());
|
|
|
|
// Solid-color surfaces (isSolid: Base1Solid surfaces / NoPos-stippled polys)
|
|
// bake a flat ARGB fill to a fixed 32x32 RGBA array. Distinct surface colors
|
|
// across an install are a few hundred at most, so this is bounded by DISTINCT
|
|
// COLORS observed rather than by call count — unlike DecodedTextureCache's
|
|
// byte-bounded LRU, which exists to cap large per-surface pixel arrays, not a
|
|
// handful of 4 KiB solid fills. ConcurrentDictionary: MeshExtractor is shared
|
|
// by up to MaxParallelLoads (4) decode workers.
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, byte[]> _solidColorTextureCache = new();
|
|
|
|
/// <summary>Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
|
|
public CacheStats DecodedTextureCacheStats => _decodedTextureCache.Stats;
|
|
|
|
/// <summary>
|
|
/// MP1a mechanical seam: receives particle-preload meshes staged
|
|
/// mid-extraction (see <see cref="CollectEmittersFromScript"/>). The App
|
|
/// wires this to its staged-upload queue, restoring the original
|
|
/// immediate-enqueue semantics — entries must survive a subsequent throw
|
|
/// in the same Prepare* call (retail's code enqueued directly onto
|
|
/// ObjectMeshManager's <c>_stagedMeshData</c> mid-Prepare, so preloads
|
|
/// staged before a malformed-dat texture-decode throw were already safe).
|
|
/// The MP1b bake tool passes its own collector. The sink must be
|
|
/// thread-safe: one MeshExtractor is shared by up to MaxParallelLoads (4)
|
|
/// decode workers.
|
|
/// The constructor argument is REQUIRED (no default): a bake tool that
|
|
/// forgot the sink would silently lose particle-preload meshes; requiring
|
|
/// the argument forces the decision. The type stays nullable so a caller
|
|
/// can consciously pass null when preloads are irrelevant.
|
|
/// </summary>
|
|
private readonly Action<ObjectMeshData>? _sideStagedSink;
|
|
|
|
public MeshExtractor(IDatReaderWriter dats, ILogger logger, Action<ObjectMeshData>? sideStagedSink) {
|
|
_dats = dats;
|
|
_logger = logger;
|
|
_sideStagedSink = sideStagedSink;
|
|
_physicsScripts = new RetailPhysicsScriptLoader(dats.Portal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT.
|
|
/// This loads vertices, indices, and texture data but creates NO GPU resources.
|
|
/// Thread-safe: only reads from DAT files.
|
|
/// </summary>
|
|
public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) {
|
|
try {
|
|
// Use the low 32 bits as the DAT file ID
|
|
var datId = (uint)(id & 0xFFFFFFFFu);
|
|
if (!_dats.TryResolvePreferred(
|
|
datId,
|
|
out IDatDatabase? db,
|
|
out DBObjType type))
|
|
return null;
|
|
|
|
if (type == DBObjType.Setup) {
|
|
if (!db.TryGet<Setup>(datId, out var setup)) return null;
|
|
return PrepareSetupMeshData(id, setup, ct);
|
|
}
|
|
else if (type == DBObjType.GfxObj) {
|
|
if (!db.TryGet<GfxObj>(datId, out var gfxObj)) return null;
|
|
return PrepareGfxObjMeshData(id, gfxObj, Vector3.One, ct);
|
|
}
|
|
else if (type == DBObjType.EnvCell) {
|
|
if (!db.TryGet<EnvCell>(datId, out var envCell)) return null;
|
|
|
|
// If bit 32 is set, this is a request for the cell's synthetic geometry only
|
|
if ((id & 0x1_0000_0000UL) != 0) {
|
|
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
|
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment)) {
|
|
if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) {
|
|
return PrepareCellStructMeshData(id, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return PrepareEnvCellMeshData(id, envCell, ct);
|
|
}
|
|
else if (type == DBObjType.Environment) {
|
|
if (!db.TryGet<DatReaderWriter.DBObjs.Environment>(datId, out var environment)) return null;
|
|
|
|
// For Environment objects, create wireframe-only edge geometry
|
|
if (environment.Cells.Count > 0) {
|
|
var result = PrepareCellStructEdgeLineData(id, environment.Cells, Matrix4x4.Identity, ct);
|
|
return result;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
catch (OperationCanceledException) {
|
|
throw;
|
|
}
|
|
catch (Exception ex) {
|
|
_logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private ObjectMeshData? PrepareSetupMeshData(ulong id, Setup setup, CancellationToken ct) {
|
|
var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>();
|
|
var min = new Vector3(float.MaxValue);
|
|
var max = new Vector3(float.MinValue);
|
|
bool hasBounds = false;
|
|
|
|
CollectParts((uint)(id & 0xFFFFFFFFu), Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, ct);
|
|
|
|
var emitters = new List<StagedEmitter>();
|
|
var processedScripts = new HashSet<uint>();
|
|
if (setup.DefaultScript.DataId != 0) {
|
|
if (processedScripts.Add(setup.DefaultScript.DataId)) {
|
|
CollectEmittersFromScript(setup.DefaultScript.DataId, emitters, ct);
|
|
}
|
|
}
|
|
|
|
return new ObjectMeshData {
|
|
ObjectId = id,
|
|
IsSetup = true,
|
|
SetupParts = parts,
|
|
ParticleEmitters = emitters,
|
|
BoundingBox = hasBounds ? new BoundingBox(min, max) : default,
|
|
SelectionSphere = setup.SelectionSphere
|
|
};
|
|
}
|
|
|
|
private void CollectEmittersFromScript(uint scriptId, List<StagedEmitter> emitters, CancellationToken ct) {
|
|
var script = _physicsScripts.LoadPhysicsScript(scriptId);
|
|
if (script is not null) {
|
|
foreach (var hook in script.ScriptData) {
|
|
if (hook.Hook is CreateParticleHook particleHook) {
|
|
if (_dats.Portal.TryGet<ParticleEmitter>(particleHook.EmitterInfoId.DataId, out var emitter)) {
|
|
emitters.Add(new StagedEmitter {
|
|
Emitter = emitter,
|
|
PartIndex = particleHook.PartIndex,
|
|
Offset = Matrix4x4.CreateFromQuaternion(particleHook.Offset.Orientation) * Matrix4x4.CreateTranslation(particleHook.Offset.Origin)
|
|
});
|
|
|
|
// Pre-load and stage the particle's GfxObjs
|
|
if (emitter.HwGfxObjId.DataId != 0) {
|
|
var meshData = PrepareMeshData(emitter.HwGfxObjId.DataId, false, ct);
|
|
if (meshData != null) {
|
|
_sideStagedSink?.Invoke(meshData);
|
|
}
|
|
}
|
|
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
|
|
var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct);
|
|
if (meshData != null) {
|
|
_sideStagedSink?.Invoke(meshData);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void CollectParts(uint id, Matrix4x4 currentTransform, List<(ulong GfxObjId, Matrix4x4 Transform)> parts, ref Vector3 min, ref Vector3 max, ref bool hasBounds, CancellationToken ct, int depth = 0) {
|
|
if (depth > 50) {
|
|
_logger.LogWarning("Max recursion depth reached while collecting parts for 0x{Id:X8}. Possible circular dependency.", id);
|
|
return;
|
|
}
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
if (!_dats.TryResolvePreferred(
|
|
id,
|
|
out IDatDatabase? db,
|
|
out DBObjType type))
|
|
return;
|
|
|
|
if (type == DBObjType.Setup) {
|
|
if (!db.TryGet<Setup>(id, out var setup)) return;
|
|
|
|
// Use Resting placement first, then default
|
|
if (!setup.PlacementFrames.TryGetValue(Placement.Resting, out var placementFrame)) {
|
|
if (!setup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame)) {
|
|
placementFrame = setup.PlacementFrames.Values.FirstOrDefault();
|
|
}
|
|
}
|
|
if (placementFrame == null) return;
|
|
|
|
for (int i = 0; i < setup.Parts.Count; i++) {
|
|
var partId = setup.Parts[i];
|
|
var transform = Matrix4x4.Identity;
|
|
|
|
if (setup.Flags.HasFlag(SetupFlags.HasDefaultScale) && setup.DefaultScale.Count > i) {
|
|
transform *= Matrix4x4.CreateScale(setup.DefaultScale[i]);
|
|
}
|
|
|
|
if (placementFrame.Frames != null && i < placementFrame.Frames.Count) {
|
|
var orientation = new System.Numerics.Quaternion(
|
|
(float)placementFrame.Frames[i].Orientation.X,
|
|
(float)placementFrame.Frames[i].Orientation.Y,
|
|
(float)placementFrame.Frames[i].Orientation.Z,
|
|
(float)placementFrame.Frames[i].Orientation.W
|
|
);
|
|
transform *= Matrix4x4.CreateFromQuaternion(orientation)
|
|
* Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin);
|
|
}
|
|
|
|
CollectParts(partId, transform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1);
|
|
}
|
|
}
|
|
else if (type == DBObjType.EnvCell) {
|
|
if (!db.TryGet<EnvCell>(id, out var envCell)) return;
|
|
|
|
// Calculate the inverse transform of the cell to localize its contents
|
|
var cellOrientation = new System.Numerics.Quaternion(
|
|
(float)envCell.Position.Orientation.X,
|
|
(float)envCell.Position.Orientation.Y,
|
|
(float)envCell.Position.Orientation.Z,
|
|
(float)envCell.Position.Orientation.W
|
|
);
|
|
var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) *
|
|
Matrix4x4.CreateTranslation(envCell.Position.Origin);
|
|
if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) {
|
|
invertCellTransform = Matrix4x4.Identity;
|
|
}
|
|
|
|
// Include cell geometry
|
|
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
|
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment)) {
|
|
if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) {
|
|
foreach (var vert in cellStruct.VertexArray.Vertices.Values) {
|
|
var transformed = Vector3.Transform(vert.Origin, currentTransform);
|
|
min = Vector3.Min(min, transformed);
|
|
max = Vector3.Max(max, transformed);
|
|
}
|
|
hasBounds = true;
|
|
|
|
// Add synthetic geometry ID to parts list
|
|
parts.Add(((ulong)id | 0x1_0000_0000UL, currentTransform));
|
|
}
|
|
}
|
|
|
|
foreach (var stab in envCell.StaticObjects) {
|
|
var orientation = new System.Numerics.Quaternion(
|
|
(float)stab.Frame.Orientation.X,
|
|
(float)stab.Frame.Orientation.Y,
|
|
(float)stab.Frame.Orientation.Z,
|
|
(float)stab.Frame.Orientation.W
|
|
);
|
|
var transform = Matrix4x4.CreateFromQuaternion(orientation)
|
|
* Matrix4x4.CreateTranslation(stab.Frame.Origin);
|
|
// Localize static object transform relative to the cell
|
|
var localizedTransform = transform * invertCellTransform;
|
|
|
|
CollectParts(stab.Id, localizedTransform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1);
|
|
}
|
|
}
|
|
else if (type == DBObjType.GfxObj) {
|
|
parts.Add((id, currentTransform));
|
|
|
|
if (db.TryGet<GfxObj>(id, out var partGfx)) {
|
|
var (partMin, partMax) = ComputeBounds(partGfx, Vector3.One);
|
|
var corners = new Vector3[8];
|
|
corners[0] = new Vector3(partMin.X, partMin.Y, partMin.Z);
|
|
corners[1] = new Vector3(partMin.X, partMin.Y, partMax.Z);
|
|
corners[2] = new Vector3(partMin.X, partMax.Y, partMin.Z);
|
|
corners[3] = new Vector3(partMin.X, partMax.Y, partMax.Z);
|
|
corners[4] = new Vector3(partMax.X, partMin.Y, partMin.Z);
|
|
corners[5] = new Vector3(partMax.X, partMin.Y, partMax.Z);
|
|
corners[6] = new Vector3(partMax.X, partMax.Y, partMin.Z);
|
|
corners[7] = new Vector3(partMax.X, partMax.Y, partMax.Z);
|
|
|
|
foreach (var corner in corners) {
|
|
var transformed = Vector3.Transform(corner, currentTransform);
|
|
min = Vector3.Min(min, transformed);
|
|
max = Vector3.Max(max, transformed);
|
|
}
|
|
hasBounds = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private ObjectMeshData? PrepareGfxObjMeshData(ulong id, GfxObj gfxObj, Vector3 scale, CancellationToken ct) {
|
|
var vertices = new List<VertexPositionNormalTexture>();
|
|
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
|
|
var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
|
|
|
|
var (min, max) = ComputeBounds(gfxObj, scale);
|
|
var boundingBox = new BoundingBox(min, max);
|
|
|
|
// #113 (2026-06-11): retail draws a GfxObj by TRAVERSING its drawing
|
|
// BSP — a polygon present in the Polygons dictionary but referenced by
|
|
// no DrawingBSP node is never rendered (physics/no-draw geometry).
|
|
// The Holtburg meeting hall (0x010014C3) keeps its walkable exterior
|
|
// stair-ramp as dictionary polys {0,1}: in the PhysicsBSP (NPCs walk
|
|
// it) but absent from every DrawingBSP node — retail shows a plain
|
|
// wall; iterating the dictionary draws the "phantom staircase"
|
|
// (invisible-but-walkable in retail, visible in acdream). The hill
|
|
// cottage (0x01000827) carries 8 such orphans.
|
|
//
|
|
// ⚠️ FILTER NOT APPLIED (e46d3d9 un-applied same day): naively
|
|
// filtering to CollectDrawingBspPolygonIds(gfxObj) made DOORS
|
|
// disappear across Holtburg (user gate 2026-06-11) — the naive
|
|
// PosNode/NegNode walk evidently misses polys some models reference
|
|
// another way (portal-type nodes? leaf indexing? DatReaderWriter
|
|
// parse gap?). Diagnose with the histogram fact in
|
|
// Issue113PhantomStairsDumpTests on a door GfxObj BEFORE re-landing.
|
|
// The full retail draw is BSP-TRAVERSAL ORDER drawing, not a
|
|
// dictionary iteration with a filter — see the holistic port handoff
|
|
// docs/research/2026-06-11-building-render-holistic-port-handoff.md.
|
|
foreach (var polyEntry in gfxObj.Polygons) {
|
|
ct.ThrowIfCancellationRequested();
|
|
var poly = polyEntry.Value;
|
|
if (poly.VertexIds.Count < 3) continue;
|
|
|
|
// Handle Positive Surface
|
|
// #426 (2026-08-23, Holtburg windmill axle 0x010010CE): NoPos
|
|
// ("NO_POS_UVS", acclient.h:7386) means "this side has no texture
|
|
// coordinates" — that's true of every SOLID-COLOUR polygon, not
|
|
// "there is no positive face". Retail's D3DPolyRender::DrawMesh
|
|
// draws untextured (solid) subsets on ordinary objects same as
|
|
// textured ones (see RetailUntexturedSurfacePolicy); only a
|
|
// building shell or an EnvCell interior skips them, and that is
|
|
// a DRAW-time decision (RetailUntexturedSubsetPolicy, applied in
|
|
// WbDrawDispatcher), not an extraction-time one. So the positive
|
|
// side is always emitted when PosSurface is a valid index;
|
|
// AddSurfaceToBatch already falls back to UV index 0 / zero
|
|
// texcoords (via BuildPolygonIndices) when NoPos leaves no UVs to
|
|
// read.
|
|
AddSurfaceToBatch(poly, poly.PosSurface, false);
|
|
|
|
// Handle Negative Surface
|
|
// Some objects use Clockwise CullMode to indicate negative surface data is present
|
|
bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) ||
|
|
poly.Stippling.HasFlag(StipplingType.Both) ||
|
|
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise);
|
|
|
|
if (hasNeg) {
|
|
AddSurfaceToBatch(poly, poly.NegSurface, true);
|
|
}
|
|
|
|
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) {
|
|
if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) return;
|
|
|
|
var surfaceId = gfxObj.Surfaces[surfaceIdx];
|
|
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface)) {
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-skip] gfxobj Surface 0x{surfaceId:X8} miss -> poly batch dropped (obj 0x{gfxObj.Id:X8})");
|
|
return;
|
|
}
|
|
|
|
int texWidth, texHeight;
|
|
byte[] textureData;
|
|
TextureFormat textureFormat;
|
|
UploadPixelFormat? uploadPixelFormat = null;
|
|
UploadPixelType? uploadPixelType = null;
|
|
// #426: "solid" (untextured) is a SURFACE fact, not a
|
|
// polygon-stippling fact — see RetailUntexturedSurfacePolicy.
|
|
// The old `NoPos ||` term conflated "this polygon's positive
|
|
// side has no UVs" with "this surface is untextured"; it also
|
|
// wrongly classified a NEG-side batch by the POS-side's NoPos
|
|
// flag, since this method is shared by both sides.
|
|
bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type);
|
|
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
|
uint paletteId = 0;
|
|
bool isDxt3or5 = false;
|
|
bool textureDataIsCached = false;
|
|
DatReaderWriter.Enums.PixelFormat? sourceFormat = null;
|
|
var isAdditive = false;
|
|
var isTransparent = false;
|
|
|
|
if (isSolid) {
|
|
texWidth = texHeight = 32;
|
|
textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
|
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)) {
|
|
// check highres
|
|
if (!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out var hrRenderSurface)) {
|
|
throw new Exception($"Unable to load RenderSurface: 0x{renderSurfaceId:X8}");
|
|
}
|
|
|
|
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)) {
|
|
// Vulkan requires textureCompressionBC, so an unedited
|
|
// DAT DXT surface can stay byte-exact all the way to the
|
|
// GPU. Decoding it here inflated pak, CPU, and GPU
|
|
// residency by up to eight times.
|
|
textureFormat = ToTextureFormat(renderSurface.Format);
|
|
textureData = renderSurface.SourceData;
|
|
}
|
|
else if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
|
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
|
textureFormat = TextureFormat.RGBA8;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
|
|
textureData = _decodedTextureCache.GetOrCreate(
|
|
decodedTextureKey,
|
|
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
|
out textureDataIsCached);
|
|
|
|
if (isClipMap && textureData != null) {
|
|
// Cached pixels are canonical and immutable. Surface-local
|
|
// alpha processing must never modify the shared array.
|
|
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 {
|
|
textureFormat = TextureFormat.RGBA8;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
if (_decodedTextureCache.TryGet(
|
|
decodedTextureKey,
|
|
out byte[] cachedPixels)) {
|
|
textureData = cachedPixels;
|
|
textureDataIsCached = true;
|
|
}
|
|
else {
|
|
textureData = renderSurface.SourceData;
|
|
switch (renderSurface.Format) {
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
|
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData))
|
|
throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}");
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
|
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData))
|
|
throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}");
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
if (surface.Type.HasFlag(SurfaceType.Additive)) {
|
|
TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
}
|
|
else {
|
|
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
}
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
default:
|
|
throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}");
|
|
}
|
|
|
|
textureData = _decodedTextureCache.RetainOrUse(
|
|
decodedTextureKey,
|
|
textureData,
|
|
out textureDataIsCached);
|
|
}
|
|
}
|
|
|
|
if (surface.Translucency > 0.0f && textureData != null) {
|
|
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
|
|
if (textureDataIsCached) {
|
|
var clonedData = new byte[textureData.Length];
|
|
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
|
textureData = clonedData;
|
|
}
|
|
|
|
float alphaScale = 1.0f - surface.Translucency;
|
|
for (int i = 3; i < textureData.Length; i += 4) {
|
|
textureData[i] = (byte)(textureData[i] * alphaScale);
|
|
}
|
|
}
|
|
|
|
isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive);
|
|
isTransparent = isSolid ? surface.ColorValue.Alpha < 255 :
|
|
(surface.Type.HasFlag(SurfaceType.Translucent) ||
|
|
surface.Type.HasFlag(SurfaceType.Base1ClipMap) ||
|
|
((uint)surface.Type & 0x100) != 0 || // Alpha
|
|
((uint)surface.Type & 0x200) != 0 || // InvAlpha
|
|
isAdditive ||
|
|
(surface.Translucency > 0.0f && surface.Translucency < 1.0f) ||
|
|
textureFormat == TextureFormat.A8 ||
|
|
textureFormat == TextureFormat.Rgba32f ||
|
|
isDxt3or5 ||
|
|
(sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 ||
|
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 ||
|
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
|
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
|
|
}
|
|
else {
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-skip] gfxobj SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> poly batch dropped (surface 0x{surfaceId:X8})");
|
|
return;
|
|
}
|
|
|
|
var format = (texWidth, texHeight, textureFormat);
|
|
var key = new TextureKey {
|
|
SurfaceId = surfaceId,
|
|
PaletteId = paletteId,
|
|
Stippling = poly.Stippling,
|
|
IsSolid = isSolid
|
|
};
|
|
|
|
if (!batchesByFormat.TryGetValue(format, out var batches)) {
|
|
batches = new List<TextureBatchData>();
|
|
batchesByFormat[format] = batches;
|
|
}
|
|
|
|
var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType);
|
|
if (batch == null) {
|
|
batch = new TextureBatchData {
|
|
Key = key,
|
|
CullMode = poly.SidesType,
|
|
TextureData = textureData!,
|
|
UploadPixelFormat = uploadPixelFormat,
|
|
UploadPixelType = uploadPixelType,
|
|
Translucency =
|
|
TranslucencyKindExtensions.FromSurfaceType(
|
|
surface.Type),
|
|
IsTransparent = isTransparent,
|
|
IsAdditive = isAdditive
|
|
};
|
|
batches.Add(batch);
|
|
}
|
|
|
|
bool batchHasWrappingUVs = batch.HasWrappingUVs;
|
|
BuildPolygonIndices(poly, gfxObj, scale, UVLookup, vertices, batch.Indices, isNeg, ref batchHasWrappingUVs);
|
|
batch.HasWrappingUVs = batchHasWrappingUVs;
|
|
}
|
|
}
|
|
|
|
return new ObjectMeshData {
|
|
ObjectId = id,
|
|
IsSetup = false,
|
|
Vertices = vertices.ToArray(),
|
|
TextureBatches = batchesByFormat,
|
|
BoundingBox = boundingBox,
|
|
SortCenter = gfxObj?.SortCenter ?? Vector3.Zero,
|
|
DIDDegrade = gfxObj != null && gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) ? gfxObj.DIDDegrade : 0,
|
|
// Retail CGfxObj::Serialize @ 0x00534970 stores drawing_sphere as
|
|
// BSPTREE::GetSphere(drawing_bsp), and RenderDeviceD3D::DrawMesh
|
|
// @ 0x005A0860 passes that exact sphere to viewconeCheck. The
|
|
// vertex-AABB sphere used here previously is a different shape
|
|
// (notably on long Facility Hub stair sections) and can change a
|
|
// portal-edge admission decision. Preserve the authored root
|
|
// sphere in prepared content; retain the AABB only for malformed
|
|
// or drawing-BSP-less assets which have no retail sphere to carry.
|
|
SelectionSphere = gfxObj?.DrawingBSP?.Root?.BoundingSphere
|
|
?? new Sphere
|
|
{
|
|
Origin = boundingBox.Center,
|
|
Radius = Vector3.Distance(
|
|
boundingBox.Max,
|
|
boundingBox.Min) / 2.0f,
|
|
}
|
|
};
|
|
}
|
|
|
|
private ObjectMeshData? PrepareEnvCellMeshData(ulong id, EnvCell envCell, CancellationToken ct) {
|
|
var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>();
|
|
var min = new Vector3(float.MaxValue);
|
|
var max = new Vector3(float.MinValue);
|
|
bool hasBounds = false;
|
|
|
|
// Calculate the inverse transform of the cell to localize its contents
|
|
var cellOrientation = new System.Numerics.Quaternion(
|
|
(float)envCell.Position.Orientation.X,
|
|
(float)envCell.Position.Orientation.Y,
|
|
(float)envCell.Position.Orientation.Z,
|
|
(float)envCell.Position.Orientation.W
|
|
);
|
|
var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) *
|
|
Matrix4x4.CreateTranslation(envCell.Position.Origin);
|
|
if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) {
|
|
invertCellTransform = Matrix4x4.Identity;
|
|
}
|
|
|
|
// Add static objects
|
|
var emitters = new List<StagedEmitter>();
|
|
foreach (var stab in envCell.StaticObjects) {
|
|
var orientation = new System.Numerics.Quaternion(
|
|
(float)stab.Frame.Orientation.X,
|
|
(float)stab.Frame.Orientation.Y,
|
|
(float)stab.Frame.Orientation.Z,
|
|
(float)stab.Frame.Orientation.W
|
|
);
|
|
var transform = Matrix4x4.CreateFromQuaternion(orientation)
|
|
* Matrix4x4.CreateTranslation(stab.Frame.Origin);
|
|
|
|
// Localize static object transform relative to the cell
|
|
var localizedTransform = transform * invertCellTransform;
|
|
|
|
CollectParts(stab.Id, localizedTransform, parts, ref min, ref max, ref hasBounds, ct);
|
|
|
|
// For EnvCell static objects, we need to manually collect emitters if they are Setups.
|
|
// Bugfix 2026-05-19 (acdream): pre-check the Setup-prefix (0x02xxxxxx) before calling
|
|
// TryGet<Setup>. Without this, calling TryGet<Setup> on a GfxObj-prefixed id
|
|
// (0x01xxxxxx) throws ArgumentOutOfRangeException as DatReaderWriter tries to parse
|
|
// GfxObj bytes as a Setup record. The exception bubbles up through PrepareMeshData's
|
|
// outer catch and the entire cell fails to upload — manifesting as missing floors
|
|
// in any building whose StaticObjects include a GfxObj-typed stab (very common).
|
|
// Confirmed via acdream's Phase 2 indoor-cell-rendering diagnostic probes; see
|
|
// docs/research/2026-05-19-indoor-cell-rendering-cause.md in the acdream repo.
|
|
if ((stab.Id & 0xFF000000u) == 0x02000000u
|
|
&& _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) {
|
|
var stabEmitters = new List<StagedEmitter>();
|
|
var processedScripts = new HashSet<uint>();
|
|
if (stabSetup.DefaultScript.DataId != 0) {
|
|
if (processedScripts.Add(stabSetup.DefaultScript.DataId)) {
|
|
CollectEmittersFromScript(stabSetup.DefaultScript.DataId, stabEmitters, ct);
|
|
}
|
|
}
|
|
|
|
foreach (var emitter in stabEmitters) {
|
|
emitters.Add(new StagedEmitter {
|
|
Emitter = emitter.Emitter,
|
|
PartIndex = emitter.PartIndex,
|
|
Offset = emitter.Offset * localizedTransform
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load environment and cell structure geometry
|
|
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
|
ObjectMeshData? cellGeometry = null;
|
|
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment)) {
|
|
if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) {
|
|
var cellGeomId = id | 0x1_0000_0000UL;
|
|
cellGeometry = PrepareCellStructMeshData(cellGeomId, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct);
|
|
if (cellGeometry != null) {
|
|
parts.Add((cellGeomId, Matrix4x4.Identity));
|
|
min = Vector3.Min(min, cellGeometry.BoundingBox.Min);
|
|
max = Vector3.Max(max, cellGeometry.BoundingBox.Max);
|
|
hasBounds = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new ObjectMeshData {
|
|
ObjectId = id,
|
|
IsSetup = true,
|
|
SetupParts = parts,
|
|
ParticleEmitters = emitters,
|
|
EnvCellGeometry = cellGeometry,
|
|
BoundingBox = hasBounds ? new BoundingBox(min, max) : default,
|
|
SelectionSphere = new Sphere { Origin = hasBounds ? (min + max) / 2f : Vector3.Zero, Radius = hasBounds ? Vector3.Distance(max, min) / 2.0f : 0f }
|
|
};
|
|
}
|
|
|
|
/// <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 built-EnvCell admission decided at slot creation
|
|
/// (<c>RenderDeviceD3D::DrawEnvCell</c> @0x0059F170 →
|
|
/// <c>D3DPolyRender::DrawMesh(..., arg4=1)</c> @0x0059D4A0 skips a
|
|
/// subset unless <c>(Surface.Type & 6) != 0</c>, contract §4).
|
|
/// An untextured slot still receives its mask accounting but no
|
|
/// texture decode and no vertices: retail constructs geometry it
|
|
/// never draws, and the contract (§9 item 3) allows production not
|
|
/// to serialize vertices that can never draw.
|
|
/// </summary>
|
|
public bool IsUntextured => RetailUntexturedSurfacePolicy.IsUntextured(Surface.Type);
|
|
|
|
/// <summary>
|
|
/// Set the first time <c>ResolveSlotBatch</c> runs for this slot, so a
|
|
/// failed texture-dependency lookup is attempted and logged exactly
|
|
/// once per slot rather than once per candidate (S1 review finding).
|
|
/// </summary>
|
|
public bool BatchResolutionAttempted;
|
|
|
|
/// <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) {
|
|
var vertices = new List<VertexPositionNormalTexture>();
|
|
// 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>>();
|
|
// null value = the slot's surface override or Surface record failed
|
|
// to resolve; cached so the lookup and its diagnostic happen once
|
|
// per slot, not once per candidate (S1 review finding).
|
|
var slots = new Dictionary<int, CellSurfaceSlot?>();
|
|
|
|
CellSurfaceSlot? GetOrCreateSlot(int slot) {
|
|
if (slots.TryGetValue(slot, out var existing))
|
|
return existing;
|
|
CellSurfaceSlot? created = null;
|
|
if (TryResolveSlot(slot, out var surface, out var surfaceId)) {
|
|
created = new CellSurfaceSlot(surface, surfaceId) {
|
|
Mask = CellStructSideCandidates.InitialSurfaceMask(surface.Type),
|
|
};
|
|
}
|
|
slots[slot] = created;
|
|
return created;
|
|
}
|
|
|
|
var min = new Vector3(float.MaxValue);
|
|
var max = new Vector3(float.MinValue);
|
|
foreach (var vert in cellStruct.VertexArray.Vertices.Values) {
|
|
var localizedPos = Vector3.Transform(vert.Origin, transform);
|
|
min = Vector3.Min(min, localizedPos);
|
|
max = Vector3.Max(max, localizedPos);
|
|
}
|
|
var boundingBox = new BoundingBox(min, max);
|
|
|
|
int unknownSidesTypePolygons = 0;
|
|
|
|
bool TryResolveSlot(int slot, out Surface surface, out uint surfaceId) {
|
|
if (slot < surfaceOverrides.Count) {
|
|
surfaceId = 0x08000000u | surfaceOverrides[slot];
|
|
}
|
|
else {
|
|
surface = default!;
|
|
surfaceId = 0;
|
|
_logger.LogWarning($"Failed to find surface override for index {slot} in CellStruct id=0x{id:X16}");
|
|
return false;
|
|
}
|
|
|
|
if (!_dats.Portal.TryGet<Surface>(surfaceId, out surface!)) {
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> slot {slot} dropped (cellstruct id=0x{id:X16})");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Resolves and decodes the texture payload for ONE surface slot,
|
|
// exactly once. Identical decode logic to the ordinary-GfxObj path
|
|
// (PrepareGfxObjMeshData) — deliberately duplicated rather than
|
|
// shared, so a future change to one extraction path cannot silently
|
|
// change the other's behavior (contract §5.2's "Core and Content do
|
|
// not retain divergent CellStruct interpretations" is about NOT
|
|
// re-implementing the retail SIDE/SUBSET algorithm twice; the DAT
|
|
// texture-decode plumbing itself is unrelated to that rule).
|
|
void ResolveSlotBatch(CellSurfaceSlot state) {
|
|
state.BatchResolutionAttempted = true;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
textureDataIsCached = true;
|
|
}
|
|
else {
|
|
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
|
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
|
textureFormat = TextureFormat.RGBA8;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
|
|
textureData = _decodedTextureCache.GetOrCreate(
|
|
decodedTextureKey,
|
|
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
|
out textureDataIsCached);
|
|
}
|
|
else {
|
|
textureFormat = TextureFormat.RGBA8;
|
|
textureData = renderSurface.SourceData;
|
|
switch (renderSurface.Format) {
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
|
|
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
|
|
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_A8:
|
|
case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA:
|
|
textureData = new byte[texWidth * texHeight * 4];
|
|
if (surface.Type.HasFlag(SurfaceType.Additive)) {
|
|
TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
}
|
|
else {
|
|
TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
|
|
}
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
break;
|
|
default: return;
|
|
}
|
|
}
|
|
|
|
if (!TextureHelpers.IsCompressedFormat(renderSurface.Format))
|
|
{
|
|
textureData = _decodedTextureCache.RetainOrUse(
|
|
decodedTextureKey,
|
|
textureData,
|
|
out textureDataIsCached);
|
|
}
|
|
}
|
|
|
|
if (isClipMap && textureData != null) {
|
|
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
|
|
if (textureDataIsCached) {
|
|
var clonedData = new byte[textureData.Length];
|
|
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
|
textureData = clonedData;
|
|
textureDataIsCached = false;
|
|
}
|
|
|
|
for (int i = 0; i < textureData.Length; i += 4) {
|
|
if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) {
|
|
textureData[i + 3] = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})");
|
|
return;
|
|
}
|
|
|
|
isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive);
|
|
isTransparent = isSolid ? surface.ColorValue.Alpha < 255 :
|
|
(surface.Type.HasFlag(SurfaceType.Translucent) ||
|
|
surface.Type.HasFlag(SurfaceType.Base1ClipMap) ||
|
|
((uint)surface.Type & 0x100) != 0 || // Alpha
|
|
((uint)surface.Type & 0x200) != 0 || // InvAlpha
|
|
isAdditive ||
|
|
(surface.Translucency > 0.0f && surface.Translucency < 1.0f) ||
|
|
textureFormat == TextureFormat.A8 ||
|
|
textureFormat == TextureFormat.Rgba32f ||
|
|
isDxt3or5 ||
|
|
(sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 ||
|
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 ||
|
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 ||
|
|
sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5)));
|
|
|
|
state.Batch = new TextureBatchData {
|
|
Key = new TextureKey {
|
|
SurfaceId = surfaceId,
|
|
PaletteId = paletteId,
|
|
// Contract §3.6 point 3 / §8.2: the subset owner is the
|
|
// SLOT, not the TextureKey, so per-polygon Stippling
|
|
// must not be able to split (or wrongly merge) a slot's
|
|
// one texture payload. Fixed None here; the real,
|
|
// per-polygon-aggregated retail mask lives in
|
|
// RetailSurfaceMask instead.
|
|
Stippling = StipplingType.None,
|
|
IsSolid = isSolid,
|
|
},
|
|
TextureData = textureData!,
|
|
UploadPixelFormat = uploadPixelFormat,
|
|
UploadPixelType = uploadPixelType,
|
|
Translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type),
|
|
IsTransparent = isTransparent,
|
|
IsAdditive = isAdditive,
|
|
};
|
|
state.Format = (texWidth, texHeight, textureFormat);
|
|
}
|
|
|
|
foreach (var poly in cellStruct.Polygons.Values) {
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// Contract §3.2 (ConstructMesh count loop, pseudo-C 426859-426866,
|
|
// 0x0059E1B3-0x0059E1CD): the positive-surface stippling OR runs
|
|
// ONCE PER POLYGON, before and independent of the num_pts and
|
|
// sides_type branches. It therefore runs here even for a
|
|
// degenerate fan or an anomalous sides value (S1 review finding).
|
|
if (poly.PosSurface >= 0
|
|
&& GetOrCreateSlot(poly.PosSurface) is { } positiveSlot) {
|
|
positiveSlot.Mask = CellStructSideCandidates.ApplyStipplingMaskBit(
|
|
positiveSlot.Mask, CellStructPolygonSurfaceSide.Positive, poly.Stippling);
|
|
}
|
|
|
|
if (poly.VertexIds.Count < 3) continue;
|
|
|
|
// OH2/S1 chunk-1: side candidates come ONLY from sides_type
|
|
// (CellStructSideCandidates.GetCandidates, contract §3.4). This
|
|
// DatReaderWriter "CullMode" field IS the raw retail
|
|
// sides_type integer (0/1/2), not a GPU cull enum — see
|
|
// CellStructSideCandidates' own remarks. NoPos/NoNeg (below,
|
|
// IsUvAbsent) govern UV-array absence only (§3.5) and no
|
|
// longer suppress a candidate. A raw value outside 0/1/2 takes
|
|
// retail's default single-side shape (the loop bounds default to
|
|
// 1) and is only counted as a data anomaly.
|
|
int rawSidesType = (int)poly.SidesType;
|
|
if (!CellStructSideCandidates.IsRetailDefinedSidesType(rawSidesType))
|
|
unknownSidesTypePolygons++;
|
|
ReadOnlySpan<CellStructSideCandidate> candidates =
|
|
CellStructSideCandidates.GetCandidates(rawSidesType);
|
|
|
|
foreach (var candidate in candidates) {
|
|
short surfaceIdxRaw = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive
|
|
? poly.PosSurface
|
|
: poly.NegSurface;
|
|
if (surfaceIdxRaw < 0) continue;
|
|
|
|
var slotState = GetOrCreateSlot(surfaceIdxRaw);
|
|
if (slotState is null) continue; // override/Surface lookup failed; logged once per slot.
|
|
|
|
// Contract §4 admission is a per-slot fact known here: an
|
|
// untextured slot keeps its mask accounting (above) but gets
|
|
// no texture decode and no vertices, since retail never
|
|
// draws it and the prepared package need not carry it.
|
|
if (slotState.IsUntextured) continue;
|
|
|
|
if (!slotState.BatchResolutionAttempted) {
|
|
ResolveSlotBatch(slotState);
|
|
}
|
|
if (slotState.Batch is null) continue; // texture resolution failed a dependency lookup; logged once per slot.
|
|
|
|
bool useNegUv = candidate.UvSlot == CellStructPolygonSurfaceSide.Negative;
|
|
bool invertNormal = candidate.NormalSign < 0;
|
|
bool uvAbsent = CellStructSideCandidates.IsUvAbsent(candidate, poly.Stippling);
|
|
|
|
bool hasWrappingUVs = slotState.Batch.HasWrappingUVs;
|
|
BuildCellStructPolygonIndices(
|
|
poly,
|
|
cellStruct,
|
|
vertexLookup,
|
|
vertices,
|
|
slotState.Batch.Indices,
|
|
useNegUv,
|
|
uvAbsent,
|
|
invertNormal,
|
|
candidate.ReverseWinding,
|
|
transform,
|
|
ref hasWrappingUVs);
|
|
slotState.Batch.HasWrappingUVs = hasWrappingUVs;
|
|
}
|
|
}
|
|
|
|
int skippedUntexturedSlots = 0;
|
|
foreach (var slot in slots.Keys.OrderBy(s => s)) {
|
|
var state = slots[slot];
|
|
if (state is null) continue;
|
|
|
|
// Contract §4: built-EnvCell DrawMesh(arg4=1) admits a subset
|
|
// iff (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0. The
|
|
// slot's mask/ownership facts exist; nothing else was built.
|
|
if (state.IsUntextured) {
|
|
skippedUntexturedSlots++;
|
|
continue;
|
|
}
|
|
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;
|
|
|
|
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 a raw sides_type outside 0/1/2 (OH2 contract §3.4); they were constructed as retail's default single-side shape.",
|
|
id, unknownSidesTypePolygons);
|
|
}
|
|
if (skippedUntexturedSlots > 0) {
|
|
_logger.LogDebug(
|
|
"CellStruct id=0x{Id:X16}: {Count} surface slot(s) constructed but not emitted — untextured under retail's built-EnvCell admission (Surface.Type & 6) == 0 (OH2 contract §4).",
|
|
id, skippedUntexturedSlots);
|
|
}
|
|
|
|
return new ObjectMeshData {
|
|
ObjectId = id,
|
|
IsSetup = false,
|
|
Vertices = vertices.ToArray(),
|
|
TextureBatches = batchesByFormat,
|
|
BoundingBox = boundingBox,
|
|
SortCenter = Vector3.Zero,
|
|
SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f }
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds one candidate's fan vertices/indices for a CellStruct polygon
|
|
/// and appends them to <paramref name="indices"/>. Vertex identity and
|
|
/// UV-absence fallback follow contract §3.5 as arbitrated on the binary:
|
|
/// <c>copyVert</c> @0x0059C080 multiplies the authored normal by the
|
|
/// selected sign; an absent polygon UV-index array means UV INDEX 0
|
|
/// (the caller zeroes the index register, <c>ConstructMesh</c>
|
|
/// @0x0059E691 <c>xor ebx,ebx</c>), and the coordinate is zeroed only
|
|
/// when the vertex has no UV array, the index is negative, or the index
|
|
/// is out of the vertex's range. Fan winding is
|
|
/// <see cref="CellStructSideCandidates.TriangleFanIndices"/> exactly
|
|
/// (contract §3.4's forward/reversed table), not a re-derived formula.
|
|
/// </summary>
|
|
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
|
|
Dictionary<(ushort vertId, ushort uvIdx, bool negativeLane), ushort> vertexLookup,
|
|
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
|
|
bool useNegUv, bool uvAbsent, bool invertNormal, bool reverseWinding,
|
|
Matrix4x4 transform, ref bool hasWrappingUVs) {
|
|
|
|
var polyIndices = new List<ushort>();
|
|
|
|
for (int i = 0; i < poly.VertexIds.Count; i++) {
|
|
ushort vertId = (ushort)poly.VertexIds[i];
|
|
|
|
// Retail's UV-index selection, arbitrated 2026-09-02 on the
|
|
// PDB-paired binary (ConstructMesh @0x0059E683-0x0059E693):
|
|
// mov edi,[uv-index array]; test edi,edi; je -> xor ebx,ebx
|
|
// else movsx ebx, byte ptr [edx+edi]
|
|
// so an ABSENT polygon UV-index array (NoPos/NoNeg, contract
|
|
// §3.5) yields UV INDEX 0 — copyVert @0x0059C080 then reads the
|
|
// vertex's own UV slot 0 like any other index. copyVert zeroes
|
|
// the coordinate only when the index is negative (the array
|
|
// element is a SIGNED char: movsx), the index is >= the vertex's
|
|
// uv count, or the vertex has no UV array at all. It never
|
|
// clamps an out-of-range index to slot 0.
|
|
int uvIdxSigned = 0;
|
|
if (!uvAbsent) {
|
|
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
|
uvIdxSigned = unchecked((sbyte)(byte)poly.NegUVIndices[i]);
|
|
else if (!useNegUv && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
|
uvIdxSigned = unchecked((sbyte)(byte)poly.PosUVIndices[i]);
|
|
}
|
|
|
|
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
|
|
|
bool uvInRange = uvIdxSigned >= 0 && uvIdxSigned < vertex.UVs.Count;
|
|
Vector2 uv = uvInRange
|
|
? new Vector2(vertex.UVs[uvIdxSigned].U, vertex.UVs[uvIdxSigned].V)
|
|
: Vector2.Zero;
|
|
|
|
// 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 && uvInRange) {
|
|
if (uv.X < 0f || uv.X > 1f || uv.Y < 0f || uv.Y > 1f) {
|
|
hasWrappingUVs = true;
|
|
}
|
|
}
|
|
|
|
if (!vertexLookup.TryGetValue(key, out var idx)) {
|
|
|
|
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
|
if (invertNormal) {
|
|
normal = -normal;
|
|
}
|
|
|
|
idx = (ushort)vertices.Count;
|
|
vertices.Add(new VertexPositionNormalTexture(
|
|
Vector3.Transform(vertex.Origin, transform),
|
|
normal,
|
|
uv
|
|
));
|
|
vertexLookup[key] = idx;
|
|
}
|
|
polyIndices.Add(idx);
|
|
}
|
|
|
|
// CellStructSideCandidates.TriangleFanIndices is the single source
|
|
// of truth for retail's forward/reversed fan order (contract §3.4);
|
|
// this loop drives it rather than re-deriving the index arithmetic.
|
|
int triangleCount = polyIndices.Count - 2;
|
|
for (int t = 0; t < triangleCount; t++) {
|
|
var (a, b, c) = CellStructSideCandidates.TriangleFanIndices(t, reverseWinding);
|
|
indices.Add(polyIndices[a]);
|
|
indices.Add(polyIndices[b]);
|
|
indices.Add(polyIndices[c]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the shared 32x32 RGBA fill for <paramref name="color"/>, decoding
|
|
/// once per distinct ARGB value. Both call sites (GfxObj and CellStruct isSolid
|
|
/// surfaces) hardcode 32x32 immediately before calling this, so keying purely
|
|
/// on the packed ARGB value cannot collide with a different-sized request.
|
|
/// The returned array is shared and must be treated as immutable by callers,
|
|
/// same contract as <see cref="DecodedTextureCache"/> pixels.
|
|
/// internal (not private): exercised directly by
|
|
/// SolidColorTextureCacheTests (AcDream.Content.csproj grants
|
|
/// InternalsVisibleTo to AcDream.Content.Tests).
|
|
/// </summary>
|
|
internal byte[] GetOrCreateSolidColorTexture(DatReaderWriter.Types.ColorARGB color, int width, int height) {
|
|
uint key = ((uint)color.Alpha << 24) | ((uint)color.Red << 16) | ((uint)color.Green << 8) | color.Blue;
|
|
return _solidColorTextureCache.GetOrAdd(
|
|
key,
|
|
_ => TextureHelpers.CreateSolidColorTexture(color, width, height));
|
|
}
|
|
|
|
private static DecodedTextureKey CreateDecodedTextureKey(
|
|
uint renderSurfaceId,
|
|
DatReaderWriter.Enums.PixelFormat format,
|
|
bool isClipMap,
|
|
bool isAdditive)
|
|
{
|
|
bool clipAffectsDecode = format is
|
|
DatReaderWriter.Enums.PixelFormat.PFID_INDEX16 or
|
|
DatReaderWriter.Enums.PixelFormat.PFID_P8;
|
|
bool additiveAffectsDecode = format is
|
|
DatReaderWriter.Enums.PixelFormat.PFID_A8 or
|
|
DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA;
|
|
return new DecodedTextureKey(
|
|
renderSurfaceId,
|
|
clipAffectsDecode && isClipMap,
|
|
additiveAffectsDecode && isAdditive);
|
|
}
|
|
|
|
private byte[] DecodeCompressedTexture(
|
|
RenderSurface renderSurface,
|
|
int width,
|
|
int height)
|
|
{
|
|
var textureData = new byte[width * height * 4];
|
|
CompressionFormat compressionFormat = renderSurface.Format switch
|
|
{
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1,
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2,
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3,
|
|
_ => throw new NotSupportedException(
|
|
$"Unsupported compressed format: {renderSurface.Format}"),
|
|
};
|
|
|
|
using var image = _bcDecoder.Value!.DecodeRawToImageRgba32(
|
|
renderSurface.SourceData,
|
|
width,
|
|
height,
|
|
compressionFormat);
|
|
image.CopyPixelDataTo(textureData);
|
|
return textureData;
|
|
}
|
|
|
|
private static bool CanPreserveCompressedTexture(
|
|
DatReaderWriter.Enums.PixelFormat format,
|
|
bool isClipMap,
|
|
float translucency) =>
|
|
TextureHelpers.IsCompressedFormat(format)
|
|
&& !isClipMap
|
|
&& translucency <= 0.0f;
|
|
|
|
private static TextureFormat ToTextureFormat(
|
|
DatReaderWriter.Enums.PixelFormat format) => format switch
|
|
{
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => TextureFormat.DXT1,
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => TextureFormat.DXT3,
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => TextureFormat.DXT5,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(format),
|
|
format,
|
|
"The source is not a supported block-compressed texture."),
|
|
};
|
|
|
|
private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale,
|
|
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
|
|
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, ref bool hasWrappingUVs) {
|
|
|
|
var polyIndices = new List<ushort>();
|
|
|
|
for (int i = 0; i < poly.VertexIds.Count; i++) {
|
|
ushort vertId = (ushort)poly.VertexIds[i];
|
|
ushort uvIdx = 0;
|
|
|
|
if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
|
|
uvIdx = poly.NegUVIndices[i];
|
|
else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count)
|
|
uvIdx = poly.PosUVIndices[i];
|
|
|
|
if (!gfxObj.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
|
|
|
if (uvIdx >= vertex.UVs.Count) {
|
|
uvIdx = 0;
|
|
}
|
|
|
|
var key = (vertId, uvIdx, useNegSurface);
|
|
|
|
if (!hasWrappingUVs) {
|
|
var uvCheck = vertex.UVs.Count > 0
|
|
? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V)
|
|
: Vector2.Zero;
|
|
if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) {
|
|
hasWrappingUVs = true;
|
|
}
|
|
}
|
|
|
|
if (!UVLookup.TryGetValue(key, out var idx)) {
|
|
var uv = vertex.UVs.Count > 0
|
|
? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V)
|
|
: Vector2.Zero;
|
|
|
|
var normal = Vector3.Normalize(vertex.Normal);
|
|
if (useNegSurface) {
|
|
normal = -normal;
|
|
}
|
|
|
|
idx = (ushort)vertices.Count;
|
|
vertices.Add(new VertexPositionNormalTexture(
|
|
vertex.Origin * scale,
|
|
normal,
|
|
uv
|
|
));
|
|
UVLookup[key] = idx;
|
|
}
|
|
polyIndices.Add(idx);
|
|
}
|
|
|
|
if (useNegSurface) {
|
|
// Reverse winding for negative surface so it's visible from the other side
|
|
for (int i = 2; i < polyIndices.Count; i++) {
|
|
indices.Add(polyIndices[0]);
|
|
indices.Add(polyIndices[i - 1]);
|
|
indices.Add(polyIndices[i]);
|
|
}
|
|
}
|
|
else {
|
|
for (int i = 2; i < polyIndices.Count; i++) {
|
|
indices.Add(polyIndices[i]);
|
|
indices.Add(polyIndices[i - 1]);
|
|
indices.Add(polyIndices[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
public (Vector3 Min, Vector3 Max) ComputeBounds(GfxObj gfxObj, Vector3 scale) {
|
|
var min = new Vector3(float.MaxValue);
|
|
var max = new Vector3(float.MinValue);
|
|
foreach (var vert in gfxObj.VertexArray.Vertices.Values) {
|
|
var p = vert.Origin * scale;
|
|
min = Vector3.Min(min, p);
|
|
max = Vector3.Max(max, p);
|
|
}
|
|
return (min, max);
|
|
}
|
|
|
|
private ObjectMeshData? PrepareCellStructEdgeLineData(ulong id, Dictionary<uint, CellStruct> cellStructs, Matrix4x4 transform, CancellationToken ct) {
|
|
var cellStructList = cellStructs.ToList();
|
|
if (cellStructList.Count == 0) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate bounding box from ALL vertices in all cell structures
|
|
var min = new Vector3(float.MaxValue);
|
|
var max = new Vector3(float.MinValue);
|
|
var allEdgeLines = new List<Vector3>();
|
|
|
|
// Process each CellStruct and collect all edge lines
|
|
foreach (var cellStructKvp in cellStructList) {
|
|
var cellStruct = cellStructKvp.Value;
|
|
|
|
// Build edge lines for this CellStruct
|
|
var edgeLines = EdgeLineBuilder.BuildEdgeLines(cellStruct);
|
|
|
|
// Transform edge lines to world space and add to collection
|
|
foreach (var edgeLine in edgeLines) {
|
|
allEdgeLines.Add(Vector3.Transform(edgeLine, transform));
|
|
}
|
|
|
|
// Update bounding box with vertices from this CellStruct
|
|
foreach (var vert in cellStruct.VertexArray.Vertices.Values) {
|
|
var localizedPos = Vector3.Transform(vert.Origin, transform);
|
|
min = Vector3.Min(min, localizedPos);
|
|
max = Vector3.Max(max, localizedPos);
|
|
}
|
|
}
|
|
|
|
if (allEdgeLines.Count == 0) {
|
|
return null;
|
|
}
|
|
|
|
var boundingBox = new BoundingBox(min, max);
|
|
|
|
// Create minimal mesh data for edge line rendering
|
|
// We still need some vertices for rendering system to work, but they'll be transparent
|
|
var vertices = new List<VertexPositionNormalTexture> {
|
|
new VertexPositionNormalTexture { Position = Vector3.Zero, Normal = Vector3.UnitZ, UV = Vector2.Zero }
|
|
};
|
|
var indices = new List<ushort> { 0, 0, 0 }; // Dummy triangle
|
|
|
|
// Create a transparent texture for base triangles (so only edge lines are visible)
|
|
var transparentTexture = TextureHelpers.CreateSolidColorTexture(new ColorARGB { Alpha = 0, Red = 255, Green = 255, Blue = 255 }, 1, 1);
|
|
|
|
var result = new ObjectMeshData {
|
|
ObjectId = id,
|
|
IsSetup = false,
|
|
Vertices = vertices.ToArray(),
|
|
Batches = new List<MeshBatchData> {
|
|
new MeshBatchData {
|
|
Indices = indices.ToArray(),
|
|
TextureFormat = (1, 1, TextureFormat.RGBA8),
|
|
TextureKey = new TextureKey {
|
|
SurfaceId = 0xFFFFFFFF, // Dummy surface ID
|
|
PaletteId = 0,
|
|
Stippling = StipplingType.NoPos,
|
|
IsSolid = true
|
|
},
|
|
TextureIndex = 0,
|
|
TextureData = transparentTexture,
|
|
UploadPixelFormat = UploadPixelFormat.Rgba,
|
|
UploadPixelType = UploadPixelType.UnsignedByte,
|
|
CullMode = CullMode.None
|
|
}
|
|
},
|
|
// Also populate TextureBatches for GPU upload
|
|
TextureBatches = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> {
|
|
[(1, 1, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
|
new TextureBatchData {
|
|
Indices = indices.ToList(),
|
|
Key = new TextureKey {
|
|
SurfaceId = 0xFFFFFFFF, // Dummy surface ID
|
|
PaletteId = 0,
|
|
Stippling = StipplingType.NoPos,
|
|
IsSolid = true
|
|
},
|
|
TextureData = transparentTexture,
|
|
UploadPixelFormat = UploadPixelFormat.Rgba,
|
|
UploadPixelType = UploadPixelType.UnsignedByte,
|
|
CullMode = CullMode.None,
|
|
IsTransparent = false // Render in opaque pass but transparent
|
|
}
|
|
}
|
|
},
|
|
BoundingBox = boundingBox,
|
|
SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f }
|
|
};
|
|
|
|
// Store all edge lines in mesh data for later use in UploadMeshData
|
|
result.EdgeLines = allEdgeLines.ToArray();
|
|
|
|
return result;
|
|
}
|
|
}
|