feat(O-T7): drop WB project references; complete extraction

End of Phase O extraction. Final cleanup:

- Dropped <ProjectReference> entries to WorldBuilder.Shared and
  Chorizite.OpenGLSDLBackend from both AcDream.App.csproj and
  AcDream.Core.csproj.
- Added Chorizite.Core NuGet PackageReference to AcDream.Core.csproj
  (needed by Core.Rendering.Wb.TextureHelpers for TextureFormat enum;
  previously transitive through the WB project ref).
- Added BCnEncoder.Net.ImageSharp (1.1.2) + SixLabors.ImageSharp (3.1.12)
  as direct PackageReferences to AcDream.App.csproj — previously transitive
  via Chorizite.OpenGLSDLBackend project; used directly by ObjectMeshManager.

Item A (BaseObjectRenderManager static fields):
- Inlined CurrentAtlas/CurrentVAO/CurrentIBO into a new RenderStateCache.cs
  static class (AcDream.App.Rendering.Wb namespace) — the 4 consumers
  (ManagedGLIndexBuffer, ManagedGLTexture, ManagedGLTextureArray, ParticleBatcher)
  all reference RenderStateCache.* instead of BaseObjectRenderManager.*.
- Dropped using Chorizite.OpenGLSDLBackend.Lib from all 4 consumers and from
  WbDrawDispatcher (which had it only as a dead import).

Item B (ActiveParticleEmitter.ObjectLandblock):
- ObjectLandblock? erased to object?; WorldBuilder.Shared.Models.ObjectId? erased
  to ulong? — both fields are stored but never read by any consumer in our codebase.
- Dropped both WB using directives from ActiveParticleEmitter.cs.

Item C (IDatReaderWriter / IDatDatabase):
- Verbatim copy of both interfaces into IDatReaderWriter.cs in
  AcDream.App.Rendering.Wb namespace — DatCollectionAdapter and ObjectMeshManager
  already live in that namespace, so no using changes needed.
- Dropped using WorldBuilder.Shared.Services from DatCollectionAdapter.cs and
  ObjectMeshManager.cs.

Additional extractions required by the reference drop:
- GeometryUtils.cs: verbatim copy of WorldBuilder.Shared.Lib.GeometryUtils
  (float-precision overloads only; Vector3d double-precision overloads omitted —
  ObjectMeshManager uses only the float versions).
- Dropped using WorldBuilder.Shared.Lib from ObjectMeshManager.cs.

WbMeshAdapter.cs cleanup (spec O-D12):
- Deleted _wbDats (DefaultDatReaderWriter) field + ctor init + Dispose call.
- Deleted the [indoor-upload] NULL_RESULT diagnostic block (lines ~205-262) —
  its Phase 2 cell-resolution investigation is complete; its _wbDats.ResolveId
  dependency goes with this commit.
- Deleted _pendingEnvCellRequests field + isPendingEnvCell tracking in Tick().
- Simplified Tick() to a clean drain loop.

Deleted SplitFormulaDivergenceTest.cs — one-time N.5b data-collection sweep;
job done.

Verified acceptance criteria:
- Zero <ProjectReference> to WorldBuilder.* / Chorizite.OpenGLSDLBackend.* in any csproj.
- Zero 'using WorldBuilder.*' / 'using Chorizite.OpenGLSDLBackend.*' in src/.
- DefaultDatReaderWriter referenced in zero places in src/ (comments only).

Build green (0 warnings, 0 errors).
Tests: 1154 total (-1 from deleted SplitFormulaDivergenceTest), 1146 pass,
8 pre-existing failures (unchanged from baseline — physics/input tests
unrelated to this change).

Spec: docs/superpowers/specs/2026-05-21-phase-o-dat-path-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-21 17:17:33 +02:00
parent a9ccc5acf5
commit dc722e70bd
15 changed files with 312 additions and 328 deletions

View file

