From 30cc1e282e72c99d5aeb373b71919b8f5ba7c4a9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 20:19:52 +0200 Subject: [PATCH] refactor(pipeline): MP1a - MeshExtractor extracted to AcDream.Content (verbatim move) --- docs/architecture/worldbuilder-inventory.md | 44 + .../Rendering/Wb/DatCollectionAdapter.cs | 1 + .../Rendering/Wb/ObjectMeshManager.cs | 1135 +--------------- .../Wb => AcDream.Content}/EdgeLineBuilder.cs | 6 +- .../IDatReaderWriter.cs | 7 +- src/AcDream.Content/MeshExtractor.cs | 1180 +++++++++++++++++ 6 files changed, 1260 insertions(+), 1113 deletions(-) rename src/{AcDream.App/Rendering/Wb => AcDream.Content}/EdgeLineBuilder.cs (96%) rename src/{AcDream.App/Rendering/Wb => AcDream.Content}/IDatReaderWriter.cs (92%) create mode 100644 src/AcDream.Content/MeshExtractor.cs diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index b0527cea..4eabea66 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -30,6 +30,50 @@ our tree (see CLAUDE.md for the full breakdown): interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26 internal `_dats.*` call sites — above the 20-site inline-swap threshold). +**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.** +The GL-free portion of the former `ObjectMeshManager` — dat read → polygon +walk → vertex/index build → inline BCn/palette texture decode → +`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/` +assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact +same extraction code offline without an OpenGL context. This was a +mechanical, verbatim move (namespace + visibility only) per +`docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md` — no +behavior change, no divergence-register row. + +- `src/AcDream.Content/MeshExtractor.cs` — the `Prepare*` family + (`PrepareMeshData`, `PrepareSetupMeshData`, `PrepareGfxObjMeshData`, + `PrepareEnvCellMeshData`, `PrepareCellStructMeshData`, + `PrepareCellStructEdgeLineData`) + private helpers (`CollectParts`, + `CollectEmittersFromScript`, `ComputeBounds`, `BuildPolygonIndices`, + `BuildCellStructPolygonIndices`) and the decoded-texture cache / + `ThreadLocal` that back them. +- `src/AcDream.Content/ObjectMeshData.cs` — the CPU-side boundary records: + `VertexPositionNormalTexture`, `StagedEmitter`, `ObjectMeshData`, + `MeshBatchData`, `TextureBatchData`. +- `src/AcDream.Content/TextureKey.cs` — the atlas dedup key, lifted out of + the GL-owning `TextureAtlasManager` (which stays in App and now + references the lifted struct). +- `src/AcDream.Content/IDatReaderWriter.cs`, `EdgeLineBuilder.cs` — GL-free + dependencies of the extractor, moved (namespace-only) alongside it. +- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (now a + thin wrapper owning the staged-queue/worker-pool/Dispose-quiesce lifecycle + and all GL upload — constructs one `MeshExtractor` and delegates every + former `Prepare*` call site to it), `ObjectRenderData`/`ObjectRenderBatch` + (hold a GL `TextureAtlasManager` field), `TextureAtlasManager`, + `DatCollectionAdapter` (concrete `DatCollection`-backed implementation of + the now-Content-namespaced `IDatReaderWriter`), `GeometryUtils` (used only + by App-side raycasting, not by extraction), `AcSurfaceMetadata`/ + `AcSurfaceMetadataTable` (not on the extraction path), `Building.cs` + (explicitly out of scope). +- `AcDream.Core` is untouched; `AcDream.Content` references `AcDream.Core` + (for `TextureHelpers`, `Sphere`/`BoundingBox` via `Chorizite.Core.Lib`); + `AcDream.App` references `AcDream.Content`. `AcDream.Core` does NOT + reference `AcDream.Content` (one-way dependency, per Code Structure Rule 2). +- Reason: MP1 (`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` + §6.1) — the bake tool needs the identical mesh/texture extraction code + running with no GL context, so baked pak output and live-client output stay + byte-identical. + **Workflow:** Before re-implementing any AC-specific rendering or dat-handling algorithm, **check this inventory first**. If we already extracted it (🟢 sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has diff --git a/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs index 60ed18c7..e2598750 100644 --- a/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs @@ -1,3 +1,4 @@ +using AcDream.Content; using DatReaderWriter; using DatReaderWriter.Enums; using DatReaderWriter.Lib.IO; diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index c41ab98e..be98a1c2 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -98,6 +98,14 @@ namespace AcDream.App.Rendering.Wb { private readonly IDatReaderWriter _dats; private readonly ILogger _logger; + /// + /// MP1a (2026-07-05): the GL-free CPU extraction half, verbatim-moved to + /// AcDream.Content so the MP1b bake tool can run it without a GL context. + /// Owns the dat read → mesh build → inline texture decode pipeline; this + /// class keeps the queue/worker lifecycle and all GL upload. + /// + private readonly MeshExtractor _extractor; + internal IDatReaderWriter Dats => _dats; public bool IsDisposed { get; private set; } @@ -149,12 +157,6 @@ namespace AcDream.App.Rendering.Wb { return false; } - // Cache for decoded textures to avoid redundant BCn decoding - private readonly ConcurrentQueue _decodedTextureLru = new(); - private readonly ConcurrentDictionary _decodedTextureCache = new(); - private const int MaxDecodedTextures = 128; - private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); - public GlobalMeshBuffer? GlobalBuffer { get; } private readonly bool _useModernRendering; @@ -165,6 +167,7 @@ namespace AcDream.App.Rendering.Wb { _graphicsDevice = graphicsDevice; _dats = dats; _logger = logger; + _extractor = new MeshExtractor(_dats, _logger); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); @@ -449,7 +452,7 @@ namespace AcDream.App.Rendering.Wb { uint envId = 0x0D000000u | req.EnvironmentId; if (_dats.Portal.TryGet(envId, out var environment)) { if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) { - data = PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); + data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); // TEMP diagnostic #105 (strip with fix): a null prep here means // this deduplicated cell geometry will NEVER render anywhere. if (data == null) @@ -470,7 +473,13 @@ namespace AcDream.App.Rendering.Wb { if ((id & 0x2_0000_0000UL) != 0) Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it"); // If it's a direct setup or gfxobj, make sure background loads don't abort half-way - data = PrepareMeshData(id, isSetup, CancellationToken.None); + data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None); + } + // MP1a: drain any mesh data the extractor staged as a side effect + // (particle emitter GfxObj preloads inside CollectEmittersFromScript) + // and enqueue it the same way the top-level result is enqueued below. + foreach (var sideStaged in _extractor.DrainSideStaged()) { + _stagedMeshData.Enqueue(sideStaged); } if (data != null) { lock (_cpuMeshCache) { @@ -509,62 +518,12 @@ namespace AcDream.App.Rendering.Wb { /// 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. + /// + /// MP1a (2026-07-05): delegates to , + /// the verbatim-moved GL-free extraction dispatcher. See . /// 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); - var resolutions = _dats.ResolveId(datId).ToList(); - var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); - if (selectedResolution == null) return null; - - var type = selectedResolution.Type; - var db = selectedResolution.Database; - - 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) { - // Ignore - return null; - } - catch (Exception ex) { - _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id); - return null; - } + return _extractor.PrepareMeshData(id, isSetup, ct); } /// @@ -707,7 +666,7 @@ namespace AcDream.App.Rendering.Wb { bool hasBounds = false; var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); + _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); result = hasBounds ? (min, max) : null; } else if (type == DBObjType.EnvCell) { @@ -734,13 +693,13 @@ namespace AcDream.App.Rendering.Wb { bool hasBounds = false; var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); + _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); result = hasBounds ? (min, max) : null; } } else { if (!db.TryGet(datId, out var gfxObj)) return null; - result = ComputeBounds(gfxObj, Vector3.One); + result = _extractor.ComputeBounds(gfxObj, Vector3.One); } _boundsCache[id] = result; return result; @@ -753,181 +712,6 @@ namespace AcDream.App.Rendering.Wb { #region Private: Background Preparation - 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) { - if (_dats.Portal.TryGet(scriptId, out var script)) { - foreach (var hook in script.ScriptData) { - if (hook.Hook.HookType == AnimationHookType.CreateParticle && 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) { - _stagedMeshData.Enqueue(meshData); - } - } - if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { - var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct); - if (meshData != null) { - _stagedMeshData.Enqueue(meshData); - } - } - } - } - } - } - } - - private 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(); - - var resolutions = _dats.ResolveId(id).ToList(); - var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); - if (selectedResolution == null) return; - - var type = selectedResolution.Type; - var db = selectedResolution.Database; - - 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; - } - } - } - /// /// #113: the set of polygon ids referenced by the GfxObj's drawing BSP — /// the polys retail actually renders (D3DPolyRender traverses the BSP; @@ -949,769 +733,6 @@ namespace AcDream.App.Rendering.Wb { if (node.NegNode is not null) CollectDrawingBspPolygonIds(node.NegNode, ids); } - 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 - if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { - 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; - PixelFormat? uploadPixelFormat = null; - PixelType? uploadPixelType = null; - bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); - bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); - uint paletteId = 0; - bool isDxt3or5 = false; - DatReaderWriter.Enums.PixelFormat? sourceFormat = null; - var isAdditive = false; - var isTransparent = false; - - if (isSolid) { - texWidth = texHeight = 32; - textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = PixelFormat.Rgba; - } - 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; - - if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { - isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = PixelFormat.Rgba; - - if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) { - // use cached data - } - else { - textureData = new byte[texWidth * texHeight * 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, texWidth, texHeight, compressionFormat)) { - image.CopyPixelDataTo(textureData); - } - _decodedTextureCache.TryAdd(renderSurfaceId, textureData); - } - - 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 (_decodedTextureCache.ContainsKey(renderSurfaceId)) { - var clonedData = new byte[textureData.Length]; - System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); - textureData = clonedData; - } - - 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; - 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 = PixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = PixelFormat.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 = PixelFormat.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 = PixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = PixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = PixelFormat.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 = PixelFormat.Rgba; - break; - default: - throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}"); - } - } - - 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 (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) { - 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, - 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, - SelectionSphere = 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 } - }; - } - - private ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List surfaceOverrides, Matrix4x4 transform, 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 = 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); - - foreach (var poly in cellStruct.Polygons.Values) { - ct.ThrowIfCancellationRequested(); - if (poly.VertexIds.Count < 3) continue; - - // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this - // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a - // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, - // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still - // suppress hidden portal/cap faces before they reach our mesh. - bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); - bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); - - if (hasPos) - AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); - if (hasPos && poly.SidesType == CullMode.None) { - AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); - } - else if (hasNeg && poly.SidesType == CullMode.Clockwise) { - AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); - } - - void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { - if (surfaceIdx < 0) return; - - uint surfaceId; - if (surfaceIdx < surfaceOverrides.Count) { - surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx]; - } - else { - _logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}"); - return; - } - - if (!_dats.Portal.TryGet(surfaceId, out var surface)) { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})"); - return; - } - - int texWidth, texHeight; - byte[] textureData; - TextureFormat textureFormat; - PixelFormat? uploadPixelFormat = null; - PixelType? uploadPixelType = null; - bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); - bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); - uint paletteId = 0; - bool isDxt3or5 = false; - DatReaderWriter.Enums.PixelFormat? sourceFormat = null; - var isAdditive = false; - var isTransparent = false; - - if (isSolid) { - texWidth = texHeight = 32; - textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = PixelFormat.Rgba; - } - 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; - - if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) { - textureData = cachedData; - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = PixelFormat.Rgba; - } - 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 = PixelFormat.Rgba; - - textureData = new byte[texWidth * texHeight * 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, texWidth, texHeight, compressionFormat)) { - image.CopyPixelDataTo(textureData); - } - } - 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 = PixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = PixelFormat.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 = PixelFormat.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 = PixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = PixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = PixelFormat.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 = PixelFormat.Rgba; - break; - default: return; - } - } - - // Add to cache with LRU logic - if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) { - _decodedTextureLru.Enqueue(renderSurfaceId); - if (_decodedTextureCache.Count > MaxDecodedTextures) { - if (_decodedTextureLru.TryDequeue(out var evictedId)) { - _decodedTextureCache.TryRemove(evictedId, out _); - } - } - } - } - - 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 - var clonedData = new byte[textureData.Length]; - System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); - textureData = clonedData; - - 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))); - - 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, - IsTransparent = isTransparent, - IsAdditive = isAdditive - }; - batches.Add(batch); - } - - // Helper for CellStruct vertices - bool batchHasWrappingUVs = batch.HasWrappingUVs; - BuildCellStructPolygonIndices( - poly, - cellStruct, - UVLookup, - vertices, - batch.Indices, - useNegUv, - invertNormal, - reverseWinding, - transform, - ref batchHasWrappingUVs); - batch.HasWrappingUVs = batchHasWrappingUVs; - } - } - - 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 } - }; - } - - private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, - Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, - List vertices, List indices, - bool useNegUv, 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]; - ushort uvIdx = 0; - - if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) - uvIdx = poly.NegUVIndices[i]; - else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) - uvIdx = poly.PosUVIndices[i]; - - if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; - - if (uvIdx >= vertex.UVs.Count) { - uvIdx = 0; - } - - var key = (vertId, uvIdx, invertNormal); - - 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(Vector3.TransformNormal(vertex.Normal, transform)); - if (invertNormal) { - normal = -normal; - } - - idx = (ushort)vertices.Count; - vertices.Add(new VertexPositionNormalTexture( - Vector3.Transform(vertex.Origin, transform), - normal, - uv - )); - UVLookup[key] = idx; - } - polyIndices.Add(idx); - } - - if (reverseWinding) { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[i]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[0]); - } - } - else { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[0]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[i]); - } - } - } - - 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]); - } - } - } - #endregion #region Private: GPU Upload @@ -1932,17 +953,6 @@ namespace AcDream.App.Rendering.Wb { #endregion - private (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 void UnloadObject(ulong key) { if (!_renderData.TryGetValue(key, out var data)) return; @@ -2066,102 +1076,5 @@ namespace AcDream.App.Rendering.Wb { } }); } - - 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 = PixelFormat.Rgba, - UploadPixelType = PixelType.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 = PixelFormat.Rgba, - UploadPixelType = PixelType.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; - } } } diff --git a/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs b/src/AcDream.Content/EdgeLineBuilder.cs similarity index 96% rename from src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs rename to src/AcDream.Content/EdgeLineBuilder.cs index cec23146..028dd821 100644 --- a/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs +++ b/src/AcDream.Content/EdgeLineBuilder.cs @@ -1,7 +1,11 @@ using System.Numerics; using DatReaderWriter.Types; -namespace AcDream.App.Rendering.Wb { +// MP1a (2026-07-05, Task 4): moved to AcDream.Content, namespace only — +// consumed by MeshExtractor.PrepareCellStructEdgeLineData, which must be +// GL-free. + +namespace AcDream.Content { public static class EdgeLineBuilder { public static List BuildEdgeLines(CellStruct cellStruct) { var edgeMap = new Dictionary>(); diff --git a/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs b/src/AcDream.Content/IDatReaderWriter.cs similarity index 92% rename from src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs rename to src/AcDream.Content/IDatReaderWriter.cs index d6c7d9e3..fc63b11f 100644 --- a/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs +++ b/src/AcDream.Content/IDatReaderWriter.cs @@ -9,8 +9,13 @@ using System.Diagnostics.CodeAnalysis; // WorldBuilder.Shared project reference can be dropped. // The only consumer of IDatReaderWriter in acdream is DatCollectionAdapter + // ObjectMeshManager, both already in this namespace. +// +// MP1a (2026-07-05, Task 4): moved to AcDream.Content, namespace only — +// MeshExtractor's constructor takes IDatReaderWriter and must be GL-free. +// DatCollectionAdapter (the concrete DatCollection-backed implementation) +// stays in AcDream.App; it now implements this Content-namespaced interface. -namespace AcDream.App.Rendering.Wb; +namespace AcDream.Content; /// /// Interface for the dat reader/writer diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs new file mode 100644 index 00000000..66b4d373 --- /dev/null +++ b/src/AcDream.Content/MeshExtractor.cs @@ -0,0 +1,1180 @@ +using AcDream.Core.Rendering.Wb; +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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading; +using CullMode = DatReaderWriter.Enums.CullMode; +using PixelFormat = Silk.NET.OpenGL.PixelFormat; +using PixelType = Silk.NET.OpenGL.PixelType; +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; + + // Cache for decoded textures to avoid redundant BCn decoding + private readonly ConcurrentQueue _decodedTextureLru = new(); + private readonly ConcurrentDictionary _decodedTextureCache = new(); + private const int MaxDecodedTextures = 128; + private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); + + /// + /// MP1a mechanical seam: retail's particle-preload side effect (see + /// ) used to enqueue directly onto + /// ObjectMeshManager's runtime staged-upload queue (_stagedMeshData). + /// That queue is a GL-upload-lifecycle concern and stays in App per the + /// plan's binding rules, so the extractor collects the same side-effect + /// output here instead; ObjectMeshManager drains it via + /// immediately after each Prepare* call and + /// enqueues the results itself. No behavior change — same objects reach + /// the same queue, just via an explicit hand-off instead of a shared field. + /// + private readonly List _sideStaged = new(); + + public MeshExtractor(IDatReaderWriter dats, ILogger logger) { + _dats = dats; + _logger = logger; + } + + /// + /// Drains and returns any mesh data staged as a side effect of the most + /// recent Prepare* call (particle emitter GfxObj preloads). See + /// . + /// + public List DrainSideStaged() { + if (_sideStaged.Count == 0) return new List(); + var result = new List(_sideStaged); + _sideStaged.Clear(); + return result; + } + + /// + /// 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); + var resolutions = _dats.ResolveId(datId).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return null; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + 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) { + // Ignore + return null; + } + catch (Exception ex) { + _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id); + return null; + } + } + + public 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 + }; + } + + public void CollectEmittersFromScript(uint scriptId, List emitters, CancellationToken ct) { + if (_dats.Portal.TryGet(scriptId, out var script)) { + foreach (var hook in script.ScriptData) { + if (hook.Hook.HookType == AnimationHookType.CreateParticle && 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) { + _sideStaged.Add(meshData); + } + } + if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { + var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct); + if (meshData != null) { + _sideStaged.Add(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(); + + var resolutions = _dats.ResolveId(id).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + 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; + } + } + } + + public 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 + if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { + 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; + PixelFormat? uploadPixelFormat = null; + PixelType? uploadPixelType = null; + bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + 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; + + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + + if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) { + // use cached data + } + else { + textureData = new byte[texWidth * texHeight * 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, texWidth, texHeight, compressionFormat)) { + image.CopyPixelDataTo(textureData); + } + _decodedTextureCache.TryAdd(renderSurfaceId, textureData); + } + + 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 (_decodedTextureCache.ContainsKey(renderSurfaceId)) { + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + } + + 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; + 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 = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.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 = PixelFormat.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 = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.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 = PixelFormat.Rgba; + break; + default: + throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}"); + } + } + + 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 (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) { + 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, + 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, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + } + + public 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 } + }; + } + + public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List surfaceOverrides, Matrix4x4 transform, 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 = 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); + + foreach (var poly in cellStruct.Polygons.Values) { + ct.ThrowIfCancellationRequested(); + if (poly.VertexIds.Count < 3) continue; + + // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this + // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a + // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, + // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still + // suppress hidden portal/cap faces before they reach our mesh. + bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); + + if (hasPos) + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); + if (hasPos && poly.SidesType == CullMode.None) { + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); + } + else if (hasNeg && poly.SidesType == CullMode.Clockwise) { + AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); + } + + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { + if (surfaceIdx < 0) return; + + uint surfaceId; + if (surfaceIdx < surfaceOverrides.Count) { + surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx]; + } + else { + _logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}"); + return; + } + + if (!_dats.Portal.TryGet(surfaceId, out var surface)) { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})"); + return; + } + + int texWidth, texHeight; + byte[] textureData; + TextureFormat textureFormat; + PixelFormat? uploadPixelFormat = null; + PixelType? uploadPixelType = null; + bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + 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; + + if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) { + textureData = cachedData; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + 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 = PixelFormat.Rgba; + + textureData = new byte[texWidth * texHeight * 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, texWidth, texHeight, compressionFormat)) { + image.CopyPixelDataTo(textureData); + } + } + 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 = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.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 = PixelFormat.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 = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.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 = PixelFormat.Rgba; + break; + default: return; + } + } + + // Add to cache with LRU logic + if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) { + _decodedTextureLru.Enqueue(renderSurfaceId); + if (_decodedTextureCache.Count > MaxDecodedTextures) { + if (_decodedTextureLru.TryDequeue(out var evictedId)) { + _decodedTextureCache.TryRemove(evictedId, out _); + } + } + } + } + + 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 + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + + 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))); + + 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, + IsTransparent = isTransparent, + IsAdditive = isAdditive + }; + batches.Add(batch); + } + + // Helper for CellStruct vertices + bool batchHasWrappingUVs = batch.HasWrappingUVs; + BuildCellStructPolygonIndices( + poly, + cellStruct, + UVLookup, + vertices, + batch.Indices, + useNegUv, + invertNormal, + reverseWinding, + transform, + ref batchHasWrappingUVs); + batch.HasWrappingUVs = batchHasWrappingUVs; + } + } + + 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 } + }; + } + + private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, + Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, + List vertices, List indices, + bool useNegUv, 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]; + ushort uvIdx = 0; + + if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + uvIdx = poly.NegUVIndices[i]; + else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + uvIdx = poly.PosUVIndices[i]; + + if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; + + if (uvIdx >= vertex.UVs.Count) { + uvIdx = 0; + } + + var key = (vertId, uvIdx, invertNormal); + + 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(Vector3.TransformNormal(vertex.Normal, transform)); + if (invertNormal) { + normal = -normal; + } + + idx = (ushort)vertices.Count; + vertices.Add(new VertexPositionNormalTexture( + Vector3.Transform(vertex.Origin, transform), + normal, + uv + )); + UVLookup[key] = idx; + } + polyIndices.Add(idx); + } + + if (reverseWinding) { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); + } + } + else { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); + } + } + } + + 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); + } + + public 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 = PixelFormat.Rgba, + UploadPixelType = PixelType.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 = PixelFormat.Rgba, + UploadPixelType = PixelType.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; + } +}