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;
+ }
+ }
}