@ -18,6 +18,11 @@
implement IUniformBuffer from Chorizite.Core; NuGet PackageReferences are not
forwarded from ProjectReferences so we must declare it explicitly here. -->
<PackageReference Include="Chorizite.Core" Version="0.0.18" />
<!-- Phase O-T7: BCnEncoder.Net.ImageSharp + SixLabors.ImageSharp were previously
transitive via the WorldBuilder project reference. Now direct deps of
ObjectMeshManager.cs (extracted in T2). -->
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenGL.Extensions.ARB" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
@ -35,12 +40,6 @@
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
<!-- Phase N.4 Task 9: WbMeshAdapter constructs the WB GL pipeline directly.
AcDream.Core already references these projects, but project references are
not transitive in .NET — AcDream.App must list them explicitly to compile
against Chorizite.OpenGLSDLBackend and WorldBuilder.Shared types. -->
<ProjectReference Include="..\..\references\WorldBuilder\WorldBuilder.Shared\WorldBuilder.Shared.csproj" />
<ProjectReference Include="..\..\references\WorldBuilder\Chorizite.OpenGLSDLBackend\Chorizite.OpenGLSDLBackend.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Rendering\Shaders\*.*">

View file

@ -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;

View file

@ -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;

View file

@ -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);
/// <summary>
/// Converts a quaternion to Euler angles (in degrees) using the ZYX convention.
/// </summary>
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);
}
/// <summary>
/// Converts Euler angles (in degrees) to a quaternion using the ZYX convention.
/// </summary>
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
);
}
}

View file

@ -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;
/// <summary>
/// Interface for the dat reader/writer
/// </summary>
public interface IDatReaderWriter : IDisposable {
/// <summary>
/// Gets the source directory of the DAT files.
/// </summary>
string SourceDirectory { get; }
/// <summary>
/// Tries to get the raw bytes of a file from a specific region database.
/// </summary>
bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead);
/// <summary>
/// The portal database
/// </summary>
IDatDatabase Portal { get; }
/// <summary>
/// The cell region databases. Each key is a cell region ID
/// </summary>
ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; }
/// <summary>
/// The high res database
/// </summary>
IDatDatabase HighRes { get; }
/// <summary>
/// The language database
/// </summary>
IDatDatabase Language { get; }
/// <summary>
/// A mapping of region ids to region dat file entry ids. key: region id, value: region dat file entry
/// </summary>
ReadOnlyDictionary<uint, uint> RegionFileMap { get; }
/// <summary>
/// Gets the current portal iteration.
/// </summary>
int PortalIteration { get; }
/// <summary>
/// Gets the current cell iteration (from the first cell region).
/// </summary>
int CellIteration { get; }
/// <summary>
/// Gets the current high res iteration.
/// </summary>
int HighResIteration { get; }
/// <summary>
/// Gets the current language iteration.
/// </summary>
int LanguageIteration { get; }
/// <summary>Attempts to save a database object to the appropriate DAT.</summary>
bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj;
/// <summary>Attempts to save a database object to the appropriate DAT for a specific region.</summary>
bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj;
/// <summary>
/// Resolution of a data ID to a database and type
/// </summary>
public record IdResolution(IDatDatabase Database, DBObjType Type);
/// <summary>
/// Resolves a data ID to all possible databases and types.
/// </summary>
public IEnumerable<IdResolution> ResolveId(uint id);
}
/// <summary>
/// Interface for a dat database, providing methods to retrieve files and objects.
/// </summary>
public interface IDatDatabase : IDisposable {
DatDatabase Db { get; }
/// <summary>Retrieves the current iteration of the database.</summary>
int Iteration { get; }
/// <summary>Retrieves all file IDs of a specific type.</summary>
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj;
/// <summary>Attempts to retrieve a database object by its file ID.</summary>
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj;
/// <summary>Attempts to retrieve the raw bytes of a file by its ID.</summary>
bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value);
/// <summary>Attempts to retrieve the raw bytes of a file by its ID into a provided buffer.</summary>
bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead);
/// <summary>Attempts to save a database object.</summary>
bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj;
}

View file

@ -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 {
/// <inheritdoc />
public void Bind() {
BaseObjectRenderManager.CurrentIBO = 0;
RenderStateCache.CurrentIBO = 0;
GL.BindBuffer(GLEnum.ElementArrayBuffer, bufferId);
GLHelpers.CheckErrors(GL);
}

View file

@ -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;

View file

@ -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);

