From cc3afbcbebb7e795bacdcbe5336317cd372a6c5a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 23 May 2026 20:24:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(phys):=20A6.P3=20#98=20=E2=80=94=20GfxObj?= =?UTF-8?q?=20dump=20infrastructure=20(ACDREAM=5FDUMP=5FGFXOBJS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the existing ACDREAM_DUMP_CELLS pattern for GfxObj-owned geometry: when ACDREAM_DUMP_GFXOBJS lists a hex GfxObj id, the first PhysicsDataCache.CacheGfxObj for that id writes the full resolved polygon table to a JSON fixture under tests/AcDream.Core.Tests/Fixtures/issue98/0x{id:X8}.gfxobj.json (override dir via ACDREAM_DUMP_GFXOBJS_DIR). Motivation: the existing [resolve-bldg] probe captures GfxObj-level metadata (id, BSP root radius, entity origin) but emits "hitPoly: n/a (BSP path — side-channel not written)" because the BSPQuery wire site that would populate LastBspHitPoly never landed. A polygon-level dump at cache time bypasses that gap — one capture run yields the FULL polygon table, fixture-loadable by the harness's RegisterCottageGfxObj helper (next commit). See docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md for the cottage GfxObj 0x01000A2B context: landblock-baked static at entity origin (130.5, 11.5, 94.0), responsible for the head-sphere cap from below at world Z=94.0 that issue #98 is documenting. Test baseline: 1183 + 8 pre-existing failures (serial run; +5 new tests all pass; was 1178 + 8 pre-session). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/GfxObjDump.cs | 198 +++++++++++++++++ src/AcDream.Core/Physics/PhysicsDataCache.cs | 23 +- .../Physics/PhysicsDiagnostics.cs | 39 ++++ .../Physics/GfxObjDumpRoundTripTests.cs | 201 ++++++++++++++++++ .../Physics/PhysicsDiagnosticsTests.cs | 24 +++ 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.Core/Physics/GfxObjDump.cs create mode 100644 tests/AcDream.Core.Tests/Physics/GfxObjDumpRoundTripTests.cs diff --git a/src/AcDream.Core/Physics/GfxObjDump.cs b/src/AcDream.Core/Physics/GfxObjDump.cs new file mode 100644 index 0000000..422eb18 --- /dev/null +++ b/src/AcDream.Core/Physics/GfxObjDump.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Serialization; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// A6.P3 issue #98 (2026-05-23 evening v2) — JSON-serializable snapshot of +/// a single instance, capturing the polygons +/// the engine actually queries during collision. Parallel to +/// but for GfxObj-owned geometry (landblock-baked +/// statics like the cottage building 0x01000A2B in this investigation). +/// +/// +/// Polygons are captured in OBJECT-LOCAL frame — the same frame the +/// engine uses at collision time after applying the ShadowEntry's +/// world transform. The harness loads the dump, attaches a synthetic +/// single-leaf BSP, and registers the GfxObj at the appropriate world +/// transform via the test's ShadowObjects.Register call. +/// +/// +/// +/// What's intentionally NOT captured: the raw DAT +/// PhysicsBSPTree (the dump's consumer wraps polygons in a +/// single-leaf BSP), the original PhysicsPolygons + raw +/// VertexArray dicts (the engine uses +/// during collision; the raw dicts are placeholders post-hydrate). +/// +/// +public sealed record GfxObjDump( + uint GfxObjId, + Vector3Dto BoundingSphereOrigin, + float BoundingSphereRadius, + IReadOnlyList ResolvedPolygons); + +public static class GfxObjDumpSerializer +{ + private static readonly JsonSerializerOptions WriteOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + }; + + private static readonly JsonSerializerOptions ReadOptions = new() + { + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + }; + + /// + /// Snapshot a instance into a + /// JSON-friendly DTO. Polygon vertices + /// stay in object-local frame. + /// + public static GfxObjDump Capture(uint gfxObjId, GfxObjPhysics gfx) + { + var resolved = new List(gfx.Resolved.Count); + foreach (var (id, poly) in gfx.Resolved) + { + var verts = new List(poly.Vertices.Length); + foreach (var v in poly.Vertices) + verts.Add(Vector3Dto.From(v)); + + resolved.Add(new PolygonDump( + Id: id, + NumPoints: poly.NumPoints, + SidesType: (int)poly.SidesType, + Plane: PlaneDto.From(poly.Plane), + Vertices: verts)); + } + + // Fall back to (0,0,0) + 0 when BoundingSphere is unset — the + // hydrate path recomputes a covering sphere from the polygon + // vertices in that case. + var bsOrigin = gfx.BoundingSphere?.Origin ?? Vector3.Zero; + var bsRadius = gfx.BoundingSphere?.Radius ?? 0f; + + return new GfxObjDump( + GfxObjId: gfxObjId, + BoundingSphereOrigin: Vector3Dto.From(bsOrigin), + BoundingSphereRadius: bsRadius, + ResolvedPolygons: resolved); + } + + public static void Write(GfxObjDump dump, string filePath) + { + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + using var stream = File.Create(filePath); + JsonSerializer.Serialize(stream, dump, WriteOptions); + } + + public static GfxObjDump Read(string filePath) + { + using var stream = File.OpenRead(filePath); + var dump = JsonSerializer.Deserialize(stream, ReadOptions); + if (dump is null) + throw new InvalidDataException( + $"GfxObj dump deserialized to null: {filePath}"); + return dump; + } + + /// + /// Re-hydrate a from a + /// . Constructs a synthetic single-leaf + /// referencing every polygon — sufficient + /// for BSPQuery traversal at collision time. PhysicsPolygons and + /// Vertices are placeholders (the engine reads + /// during collision, not the raw dat dicts). + /// + public static GfxObjPhysics Hydrate(GfxObjDump dump) + { + var resolved = new Dictionary( + dump.ResolvedPolygons.Count); + foreach (var p in dump.ResolvedPolygons) + { + var verts = new Vector3[p.Vertices.Count]; + for (int i = 0; i < verts.Length; i++) + verts[i] = p.Vertices[i].ToVector3(); + + resolved[p.Id] = new ResolvedPolygon + { + Vertices = verts, + Plane = p.Plane.ToPlane(), + NumPoints = p.NumPoints, + SidesType = (DatReaderWriter.Enums.CullMode)p.SidesType, + Id = p.Id, + }; + } + + // Bounding sphere: use the captured value, OR fall back to a sphere + // that covers every polygon (computed from the centroid + max + // distance to any vertex). + var bsOrigin = dump.BoundingSphereOrigin.ToVector3(); + var bsRadius = dump.BoundingSphereRadius; + if (bsRadius <= 0f && resolved.Count > 0) + { + (bsOrigin, bsRadius) = ComputeCoveringSphere(resolved); + } + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = bsOrigin, + Radius = bsRadius, + }, + }; + foreach (var id in resolved.Keys) + leaf.Polygons.Add(id); + + var bspTree = new PhysicsBSPTree { Root = leaf }; + + return new GfxObjPhysics + { + BSP = bspTree, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = leaf.BoundingSphere, + }; + } + + private static (Vector3 origin, float radius) ComputeCoveringSphere( + Dictionary resolved) + { + var sum = Vector3.Zero; + int count = 0; + foreach (var poly in resolved.Values) + foreach (var v in poly.Vertices) + { + sum += v; + count++; + } + + if (count == 0) + return (Vector3.Zero, 0f); + + var centroid = sum / count; + float maxDistSq = 0f; + foreach (var poly in resolved.Values) + foreach (var v in poly.Vertices) + { + float distSq = Vector3.DistanceSquared(centroid, v); + if (distSq > maxDistSq) maxDistSq = distSq; + } + + return (centroid, MathF.Sqrt(maxDistSq)); + } +} diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 66a9556..f50a144 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -46,7 +46,7 @@ public sealed class PhysicsDataCache if (gfxObj.PhysicsBSP?.Root is null) return; if (gfxObj.VertexArray is null) return; - _gfxObj[gfxObjId] = new GfxObjPhysics + var physics = new GfxObjPhysics { BSP = gfxObj.PhysicsBSP, PhysicsPolygons = gfxObj.PhysicsPolygons, @@ -54,6 +54,27 @@ public sealed class PhysicsDataCache Vertices = gfxObj.VertexArray, Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), }; + _gfxObj[gfxObjId] = physics; + + if (PhysicsDiagnostics.ProbeDumpGfxObjsEnabled + && PhysicsDiagnostics.ProbeDumpGfxObjIds.Contains(gfxObjId)) + { + try + { + var dump = GfxObjDumpSerializer.Capture(gfxObjId, physics); + var path = System.IO.Path.Combine( + PhysicsDiagnostics.ProbeDumpGfxObjsPath, + System.FormattableString.Invariant($"0x{gfxObjId:X8}.gfxobj.json")); + GfxObjDumpSerializer.Write(dump, path); + Console.WriteLine(System.FormattableString.Invariant( + $"[gfxobj-dump] wrote 0x{gfxObjId:X8} polys={dump.ResolvedPolygons.Count} → {path}")); + } + catch (Exception ex) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[gfxobj-dump] FAILED to dump 0x{gfxObjId:X8}: {ex.GetType().Name}: {ex.Message}")); + } + } } /// diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index a941efe..bb9008d 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -429,6 +429,45 @@ public static class PhysicsDiagnostics public static bool ProbeDumpCellsEnabled => ProbeDumpCellIds.Count > 0; + /// + /// A6.P3 issue #98 (2026-05-23 evening v2) — GfxObj-equivalent of + /// . When non-empty, dumps the polygon + /// table + BSP root metadata of any cached GfxObj whose id matches + /// one of the listed values, as a JSON fixture under + /// . One-shot per id (a second + /// cache of the same GfxObj is a no-op). + /// + /// + /// Configured via ACDREAM_DUMP_GFXOBJS as a comma-separated + /// list of hex GfxObj ids (with or without 0x prefix). Output + /// defaults to tests/AcDream.Core.Tests/Fixtures/issue98 + /// (relative to the worktree root) with one file per id named + /// 0x{id:X8}.gfxobj.json so it doesn't collide with cell + /// dumps in the same directory. Override directory via + /// ACDREAM_DUMP_GFXOBJS_DIR=<dir>. + /// + /// + /// + /// The motivation: the existing [resolve-bldg] probe captures + /// the GfxObj-level metadata (id, BSP root radius, entity origin) but + /// emits hitPoly: n/a (BSP path — side-channel not written) + /// because the BSPQuery wire site that would populate + /// never landed. A polygon-level dump + /// at cache time bypasses that gap entirely — one capture run yields + /// the FULL polygon table, suitable for fixture-loading in + /// CellarUpTrajectoryReplayTests's RegisterCottageGfxObj + /// helper. + /// + /// + public static IReadOnlySet ProbeDumpGfxObjIds { get; set; } = + ParseHexIdList(Environment.GetEnvironmentVariable("ACDREAM_DUMP_GFXOBJS")); + + public static string ProbeDumpGfxObjsPath { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_DUMP_GFXOBJS_DIR") + ?? "tests/AcDream.Core.Tests/Fixtures/issue98"; + + public static bool ProbeDumpGfxObjsEnabled => ProbeDumpGfxObjIds.Count > 0; + private static IReadOnlySet ParseHexIdList(string? raw) { if (string.IsNullOrWhiteSpace(raw)) diff --git a/tests/AcDream.Core.Tests/Physics/GfxObjDumpRoundTripTests.cs b/tests/AcDream.Core.Tests/Physics/GfxObjDumpRoundTripTests.cs new file mode 100644 index 0000000..8f5aabc --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/GfxObjDumpRoundTripTests.cs @@ -0,0 +1,201 @@ +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// A6.P3 issue #98 (2026-05-23 evening v2). Sanity check for +/// : hand-construct a small +/// , snapshot it, serialize → deserialize → +/// hydrate, then assert the round-trip preserves every field the harness's +/// RegisterCottageGfxObj path depends on. +/// +/// +/// Mirrors in shape — sanity-only, +/// no engine wiring. The fixture's polygons are in OBJECT-LOCAL frame +/// (matching the production capture path's frame), so the hydrated +/// instance is suitable for placement under any world transform via +/// ShadowObjects.Register. +/// +/// +public class GfxObjDumpRoundTripTests +{ + [Fact] + public void Capture_Then_Hydrate_PreservesPolygons() + { + var original = MakeFixtureGfx(); + + var dump = GfxObjDumpSerializer.Capture(0x01000A2Bu, original); + var hydrated = GfxObjDumpSerializer.Hydrate(dump); + + Assert.Equal(original.Resolved.Count, hydrated.Resolved.Count); + foreach (var (id, originalPoly) in original.Resolved) + { + Assert.True(hydrated.Resolved.TryGetValue(id, out var rehydrated)); + Assert.Equal(originalPoly.NumPoints, rehydrated!.NumPoints); + Assert.Equal(originalPoly.SidesType, rehydrated.SidesType); + Assert.Equal(originalPoly.Plane.Normal, rehydrated.Plane.Normal); + Assert.Equal(originalPoly.Plane.D, rehydrated.Plane.D); + Assert.Equal(originalPoly.Vertices.Length, rehydrated.Vertices.Length); + for (int i = 0; i < originalPoly.Vertices.Length; i++) + Assert.Equal(originalPoly.Vertices[i], rehydrated.Vertices[i]); + Assert.Equal(originalPoly.Id, rehydrated.Id); + } + } + + [Fact] + public void Hydrate_ConstructsSyntheticSingleLeafBspWithAllPolygons() + { + var original = MakeFixtureGfx(); + var dump = GfxObjDumpSerializer.Capture(0x01000A2Bu, original); + var hydrated = GfxObjDumpSerializer.Hydrate(dump); + + // Synthetic BSP is single-leaf, references every resolved poly id. + Assert.NotNull(hydrated.BSP); + Assert.NotNull(hydrated.BSP!.Root); + Assert.Equal(BSPNodeType.Leaf, hydrated.BSP.Root.Type); + Assert.Equal(original.Resolved.Count, hydrated.BSP.Root.Polygons.Count); + foreach (var id in original.Resolved.Keys) + Assert.Contains(id, hydrated.BSP.Root.Polygons); + } + + [Fact] + public void WriteRead_OnDisk_PreservesContent() + { + var original = MakeFixtureGfx(); + var dump = GfxObjDumpSerializer.Capture(0x01000A2Bu, original); + + var path = Path.Combine(Path.GetTempPath(), + $"acdream-gfxobjdump-test-{System.Guid.NewGuid():N}.json"); + try + { + GfxObjDumpSerializer.Write(dump, path); + Assert.True(File.Exists(path)); + + var readBack = GfxObjDumpSerializer.Read(path); + Assert.Equal(dump.GfxObjId, readBack.GfxObjId); + Assert.Equal(dump.ResolvedPolygons.Count, readBack.ResolvedPolygons.Count); + + var hydrated = GfxObjDumpSerializer.Hydrate(readBack); + Assert.Equal(original.Resolved.Count, hydrated.Resolved.Count); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void Hydrate_RecomputesCoveringSphereWhenDumpHasZeroRadius() + { + // Force the hydrate-side covering-sphere computation by capturing + // a GfxObj whose BoundingSphere is null. The hydrate path should + // compute a sphere that encloses every vertex. + var resolved = new Dictionary + { + [0x0001] = new ResolvedPolygon + { + Id = 0x0001, + NumPoints = 3, + SidesType = CullMode.Clockwise, + Plane = new Plane(new Vector3(0, 0, 1), 0f), + Vertices = new[] + { + new Vector3(0f, 0f, 0f), + new Vector3(4f, 0f, 0f), + new Vector3(0f, 3f, 0f), + }, + }, + }; + var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf }; + leaf.Polygons.Add(0x0001); + var src = new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = leaf }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = null, + }; + + var dump = GfxObjDumpSerializer.Capture(0x42u, src); + // BoundingSphere=null in source → radius 0 in dump. + Assert.Equal(0f, dump.BoundingSphereRadius); + + var hydrated = GfxObjDumpSerializer.Hydrate(dump); + Assert.NotNull(hydrated.BoundingSphere); + Assert.True(hydrated.BoundingSphere!.Radius > 0f); + // Each vertex must lie within the covering sphere (with float-EPS slack). + foreach (var v in resolved[0x0001].Vertices) + { + float dist = Vector3.Distance(hydrated.BoundingSphere.Origin, v); + Assert.True(dist <= hydrated.BoundingSphere.Radius + 1e-4f, + $"Vertex {v} at distance {dist} exceeds covering radius {hydrated.BoundingSphere.Radius}"); + } + } + + private static GfxObjPhysics MakeFixtureGfx() + { + // Two polygons modelling a fragment of the cottage GfxObj (object- + // local frame): a downward-facing horizontal "cottage floor" and + // the cellar ramp (the polys live capture pinpointed). + var resolved = new Dictionary + { + [0x0004] = new ResolvedPolygon + { + Id = 0x0004, + NumPoints = 3, + SidesType = CullMode.Clockwise, + // Downward-facing floor at object-Z=1.5 (in local frame the + // cottage floor sits 1.5m above the building origin). + Plane = new Plane(new Vector3(0, 0, -1), 1.5f), + Vertices = new[] + { + new Vector3(-6.2f, 7.6f, 1.5f), + new Vector3(-10f, 7.6f, 1.5f), + new Vector3(-10f, 2.8f, 1.5f), + }, + }, + [0x0008] = new ResolvedPolygon + { + Id = 0x0008, + NumPoints = 4, + SidesType = CullMode.Clockwise, + Plane = new Plane(new Vector3(0f, -0.7190f, 0.6950f), -0.1007f), + Vertices = new[] + { + new Vector3( 0.8f, -1.59f, -1.5f), + new Vector3( 0.8f, 1.31f, 1.5f), + new Vector3(-0.8f, 1.31f, 1.5f), + new Vector3(-0.8f, -1.59f, -1.5f), + }, + }, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere + { + Origin = new Vector3(-5f, 4f, 0f), + Radius = 14f, + }, + }; + leaf.Polygons.Add(0x0004); + leaf.Polygons.Add(0x0008); + + return new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = leaf }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = leaf.BoundingSphere, + }; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs index 682b31b..8c9d45b 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs @@ -1,5 +1,6 @@ using AcDream.Core.Physics; using DatReaderWriter.Enums; +using System.Collections.Generic; using System.Numerics; using Xunit; @@ -141,4 +142,27 @@ public class PhysicsDiagnosticsTests PhysicsDiagnostics.ProbeStepWalkEnabled = initial; } } + + // ----------------------------------------------------------------------- + // ProbeDumpGfxObjs — parallel of ProbeDumpCells (A6.P3 #98, evening v2). + // ----------------------------------------------------------------------- + + [Fact] + public void ProbeDumpGfxObjs_EnabledTracksIdSetNonEmpty() + { + var initial = PhysicsDiagnostics.ProbeDumpGfxObjIds; + try + { + PhysicsDiagnostics.ProbeDumpGfxObjIds = new HashSet(); + Assert.False(PhysicsDiagnostics.ProbeDumpGfxObjsEnabled); + + PhysicsDiagnostics.ProbeDumpGfxObjIds = new HashSet { 0x01000A2Bu }; + Assert.True(PhysicsDiagnostics.ProbeDumpGfxObjsEnabled); + Assert.Contains(0x01000A2Bu, PhysicsDiagnostics.ProbeDumpGfxObjIds); + } + finally + { + PhysicsDiagnostics.ProbeDumpGfxObjIds = initial; + } + } }