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; /// /// MP1a (2026-07-05): the GL-free CPU half of the former ObjectMeshManager — /// dat read → polygon walk → vertex/index build → inline texture decode → /// . 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. /// 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 = 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 _solidColorTextureCache = new(); /// Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review). public CacheStats DecodedTextureCacheStats => _decodedTextureCache.Stats; /// /// MP1a mechanical seam: receives particle-preload meshes staged /// mid-extraction (see ). 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 _stagedMeshData 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. /// private readonly Action? _sideStagedSink; public MeshExtractor(IDatReaderWriter dats, ILogger logger, Action? sideStagedSink) { _dats = dats; _logger = logger; _sideStagedSink = sideStagedSink; _physicsScripts = new RetailPhysicsScriptLoader(dats.Portal); } /// /// 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. /// 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(datId, out var setup)) return null; return PrepareSetupMeshData(id, setup, ct); } else if (type == DBObjType.GfxObj) { if (!db.TryGet(datId, out var gfxObj)) return null; return PrepareGfxObjMeshData(id, gfxObj, Vector3.One, ct); } else if (type == DBObjType.EnvCell) { if (!db.TryGet(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(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(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(); var processedScripts = new HashSet(); 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 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(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(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(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(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(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(); var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); 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(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(surface.OrigTextureId, out var surfaceTexture)) { var renderSurfaceId = surfaceTexture.Textures.First(); if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { // check highres if (!_dats.HighRes.TryGet(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(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(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(); 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(); 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. Without this, calling TryGet 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(stab.Id, out var stabSetup)) { var stabEmitters = new List(); var processedScripts = new HashSet(); 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(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 } }; } /// /// Per-source-surface-array-index accumulator for /// . Retail's /// D3DPolyRender::ConstructMesh @0x0059DFA0 allocates one /// MeshBatchType triangle-attribute record and one /// isStippledOrAlphaedMask 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 /// , in source polygon order, regardless of the /// resolved Surface DID, texture format, or that polygon's own /// sides_type/stippling value. /// private sealed class CellSurfaceSlot { public CellSurfaceSlot(Surface surface, uint surfaceId) { Surface = surface; SurfaceId = surfaceId; } /// Resolved once per slot; every candidate targeting this slot shares it. public Surface Surface { get; } public uint SurfaceId { get; } /// /// Retail's built-EnvCell admission decided at slot creation /// (RenderDeviceD3D::DrawEnvCell @0x0059F170 → /// D3DPolyRender::DrawMesh(..., arg4=1) @0x0059D4A0 skips a /// subset unless (Surface.Type & 6) != 0, 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. /// public bool IsUntextured => RetailUntexturedSurfacePolicy.IsUntextured(Surface.Type); /// /// Set the first time ResolveSlotBatch 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). /// public bool BatchResolutionAttempted; /// /// Retail's isStippledOrAlphaedMask byte for this slot: /// once, /// then /// per polygon candidate touching the slot (contract §3.2). /// public int Mask; /// /// 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. /// public TextureBatchData? Batch; /// (Width, Height, TextureFormat) storage-grouping key for , set alongside it. public (int Width, int Height, TextureFormat Format) Format; } /// /// 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 /// CPolygon::sides_type /// (, /// D3DPolyRender::ConstructMesh @0x0059DFA0, contract §3.4); /// NoPos/NoNeg mean "this side's UV-index array is absent" /// only (CPolygon::UnPack @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 — is that /// per-slot accumulator. A slot whose resolved Surface.Type is /// untextured () /// is fully constructed but NOT emitted into the prepared output, /// matching RenderDeviceD3D::DrawEnvCell @0x0059F170 → /// D3DPolyRender::DrawMesh(..., arg4=1) @0x0059D4A0's built-EnvCell /// admission test (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 /// (contract §4). Retires the AP-234 approximation this method used to /// carry (docs/architecture/retail-divergence-register.md). /// public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, IReadOnlyList surfaceOverrides, Matrix4x4 transform, CancellationToken ct) { var vertices = new List(); // 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>(); // 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(); 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(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(surface.OrigTextureId, out var surfaceTexture)) { var renderSurfaceId = surfaceTexture.Textures.First(); if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { if (!_dats.HighRes.TryGet(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(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(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 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(); 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 } }; } /// /// Builds one candidate's fan vertices/indices for a CellStruct polygon /// and appends them to . Vertex identity and /// UV-absence fallback follow contract §3.5 as arbitrated on the binary: /// copyVert @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, ConstructMesh /// @0x0059E691 xor ebx,ebx), 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 /// exactly /// (contract §3.4's forward/reversed table), not a re-derived formula. /// private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, Dictionary<(ushort vertId, ushort uvIdx, bool negativeLane), ushort> vertexLookup, List vertices, List indices, bool useNegUv, bool uvAbsent, bool invertNormal, bool reverseWinding, Matrix4x4 transform, ref bool hasWrappingUVs) { var polyIndices = new List(); 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]); } } /// /// Returns the shared 32x32 RGBA fill for , 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 pixels. /// internal (not private): exercised directly by /// SolidColorTextureCacheTests (AcDream.Content.csproj grants /// InternalsVisibleTo to AcDream.Content.Tests). /// 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 vertices, List indices, bool useNegSurface, ref bool hasWrappingUVs) { var polyIndices = new List(); 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 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(); // 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 { new VertexPositionNormalTexture { Position = Vector3.Zero, Normal = Vector3.UnitZ, UV = Vector2.Zero } }; var indices = new List { 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 { 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> { [(1, 1, TextureFormat.RGBA8)] = new List { 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; } }