View file

@ -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 {
/// <summary>

View file

@ -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() {

View file

@ -0,0 +1,20 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Tracks currently-bound GL state to skip redundant rebinds across the
/// WB-derived render path. Previously these were static fields on
/// <c>BaseObjectRenderManager</c> in the WorldBuilder.Shared project; inlined
/// here in Phase O-T7 to eliminate the WorldBuilder project reference.
///
/// Semantics are identical to the WB originals:
/// <c>CurrentAtlas</c> — slot index of the currently bound texture atlas.
/// <c>CurrentVAO</c> — OpenGL name of the currently bound vertex array object.
/// <c>CurrentIBO</c> — OpenGL name of the currently bound index buffer object.
/// Sentinel value 0 means "no valid binding cached."
/// </summary>
public static class RenderStateCache
{
public static uint CurrentAtlas = 0;
public static uint CurrentVAO = 0;
public static uint CurrentIBO = 0;
}

View file

@ -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;

View file

@ -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.
///
/// <para>
/// The adapter constructs its own <c>DefaultDatReaderWriter</c> internally; it
/// does NOT share file handles with our <c>DatCollection</c>. This duplicates
/// index-cache memory (~50100 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 <see cref="DatCollectionAdapter"/>
/// (backed by our shared <see cref="DatCollection"/>) — the separate
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para>
/// </summary>
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<ulong> _metadataPopulated = new();
/// <summary>
/// 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).
/// </summary>
private readonly HashSet<ulong> _pendingEnvCellRequests = new();
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
/// all public methods no-op when uninitialized.
@ -53,13 +38,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private bool _disposed;
/// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter
/// → ObjectMeshManager.
/// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="datDir">Path to the dat directory (same as the one supplied
/// to our DatCollection). DefaultDatReaderWriter opens its own file handles.</param>
/// <param name="datDir">Path to the dat directory. Retained for API compatibility;
/// DatCollectionAdapter routes all DAT I/O through our shared DatCollection.</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
@ -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<EnvCell>
// 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<DatReaderWriter.DBObjs.EnvCell>(
(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<DatReaderWriter.DBObjs.EnvCell>((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<EnvCell>={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();
}
}

View file

@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCnEncoder.Net" Version="2.2.1" />
<PackageReference Include="Chorizite.Core" Version="0.0.18" />
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<PackageReference Include="Serilog" Version="4.0.2" />
</ItemGroup>
@ -18,13 +19,5 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
<!-- Phase N: WorldBuilder is acdream's rendering + dat-handling base.
See docs/architecture/worldbuilder-inventory.md for the inventory of
what we use from WB vs port from retail decomp ourselves.
WorldBuilder.Shared = stateless helpers (TerrainUtils, TerrainEntry, RegionInfo).
Chorizite.OpenGLSDLBackend = render managers + SceneryHelpers + ParticleEmitterRenderer
(full GL pipeline; we currently use only the stateless SceneryHelpers from it). -->
<ProjectReference Include="..\..\references\WorldBuilder\WorldBuilder.Shared\WorldBuilder.Shared.csproj" />
<ProjectReference Include="..\..\references\WorldBuilder\Chorizite.OpenGLSDLBackend\Chorizite.OpenGLSDLBackend.csproj" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Phase N.5b data-collection test: quantifies how often WB's
/// <c>TerrainUtils.CalculateSplitDirection</c> disagrees with acdream's
/// <c>TerrainBlending.CalculateSplitDirection</c> (which retail uses
/// per <c>CLandBlockStruct::ConstructPolygons</c> at retail address
/// <c>00531d10</c>; named-retail decomp lines 316042-316144 contain
/// the exact constants <c>0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD /
/// 0x519B8F25</c>).
///
/// 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 &lt;5% : Path A's risk is bounded
/// - Medium 5-20% : Path B (fork-patch WB) preferred
/// - High &gt;20% : Path B/C strongly preferred
/// </summary>
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}%)");
}
}