diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 1f35dbb..d50c6b4 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -18,6 +18,11 @@ implement IUniformBuffer from Chorizite.Core; NuGet PackageReferences are not forwarded from ProjectReferences so we must declare it explicitly here. --> + + + @@ -35,12 +40,6 @@ - - - diff --git a/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs b/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs index 3314589..4c564a9 100644 --- a/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs +++ b/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs @@ -1,6 +1,4 @@ using System.Numerics; -using Chorizite.OpenGLSDLBackend.Lib; -using WorldBuilder.Shared.Models; namespace AcDream.App.Rendering.Wb { public class ActiveParticleEmitter { @@ -8,11 +6,15 @@ namespace AcDream.App.Rendering.Wb { public uint PartIndex { get; } public Matrix4x4 LocalOffset { get; } - // Store reference info instead of struct copy - public ObjectLandblock? ParentLandblock { get; set; } - public ObjectId? ParentInstanceId { get; set; } + // Store reference info instead of struct copy. + // ParentLandblock was ObjectLandblock (WB type) — typed as object? in Phase O-T7 + // to remove the WorldBuilder project reference; no consumer reads it. + public object? ParentLandblock { get; set; } + // ParentInstanceId was WorldBuilder.Shared.Models.ObjectId? — erased to ulong? in T7; + // the field is stored but never read by any consumer in our codebase. + public ulong? ParentInstanceId { get; set; } - public ActiveParticleEmitter(ParticleEmitterRenderer renderer, uint partIndex, Matrix4x4 localOffset, ObjectLandblock? parentLandblock = null, ObjectId? parentInstanceId = null) { + public ActiveParticleEmitter(ParticleEmitterRenderer renderer, uint partIndex, Matrix4x4 localOffset, object? parentLandblock = null, ulong? parentInstanceId = null) { Renderer = renderer; PartIndex = partIndex; LocalOffset = localOffset; diff --git a/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs index 3b661e2..9f49a15 100644 --- a/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs @@ -6,7 +6,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -using WorldBuilder.Shared.Services; namespace AcDream.App.Rendering.Wb; diff --git a/src/AcDream.App/Rendering/Wb/GeometryUtils.cs b/src/AcDream.App/Rendering/Wb/GeometryUtils.cs new file mode 100644 index 0000000..5b5ea09 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/GeometryUtils.cs @@ -0,0 +1,147 @@ +using System.Numerics; + +// Phase O-T7: verbatim copy of WorldBuilder.Shared.Lib.GeometryUtils into +// the AcDream.App.Rendering.Wb namespace so the WorldBuilder.Shared project +// reference can be dropped. Only the float-precision overloads are used by +// ObjectMeshManager (RayIntersectsSphere + RayIntersectsTriangle). The +// double-precision overloads are kept verbatim for completeness. + +namespace AcDream.App.Rendering.Wb; + +public static class GeometryUtils { + + public static bool RayIntersectsBox(Vector3 rayOrigin, Vector3 rayDirection, Vector3 min, Vector3 max, out float distance) { + distance = 0; + float tmin = 0.0f; + float tmax = float.MaxValue; + + if (Math.Abs(rayDirection.X) < 1e-7f) { + if (rayOrigin.X < min.X || rayOrigin.X > max.X) return false; + } + else { + float invD = 1.0f / rayDirection.X; + float t0 = (min.X - rayOrigin.X) * invD; + float t1 = (max.X - rayOrigin.X) * invD; + if (t0 > t1) (t0, t1) = (t1, t0); + tmin = Math.Max(tmin, t0); + tmax = Math.Min(tmax, t1); + if (tmin > tmax) return false; + } + + if (Math.Abs(rayDirection.Y) < 1e-7f) { + if (rayOrigin.Y < min.Y || rayOrigin.Y > max.Y) return false; + } + else { + float invD = 1.0f / rayDirection.Y; + float t0 = (min.Y - rayOrigin.Y) * invD; + float t1 = (max.Y - rayOrigin.Y) * invD; + if (t0 > t1) (t0, t1) = (t1, t0); + tmin = Math.Max(tmin, t0); + tmax = Math.Min(tmax, t1); + if (tmin > tmax) return false; + } + + if (Math.Abs(rayDirection.Z) < 1e-7f) { + if (rayOrigin.Z < min.Z || rayOrigin.Z > max.Z) return false; + } + else { + float invD = 1.0f / rayDirection.Z; + float t0 = (min.Z - rayOrigin.Z) * invD; + float t1 = (max.Z - rayOrigin.Z) * invD; + if (t0 > t1) (t0, t1) = (t1, t0); + tmin = Math.Max(tmin, t0); + tmax = Math.Min(tmax, t1); + if (tmin > tmax) return false; + } + + distance = tmin; + return true; + } + + public static bool RayIntersectsTriangle(Vector3 origin, Vector3 direction, Vector3 v0, Vector3 v1, Vector3 v2, out float t) { + t = 0; + Vector3 edge1 = v1 - v0; + Vector3 edge2 = v2 - v0; + Vector3 h = Vector3.Cross(direction, edge2); + float a = Vector3.Dot(edge1, h); + + if (a > -0.00001f && a < 0.00001f) return false; + + float f = 1.0f / a; + Vector3 s = origin - v0; + float u = f * Vector3.Dot(s, h); + + if (u < 0.0f || u > 1.0f) return false; + + Vector3 q = Vector3.Cross(s, edge1); + float v = f * Vector3.Dot(direction, q); + + if (v < 0.0f || u + v > 1.0f) return false; + + t = f * Vector3.Dot(edge2, q); + return t > 0.00001f; + } + + public static bool RayIntersectsSphere(Vector3 rayOrigin, Vector3 rayDirection, Vector3 sphereOrigin, float sphereRadius, out float distance) { + distance = 0; + Vector3 l = sphereOrigin - rayOrigin; + float tca = Vector3.Dot(l, rayDirection); + if (tca < 0) return false; + float d2 = Vector3.Dot(l, l) - tca * tca; + float r2 = sphereRadius * sphereRadius; + if (d2 > r2) return false; + float thc = MathF.Sqrt(r2 - d2); + distance = tca - thc; + return true; + } + + public static ushort PackKey(int x, int y) => (ushort)((x << 8) | y); + + /// + /// Converts a quaternion to Euler angles (in degrees) using the ZYX convention. + /// + public static Vector3 QuaternionToEuler(Quaternion q) { + float x = q.X, y = q.Y, z = q.Z, w = q.W; + float roll, pitch, yaw; + + float sinr_cosp = 2 * (w * x + y * z); + float cosr_cosp = 1 - 2 * (x * x + y * y); + roll = (float)Math.Atan2(sinr_cosp, cosr_cosp); + + float sinp = 2 * (w * y - z * x); + if (Math.Abs(sinp) >= 1) + pitch = (float)Math.CopySign(Math.PI / 2, sinp); + else + pitch = (float)Math.Asin(sinp); + + float siny_cosp = 2 * (w * z + x * y); + float cosy_cosp = 1 - 2 * (y * y + z * z); + yaw = (float)Math.Atan2(siny_cosp, cosy_cosp); + + return new Vector3(roll, pitch, yaw) * (180.0f / (float)Math.PI); + } + + /// + /// Converts Euler angles (in degrees) to a quaternion using the ZYX convention. + /// + public static Quaternion EulerToQuaternion(Vector3 euler) { + Vector3 rads = euler * (MathF.PI / 180.0f); + float roll = rads.X; + float pitch = rads.Y; + float yaw = rads.Z; + + float cr = MathF.Cos(roll * 0.5f); + float sr = MathF.Sin(roll * 0.5f); + float cp = MathF.Cos(pitch * 0.5f); + float sp = MathF.Sin(pitch * 0.5f); + float cy = MathF.Cos(yaw * 0.5f); + float sy = MathF.Sin(yaw * 0.5f); + + return new Quaternion( + sr * cp * cy - cr * sp * sy, + cr * sp * cy + sr * cp * sy, + cr * cp * sy - sr * sp * cy, + cr * cp * cy + sr * sp * sy + ); + } +} diff --git a/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs b/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs new file mode 100644 index 0000000..a287bf7 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs @@ -0,0 +1,114 @@ +using DatReaderWriter; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +// Phase O-T7: verbatim copy of WorldBuilder.Shared.Services.IDatReaderWriter + +// IDatDatabase into the AcDream.App.Rendering.Wb namespace so the +// WorldBuilder.Shared project reference can be dropped. +// The only consumer of IDatReaderWriter in acdream is DatCollectionAdapter + +// ObjectMeshManager, both already in this namespace. + +namespace AcDream.App.Rendering.Wb; + +/// +/// Interface for the dat reader/writer +/// +public interface IDatReaderWriter : IDisposable { + /// + /// Gets the source directory of the DAT files. + /// + string SourceDirectory { get; } + + /// + /// Tries to get the raw bytes of a file from a specific region database. + /// + bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead); + + /// + /// The portal database + /// + IDatDatabase Portal { get; } + + /// + /// The cell region databases. Each key is a cell region ID + /// + ReadOnlyDictionary CellRegions { get; } + + /// + /// The high res database + /// + IDatDatabase HighRes { get; } + + /// + /// The language database + /// + IDatDatabase Language { get; } + + /// + /// A mapping of region ids to region dat file entry ids. key: region id, value: region dat file entry + /// + ReadOnlyDictionary RegionFileMap { get; } + + /// + /// Gets the current portal iteration. + /// + int PortalIteration { get; } + + /// + /// Gets the current cell iteration (from the first cell region). + /// + int CellIteration { get; } + + /// + /// Gets the current high res iteration. + /// + int HighResIteration { get; } + + /// + /// Gets the current language iteration. + /// + int LanguageIteration { get; } + + /// Attempts to save a database object to the appropriate DAT. + bool TrySave(T obj, int iteration = 0) where T : IDBObj; + + /// Attempts to save a database object to the appropriate DAT for a specific region. + bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj; + + /// + /// Resolution of a data ID to a database and type + /// + public record IdResolution(IDatDatabase Database, DBObjType Type); + + /// + /// Resolves a data ID to all possible databases and types. + /// + public IEnumerable ResolveId(uint id); +} + +/// +/// Interface for a dat database, providing methods to retrieve files and objects. +/// +public interface IDatDatabase : IDisposable { + DatDatabase Db { get; } + + /// Retrieves the current iteration of the database. + int Iteration { get; } + + /// Retrieves all file IDs of a specific type. + public IEnumerable GetAllIdsOfType() where T : IDBObj; + + /// Attempts to retrieve a database object by its file ID. + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj; + + /// Attempts to retrieve the raw bytes of a file by its ID. + bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value); + + /// Attempts to retrieve the raw bytes of a file by its ID into a provided buffer. + bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead); + + /// Attempts to save a database object. + bool TrySave(T obj, int iteration = 0) where T : IDBObj; +} diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLIndexBuffer.cs b/src/AcDream.App/Rendering/Wb/ManagedGLIndexBuffer.cs index 4889a0f..7443fc5 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLIndexBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLIndexBuffer.cs @@ -1,6 +1,5 @@ using Chorizite.Core.Render.Enums; using Chorizite.Core.Render.Vertex; -using Chorizite.OpenGLSDLBackend.Lib; using Silk.NET.OpenGL; using BufferUsage = Chorizite.Core.Render.Enums.BufferUsage; @@ -158,7 +157,7 @@ namespace AcDream.App.Rendering.Wb { /// public void Bind() { - BaseObjectRenderManager.CurrentIBO = 0; + RenderStateCache.CurrentIBO = 0; GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId); GLHelpers.CheckErrors(GL); } diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs b/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs index 27d83cd..827dc8a 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLTexture.cs @@ -1,6 +1,5 @@ using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; -using Chorizite.OpenGLSDLBackend.Lib; using Silk.NET.OpenGL; namespace AcDream.App.Rendering.Wb { @@ -108,7 +107,7 @@ namespace AcDream.App.Rendering.Wb { GLHelpers.CheckErrors(GL); GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture); - BaseObjectRenderManager.CurrentAtlas = 0; + RenderStateCache.CurrentAtlas = 0; GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding); GL.BindTexture(GLEnum.Texture2D, _texture); @@ -147,7 +146,7 @@ namespace AcDream.App.Rendering.Wb { public void Bind(int slot = 0) { if (slot == 0) { - BaseObjectRenderManager.CurrentAtlas = 0; + RenderStateCache.CurrentAtlas = 0; } GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture); GLEnum targetTextureUnit = GLEnum.Texture0 + slot; diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs index 11e20d9..eb69e11 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs @@ -1,7 +1,6 @@ using AcDream.Core.Rendering.Wb; using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; -using Chorizite.OpenGLSDLBackend.Lib; // Use our extracted TextureHelpers (T3), not the WB original — disambiguate explicitly using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers; using Microsoft.Extensions.Logging; @@ -274,7 +273,7 @@ namespace AcDream.App.Rendering.Wb { GLHelpers.CheckErrors(GL); GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture); - BaseObjectRenderManager.CurrentAtlas = 0; + RenderStateCache.CurrentAtlas = 0; GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding); GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index d2e1f4c..fa6f225 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -15,7 +15,6 @@ using System.Numerics; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using WorldBuilder.Shared.Services; using AcDream.Core.Rendering.Wb; using PixelFormat = Silk.NET.OpenGL.PixelFormat; using BoundingBox = Chorizite.Core.Lib.BoundingBox; @@ -24,7 +23,6 @@ using BCnEncoder.Shared; using BCnEncoder.ImageSharp; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using WorldBuilder.Shared.Lib; namespace AcDream.App.Rendering.Wb { /// diff --git a/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs b/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs index fcfe139..0841ec6 100644 --- a/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs +++ b/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using Chorizite.Core.Render; -using Chorizite.OpenGLSDLBackend.Lib; using Silk.NET.OpenGL; namespace AcDream.App.Rendering.Wb { @@ -187,9 +186,7 @@ namespace AcDream.App.Rendering.Wb { gl.ActiveTexture(TextureUnit.Texture0); gl.BindTexture(GLEnum.Texture2DArray, (uint)_currentAtlas.NativePtr); - // T4 interim: BaseObjectRenderManager state fields stay on the WB type until T7 - // when the WorldBuilder project reference is dropped entirely. - BaseObjectRenderManager.CurrentAtlas = (uint)_currentAtlas.Slot; + RenderStateCache.CurrentAtlas = (uint)_currentAtlas.Slot; gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); unsafe { @@ -210,9 +207,8 @@ namespace AcDream.App.Rendering.Wb { gl.DepthMask(true); _allParticles.Clear(); - // T4 interim: BaseObjectRenderManager state fields stay on the WB type until T7 - BaseObjectRenderManager.CurrentVAO = 0; - BaseObjectRenderManager.CurrentIBO = 0; + RenderStateCache.CurrentVAO = 0; + RenderStateCache.CurrentIBO = 0; } public void End() { diff --git a/src/AcDream.App/Rendering/Wb/RenderStateCache.cs b/src/AcDream.App/Rendering/Wb/RenderStateCache.cs new file mode 100644 index 0000000..840a0bc --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/RenderStateCache.cs @@ -0,0 +1,20 @@ +namespace AcDream.App.Rendering.Wb; + +/// +/// Tracks currently-bound GL state to skip redundant rebinds across the +/// WB-derived render path. Previously these were static fields on +/// BaseObjectRenderManager in the WorldBuilder.Shared project; inlined +/// here in Phase O-T7 to eliminate the WorldBuilder project reference. +/// +/// Semantics are identical to the WB originals: +/// CurrentAtlas — slot index of the currently bound texture atlas. +/// CurrentVAO — OpenGL name of the currently bound vertex array object. +/// CurrentIBO — OpenGL name of the currently bound index buffer object. +/// Sentinel value 0 means "no valid binding cached." +/// +public static class RenderStateCache +{ + public static uint CurrentAtlas = 0; + public static uint CurrentVAO = 0; + public static uint CurrentIBO = 0; +} diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 6dbf0c8..f62790d 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -6,7 +6,6 @@ using AcDream.Core.Meshing; using AcDream.Core.Rendering; using AcDream.Core.Terrain; using AcDream.Core.World; -using Chorizite.OpenGLSDLBackend.Lib; using Silk.NET.OpenGL; namespace AcDream.App.Rendering.Wb; diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index ed8b8ef..f07aca1 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,16 +1,12 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using AcDream.Core.Meshing; using AcDream.Core.Rendering; -using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter; using DatReaderWriter.DBObjs; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Silk.NET.OpenGL; -using WorldBuilder.Shared.Services; namespace AcDream.App.Rendering.Wb; @@ -20,30 +16,19 @@ namespace AcDream.App.Rendering.Wb; /// so the rest of the renderer doesn't need to know about WB's types directly. /// /// -/// The adapter constructs its own DefaultDatReaderWriter internally; it -/// does NOT share file handles with our DatCollection. This duplicates -/// index-cache memory (~50–100 MB) but keeps the two subsystems fully decoupled. -/// Acceptable for Phase N.4 foundation work (plan Adjustment 1). +/// As of Phase O-T7, all DAT I/O routes through +/// (backed by our shared ) — the separate +/// DefaultDatReaderWriter file-handle set has been removed. /// /// public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { private readonly OpenGLGraphicsDevice? _graphicsDevice; - private readonly DefaultDatReaderWriter? _wbDats; private readonly ObjectMeshManager? _meshManager; private readonly DatCollection? _dats; private readonly AcSurfaceMetadataTable _metadataTable = new(); private readonly HashSet _metadataPopulated = new(); - /// - /// EnvCell ids we've requested via PrepareMeshDataAsync but not yet - /// seen completion for in Tick(). Used by the [indoor-upload] probe - /// to log requested + completed pairs. Cleared per completion; - /// missing completions after a few seconds indicate WB silently - /// returned null (hypothesis H1 in the design spec). - /// - private readonly HashSet _pendingEnvCellRequests = new(); - /// /// True when this instance was created via ; /// all public methods no-op when uninitialized. @@ -53,13 +38,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter private bool _disposed; /// - /// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter + /// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter /// → ObjectMeshManager. /// /// Active Silk.NET GL context. Must be bound to the current /// thread (construction runs GL queries; call from OnLoad). - /// Path to the dat directory (same as the one supplied - /// to our DatCollection). DefaultDatReaderWriter opens its own file handles. + /// Path to the dat directory. Retained for API compatibility; + /// DatCollectionAdapter routes all DAT I/O through our shared DatCollection. /// acdream's DatCollection, used to populate the surface /// metadata side-table via GfxObjMesh.Build. Shares file handles with /// the rest of the client; read-only access from the render thread. @@ -75,12 +60,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _dats = dats; _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); _graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice); - _wbDats = new DefaultDatReaderWriter(datDir); - // Phase 2 diagnostic — replace NullLogger with a Console-backed - // logger so WB's internal catch block at ObjectMeshManager.cs:589 - // (and similar) surfaces its swallowed exceptions instead of - // dropping them. ConsoleErrorLogger filters to LogLevel.Error+ - // so successful operations stay quiet. + // ConsoleErrorLogger surfaces WB's silently-caught exceptions + // (ObjectMeshManager.PrepareMeshData try/catch at line ~589). _meshManager = new ObjectMeshManager( _graphicsDevice, new DatCollectionAdapter(dats), @@ -186,80 +167,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // isSetup: false — acdream's MeshRefs already carry expanded // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // unused. - var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false); - - // [indoor-upload] requested probe — only for EnvCell ids. - if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled) - { - bool hadRenderDataAtRequest = _meshManager.HasRenderData(id); - _pendingEnvCellRequests.Add(id); - Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8} hadRenderData={hadRenderDataAtRequest}"); - - // Phase 2 — surface what WB's catch block silently swallows. - // ObjectMeshManager.PrepareMeshData has a try/catch at line 589 - // that calls _logger.LogError(ex, ...) — but we construct - // ObjectMeshManager with NullLogger.Instance so the log is - // dropped. This continuation captures the same data scoped to - // EnvCell ids only. Runs on ThreadPool; non-blocking. Zero cost - // when the probe is off. - ulong cellId = id; - _ = prepTask.ContinueWith(t => - { - if (t.IsFaulted && t.Exception is not null) - { - var ex = t.Exception.InnerException ?? t.Exception; - var stack = (ex.StackTrace ?? "").Split('\n') - .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0); - Console.WriteLine( - $"[indoor-upload] FAILED cellId=0x{cellId:X8} " + - $"exception={ex.GetType().Name}: {ex.Message} " + - $"stack=[{string.Join(" | ", stack)}]"); - } - else if (t.IsCompletedSuccessfully && t.Result is null) - { - // Phase 2 cause-narrowing: WB's PrepareMeshData can return - // null for several reasons (ResolveId empty / TryGet - // failed / type Unknown). Cross-check against acdream's own - // DatCollection — if WE find the cell but WB doesn't, the - // divergence is between dat readers, not a missing record. - bool ourCellFound = false; - try - { - ourCellFound = _dats?.Cell.TryGet( - (uint)cellId, out _) ?? false; - } - catch { /* swallow — this is best-effort diagnostic */ } - - int wbResolveCount = -1; - string wbSelectedType = "none"; - bool wbDbTryGetEnvCell = false; - bool wbDbIsPortal = false; - try - { - var wbResolutions = _wbDats?.ResolveId((uint)cellId).ToList(); - wbResolveCount = wbResolutions?.Count ?? -1; - if (wbResolutions is not null && wbResolutions.Count > 0) - { - var selected = wbResolutions - .OrderByDescending(r => r.Database == _wbDats!.Portal) - .First(); - wbSelectedType = selected.Type.ToString(); - wbDbIsPortal = selected.Database == _wbDats!.Portal; - try { wbDbTryGetEnvCell = selected.Database.TryGet((uint)cellId, out _); } catch {} - } - } - catch { /* swallow — best-effort */ } - - Console.WriteLine( - $"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " + - $"ourCellDb.TryGet={ourCellFound} " + - $"wbResolveId.Count={wbResolveCount} " + - $"wbSelectedType={wbSelectedType} " + - $"wbDbIsPortal={wbDbIsPortal} " + - $"wbDbTryGet={wbDbTryGetEnvCell}"); - } - }, TaskScheduler.Default); - } + _meshManager.PrepareMeshDataAsync(id, isSetup: false); } } @@ -298,26 +206,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _graphicsDevice!.ProcessGLQueue(); while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) { - // [indoor-upload] completed probe — check BEFORE upload so we - // see what WB actually produced (vertex counts, parts) before - // any post-upload mutation. - bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled - && _pendingEnvCellRequests.Remove(meshData.ObjectId); - - var renderData = _meshManager.UploadMeshData(meshData); - - if (isPendingEnvCell) - { - int parts = meshData.SetupParts?.Count ?? 0; - bool hasGeom = meshData.EnvCellGeometry is not null; - int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0; - bool uploadOk = renderData is not null; - Console.WriteLine( - $"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " + - $"isSetup={meshData.IsSetup} parts={parts} " + - $"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " + - $"uploadOk={uploadOk}"); - } + _meshManager.UploadMeshData(meshData); } } @@ -342,7 +231,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter if (_disposed) return; _disposed = true; _meshManager?.Dispose(); - _wbDats?.Dispose(); _graphicsDevice?.Dispose(); } } diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 1ac800c..eafd339 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -8,6 +8,7 @@ + @@ -18,13 +19,5 @@ - - - diff --git a/tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs b/tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs deleted file mode 100644 index feaa28f..0000000 --- a/tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs +++ /dev/null @@ -1,168 +0,0 @@ -using AcDream.Core.Terrain; -using Xunit; -using Xunit.Abstractions; -using WbTerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils; -using WbCellSplitDirection = WorldBuilder.Shared.Modules.Landscape.Models.CellSplitDirection; - -namespace AcDream.Core.Tests.Terrain; - -/// -/// Phase N.5b data-collection test: quantifies how often WB's -/// TerrainUtils.CalculateSplitDirection disagrees with acdream's -/// TerrainBlending.CalculateSplitDirection (which retail uses -/// per CLandBlockStruct::ConstructPolygons at retail address -/// 00531d10; named-retail decomp lines 316042-316144 contain -/// the exact constants 0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD / -/// 0x519B8F25). -/// -/// Sweeps every (lbX, lbY, cellX, cellY) tuple in the world map -/// (255 x 255 landblocks x 64 cells = ~4.16M cells) and reports the -/// disagreement rate, per-landblock worst case, and a few named -/// representative landblocks. The number drives the Path A/B/C -/// decision in the N.5b brainstorm: -/// - Low disagreement <5% : Path A's risk is bounded -/// - Medium 5-20% : Path B (fork-patch WB) preferred -/// - High >20% : Path B/C strongly preferred -/// -public class SplitFormulaDivergenceTest -{ - private readonly ITestOutputHelper _out; - - public SplitFormulaDivergenceTest(ITestOutputHelper output) => _out = output; - - [Fact] - public void Quantify_RetailVsWb_DivergenceRate() - { - // Two divergence flavors are tracked simultaneously: - // - // rawDisagree : retail-enum != wb-enum (pure formula output) - // diagonalDisagree: retail-actually-paints-diagonal != - // wb-actually-paints-diagonal (effective geometry) - // - // The two differ because the enums are SEMANTICALLY INVERTED: - // - acdream `CellSplitDirection.SWtoNE` -> renderer paints BL->TR - // (SW-NE diagonal). Matches retail per AC2D Landblocks.cpp:400-412 - // where FSplitNESW=true wraps a TRIANGLE_FAN [BL, BR, TR, TL] = - // diagonal BL-TR. - // - WB `CellSplitDirection.SWtoNE` -> WB's TerrainGeometryGenerator - // emits triangles {BL,TL,BR}+{BR,TL,TR} which share the BR-TL - // diagonal (SE-NW direction). The enum name is misleading; what - // WB actually draws is the OTHER diagonal. - // - // So the question "would WB's pipeline produce the same diagonals as - // retail's pipeline?" is answered by `diagonalDisagree`, not - // `rawDisagree`. If diagonalDisagree is near 0%, WB's formula + - // renderer happen to compose into a correct pipeline (despite the - // confusing labels). If diagonalDisagree is ~50%, the two pipelines - // truly diverge and Path A would visibly break terrain on every - // landblock. - - const int lbCount = 255; - const int cellsPerSide = 8; - long totalCells = 0; - long rawDisagree = 0; - long diagonalDisagree = 0; - - int worstLbDiag = 0; - uint worstLbX = 0, worstLbY = 0; - int bestLbDiag = 64; - uint bestLbX = 0, bestLbY = 0; - - for (uint lbX = 0; lbX < lbCount; lbX++) - for (uint lbY = 0; lbY < lbCount; lbY++) - { - int lbDiagDisagree = 0; - for (uint cx = 0; cx < cellsPerSide; cx++) - for (uint cy = 0; cy < cellsPerSide; cy++) - { - bool retailEnumSWtoNE = - TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) - == CellSplitDirection.SWtoNE; - bool wbEnumSWtoNE = - WbTerrainUtils.CalculateSplitDirection(lbX, cx, lbY, cy) - == WbCellSplitDirection.SWtoNE; - - // What diagonal each pipeline actually paints. - bool retailPaintsBLtoTR = retailEnumSWtoNE; // direct mapping - bool wbPaintsBLtoTR = !wbEnumSWtoNE; // inverted mapping - - totalCells++; - if (retailEnumSWtoNE != wbEnumSWtoNE) rawDisagree++; - if (retailPaintsBLtoTR != wbPaintsBLtoTR) - { - diagonalDisagree++; - lbDiagDisagree++; - } - } - - if (lbDiagDisagree > worstLbDiag) - { - worstLbDiag = lbDiagDisagree; - worstLbX = lbX; - worstLbY = lbY; - } - if (lbDiagDisagree < bestLbDiag) - { - bestLbDiag = lbDiagDisagree; - bestLbX = lbX; - bestLbY = lbY; - } - } - - double rawPct = 100.0 * rawDisagree / totalCells; - double diagPct = 100.0 * diagonalDisagree / totalCells; - - _out.WriteLine($"=== Phase N.5b — terrain split formula divergence ==="); - _out.WriteLine($"Sweep: {lbCount}x{lbCount} landblocks, {cellsPerSide*cellsPerSide} cells each"); - _out.WriteLine($"Total cells: {totalCells:N0}"); - _out.WriteLine(""); - _out.WriteLine($"RAW enum-output disagreement : {rawDisagree,12:N0} ({rawPct:F2}%)"); - _out.WriteLine($" (compares retail-enum vs wb-enum, NOT what each system actually draws)"); - _out.WriteLine(""); - _out.WriteLine($"DIAGONAL-actually-painted disagreement: {diagonalDisagree,12:N0} ({diagPct:F2}%)"); - _out.WriteLine($" (compares retail-paints-BL->TR vs wb-paints-BL->TR; this is the"); - _out.WriteLine($" number that determines whether Path A visibly works)"); - _out.WriteLine(""); - _out.WriteLine($"Worst landblock (diagonal): 0x{worstLbX:X2}{worstLbY:X2} disagrees on {worstLbDiag}/64 cells ({100.0*worstLbDiag/64:F1}%)"); - _out.WriteLine($"Best landblock (diagonal): 0x{bestLbX:X2}{bestLbY:X2} disagrees on {bestLbDiag}/64 cells ({100.0*bestLbDiag/64:F1}%)"); - - // Specific landblocks of interest (per N.5b handoff representative set). - var representative = new (string name, uint lbX, uint lbY)[] - { - ("Holtburg town", 0xA9, 0xB0), - ("Holtburg LB 0xA9B1", 0xA9, 0xB1), - ("Foundry-area", 0x80, 0x80), - ("Cragstone", 0xCB, 0x99), - ("Direlands sample", 0xC0, 0x40), - ("MapOrigin 0x0000", 0x00, 0x00), - ("MapCorner 0xFEFE", 0xFE, 0xFE), - ("Mid-map 0x7F7F", 0x7F, 0x7F), - ("Subway dungeon LB 0x0185 outdoor part", 0x01, 0x85), - }; - - _out.WriteLine(""); - _out.WriteLine("Representative landblocks (diagonal-actually-painted disagreement):"); - foreach (var (name, lbX, lbY) in representative) - { - int dis = 0; - for (uint cx = 0; cx < 8; cx++) - for (uint cy = 0; cy < 8; cy++) - { - bool retailEnum = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) == CellSplitDirection.SWtoNE; - bool wbEnum = WbTerrainUtils.CalculateSplitDirection(lbX, cx, lbY, cy) == WbCellSplitDirection.SWtoNE; - bool retailPaintsBLtoTR = retailEnum; - bool wbPaintsBLtoTR = !wbEnum; - if (retailPaintsBLtoTR != wbPaintsBLtoTR) dis++; - } - _out.WriteLine($" 0x{lbX:X2}{lbY:X2} {dis,2}/64 cells disagree ({100.0*dis/64:F1}%) {name}"); - } - - // Soft-floor on the DIAGONAL comparison: if diagPct is near 0% the - // formulas are equivalent post-inversion (Path A would just work - // visually; the only "bug" is enum naming). If diagPct is well - // above 0%, Path A truly breaks terrain. - // Soft-ceiling: an inversion of inversion shouldn't push past ~70%. - Assert.True(diagPct >= 0 && diagPct <= 100, - $"Sanity: diagonal disagreement out of range (rate={diagPct:F2}%)"); - } -}