diff --git a/src/AcDream.Core/Physics/CellDump.cs b/src/AcDream.Core/Physics/CellDump.cs
new file mode 100644
index 0000000..b631dd5
--- /dev/null
+++ b/src/AcDream.Core/Physics/CellDump.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AcDream.Core.Physics;
+
+///
+/// A6.P3 issue #98 (2026-05-23) — JSON-serializable snapshot of a single
+/// instance. Captured by
+/// when
+/// matches; consumed by
+/// (and any future replay test)
+/// to drive deterministic checks without loading a live DAT.
+///
+///
+/// What's intentionally NOT in this dump: the DAT-native
+/// PhysicsBSPTree and CellBSPTree trees. The replay harness
+/// drives the leaf-level predicates (WalkableHitsSphere,
+/// FindCrossedEdge, PolygonHitsSpherePrecise) directly on the
+/// resolved polygon list, which is enough to expose the issue-#98 walkable
+/// rejection. If a later replay needs BSP traversal, this DTO can be
+/// extended without breaking existing fixtures (the loader accepts missing
+/// fields).
+///
+///
+public sealed record CellDump(
+ uint CellId,
+ Matrix4x4Dto WorldTransform,
+ Matrix4x4Dto InverseWorldTransform,
+ IReadOnlyList ResolvedPolygons,
+ IReadOnlyList PortalPolygons,
+ IReadOnlyList Portals,
+ IReadOnlyList VisibleCellIds);
+
+public sealed record PolygonDump(
+ ushort Id,
+ int NumPoints,
+ int SidesType,
+ PlaneDto Plane,
+ IReadOnlyList Vertices);
+
+public sealed record PortalDump(
+ ushort OtherCellId,
+ ushort PolygonId,
+ ushort Flags);
+
+public sealed record Vector3Dto(float X, float Y, float Z)
+{
+ public static Vector3Dto From(Vector3 v) => new(v.X, v.Y, v.Z);
+ public Vector3 ToVector3() => new(X, Y, Z);
+}
+
+public sealed record PlaneDto(Vector3Dto Normal, float D)
+{
+ public static PlaneDto From(Plane p) => new(Vector3Dto.From(p.Normal), p.D);
+ public Plane ToPlane() => new(Normal.ToVector3(), D);
+}
+
+public sealed record Matrix4x4Dto(
+ float M11, float M12, float M13, float M14,
+ float M21, float M22, float M23, float M24,
+ float M31, float M32, float M33, float M34,
+ float M41, float M42, float M43, float M44)
+{
+ public static Matrix4x4Dto From(Matrix4x4 m) => new(
+ m.M11, m.M12, m.M13, m.M14,
+ m.M21, m.M22, m.M23, m.M24,
+ m.M31, m.M32, m.M33, m.M34,
+ m.M41, m.M42, m.M43, m.M44);
+
+ public Matrix4x4 ToMatrix() => new(
+ M11, M12, M13, M14,
+ M21, M22, M23, M24,
+ M31, M32, M33, M34,
+ M41, M42, M43, M44);
+}
+
+public static class CellDumpSerializer
+{
+ 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. Pure projection; does not allocate the
+ /// underlying polygon arrays (vertices are re-emitted as DTOs).
+ ///
+ public static CellDump Capture(uint cellId, CellPhysics cell)
+ {
+ var resolved = new List(cell.Resolved.Count);
+ foreach (var (id, poly) in cell.Resolved)
+ resolved.Add(DumpPolygon(id, poly));
+
+ var portalPolys = new List();
+ if (cell.PortalPolygons is not null)
+ {
+ foreach (var (id, poly) in cell.PortalPolygons)
+ portalPolys.Add(DumpPolygon(id, poly));
+ }
+
+ var portals = new List(cell.Portals.Count);
+ foreach (var p in cell.Portals)
+ portals.Add(new PortalDump(p.OtherCellId, p.PolygonId, p.Flags));
+
+ var visible = new List(cell.VisibleCellIds);
+
+ return new CellDump(
+ CellId: cellId,
+ WorldTransform: Matrix4x4Dto.From(cell.WorldTransform),
+ InverseWorldTransform: Matrix4x4Dto.From(cell.InverseWorldTransform),
+ ResolvedPolygons: resolved,
+ PortalPolygons: portalPolys,
+ Portals: portals,
+ VisibleCellIds: visible);
+ }
+
+ private static PolygonDump DumpPolygon(ushort id, ResolvedPolygon poly)
+ {
+ var verts = new List(poly.Vertices.Length);
+ foreach (var v in poly.Vertices)
+ verts.Add(Vector3Dto.From(v));
+
+ return new PolygonDump(
+ Id: id,
+ NumPoints: poly.NumPoints,
+ SidesType: (int)poly.SidesType,
+ Plane: PlaneDto.From(poly.Plane),
+ Vertices: verts);
+ }
+
+ public static void Write(CellDump 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 CellDump Read(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ var dump = JsonSerializer.Deserialize(stream, ReadOptions);
+ if (dump is null)
+ throw new InvalidDataException($"Cell dump deserialized to null: {filePath}");
+ return dump;
+ }
+
+ ///
+ /// Re-hydrate a from a .
+ /// The resulting instance has BSP = null and CellBSP = null
+ /// — replay tests drive the leaf-level predicates directly on
+ /// . If a future replay needs tree
+ /// traversal, extend the DTO + this method together.
+ ///
+ public static CellPhysics Hydrate(CellDump dump)
+ {
+ var resolved = new Dictionary(dump.ResolvedPolygons.Count);
+ foreach (var p in dump.ResolvedPolygons)
+ resolved[p.Id] = HydratePolygon(p);
+
+ var portalPolys = new Dictionary(dump.PortalPolygons.Count);
+ foreach (var p in dump.PortalPolygons)
+ portalPolys[p.Id] = HydratePolygon(p);
+
+ var portals = new List(dump.Portals.Count);
+ foreach (var p in dump.Portals)
+ portals.Add(new PortalInfo(
+ otherCellId: p.OtherCellId,
+ polygonId: p.PolygonId,
+ flags: p.Flags));
+
+ var visible = new HashSet(dump.VisibleCellIds);
+
+ return new CellPhysics
+ {
+ BSP = null,
+ PhysicsPolygons = null,
+ Vertices = null,
+ WorldTransform = dump.WorldTransform.ToMatrix(),
+ InverseWorldTransform = dump.InverseWorldTransform.ToMatrix(),
+ Resolved = resolved,
+ CellBSP = null,
+ Portals = portals,
+ PortalPolygons = portalPolys.Count == 0 ? null : portalPolys,
+ VisibleCellIds = visible,
+ };
+ }
+
+ private static ResolvedPolygon HydratePolygon(PolygonDump p)
+ {
+ var verts = new Vector3[p.Vertices.Count];
+ for (int i = 0; i < verts.Length; i++)
+ verts[i] = p.Vertices[i].ToVector3();
+
+ return new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = p.Plane.ToPlane(),
+ NumPoints = p.NumPoints,
+ SidesType = (DatReaderWriter.Enums.CullMode)p.SidesType,
+ Id = p.Id,
+ };
+ }
+}
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index ae40c63..66a9556 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -164,7 +164,7 @@ public sealed class PhysicsDataCache
visibleCellIds.Add(lbPrefix | lowId);
}
- _cellStruct[envCellId] = new CellPhysics
+ var cellPhysics = new CellPhysics
{
BSP = cellStruct.PhysicsBSP,
PhysicsPolygons = cellStruct.PhysicsPolygons,
@@ -178,6 +178,27 @@ public sealed class PhysicsDataCache
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
};
+ _cellStruct[envCellId] = cellPhysics;
+
+ if (PhysicsDiagnostics.ProbeDumpCellsEnabled
+ && PhysicsDiagnostics.ProbeDumpCellIds.Contains(envCellId))
+ {
+ try
+ {
+ var dump = CellDumpSerializer.Capture(envCellId, cellPhysics);
+ var path = System.IO.Path.Combine(
+ PhysicsDiagnostics.ProbeDumpCellsPath,
+ System.FormattableString.Invariant($"0x{envCellId:X8}.json"));
+ CellDumpSerializer.Write(dump, path);
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[cell-dump] wrote 0x{envCellId:X8} polys={dump.ResolvedPolygons.Count} portals={dump.Portals.Count} → {path}"));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[cell-dump] FAILED to dump 0x{envCellId:X8}: {ex.GetType().Name}: {ex.Message}"));
+ }
+ }
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
{
diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
index bf6f2bb..4a9947b 100644
--- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
@@ -395,6 +396,63 @@ public static class PhysicsDiagnostics
public static bool ProbeStepWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1";
+ ///
+ /// A6.P3 issue #98 cell-dump probe (2026-05-23). When non-empty, dumps
+ /// the geometry of any cached cell whose EnvCellId matches one of the
+ /// listed ids to a JSON file under
+ /// . One-shot per cell (a second cache
+ /// of the same id is a no-op).
+ ///
+ ///
+ /// Configured via ACDREAM_DUMP_CELLS as a comma-separated list of
+ /// hex cell ids (with or without 0x prefix). The output path
+ /// defaults to
+ /// tests/AcDream.Core.Tests/Fixtures/issue98/<cellid>.json
+ /// (relative to the worktree root). Override with
+ /// ACDREAM_DUMP_CELLS_DIR=<dir>.
+ ///
+ ///
+ ///
+ /// The dump fuels the deterministic replay harness for issue #98 — the
+ /// goal is to load cells 0xA9B40143 + 0xA9B40146 + 0xA9B40147 as JSON
+ /// fixtures and drive the failing-frame sphere through the walkable
+ /// query in a unit test, eliminating live-client iteration cost from the
+ /// investigation loop.
+ ///
+ ///
+ public static IReadOnlySet ProbeDumpCellIds { get; set; } =
+ ParseHexIdList(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CELLS"));
+
+ public static string ProbeDumpCellsPath { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_DUMP_CELLS_DIR")
+ ?? "tests/AcDream.Core.Tests/Fixtures/issue98";
+
+ public static bool ProbeDumpCellsEnabled => ProbeDumpCellIds.Count > 0;
+
+ private static IReadOnlySet ParseHexIdList(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return new System.Collections.Generic.HashSet();
+
+ var ids = new System.Collections.Generic.HashSet();
+ foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var trimmed = token.Trim();
+ if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ trimmed = trimmed[2..];
+
+ if (uint.TryParse(
+ trimmed,
+ System.Globalization.NumberStyles.HexNumber,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var id))
+ {
+ ids.Add(id);
+ }
+ }
+ return ids;
+ }
+
///
/// Side-channel populated by BSPQuery.SphereIntersectsSolidInternal
/// at the leaf where it returns true. Either
diff --git a/tests/AcDream.Core.Tests/Physics/CellDumpRoundTripTests.cs b/tests/AcDream.Core.Tests/Physics/CellDumpRoundTripTests.cs
new file mode 100644
index 0000000..509e63f
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/CellDumpRoundTripTests.cs
@@ -0,0 +1,186 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// A6.P3 issue #98 (2026-05-23). Sanity check for
+/// : hand-construct a small
+/// , snapshot it, serialize → deserialize → hydrate,
+/// then assert the round-trip preserves every field the replay harness
+/// depends on.
+///
+public class CellDumpRoundTripTests
+{
+ [Fact]
+ public void Capture_Then_Hydrate_PreservesTransformsAndPolygons()
+ {
+ var original = MakeFixtureCell();
+
+ var dump = CellDumpSerializer.Capture(0xA9B40147u, original);
+ var hydrated = CellDumpSerializer.Hydrate(dump);
+
+ Assert.Equal(original.WorldTransform, hydrated.WorldTransform);
+ Assert.Equal(original.InverseWorldTransform, hydrated.InverseWorldTransform);
+ 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]);
+ }
+ }
+
+ [Fact]
+ public void Capture_Then_Hydrate_PreservesPortalsAndVisibleCells()
+ {
+ var original = MakeFixtureCell();
+
+ var dump = CellDumpSerializer.Capture(0xA9B40147u, original);
+ var hydrated = CellDumpSerializer.Hydrate(dump);
+
+ Assert.Equal(original.Portals.Count, hydrated.Portals.Count);
+ for (int i = 0; i < original.Portals.Count; i++)
+ {
+ Assert.Equal(original.Portals[i].OtherCellId, hydrated.Portals[i].OtherCellId);
+ Assert.Equal(original.Portals[i].PolygonId, hydrated.Portals[i].PolygonId);
+ Assert.Equal(original.Portals[i].Flags, hydrated.Portals[i].Flags);
+ }
+
+ Assert.Equal(original.VisibleCellIds.Count, hydrated.VisibleCellIds.Count);
+ foreach (var id in original.VisibleCellIds)
+ Assert.Contains(id, hydrated.VisibleCellIds);
+ }
+
+ [Fact]
+ public void WriteRead_OnDisk_PreservesContent()
+ {
+ var original = MakeFixtureCell();
+ var dump = CellDumpSerializer.Capture(0xA9B40147u, original);
+
+ var path = Path.Combine(Path.GetTempPath(),
+ $"acdream-celldump-test-{System.Guid.NewGuid():N}.json");
+ try
+ {
+ CellDumpSerializer.Write(dump, path);
+ Assert.True(File.Exists(path));
+
+ var readBack = CellDumpSerializer.Read(path);
+ Assert.Equal(dump.CellId, readBack.CellId);
+ Assert.Equal(dump.ResolvedPolygons.Count, readBack.ResolvedPolygons.Count);
+ Assert.Equal(dump.Portals.Count, readBack.Portals.Count);
+
+ var hydrated = CellDumpSerializer.Hydrate(readBack);
+ Assert.Equal(original.WorldTransform, hydrated.WorldTransform);
+ Assert.Equal(original.Resolved.Count, hydrated.Resolved.Count);
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Hydrate_LeavesBspNullsSoLeafLevelPredicatesAreTheReplayPath()
+ {
+ var original = MakeFixtureCell();
+ var dump = CellDumpSerializer.Capture(0xA9B40147u, original);
+ var hydrated = CellDumpSerializer.Hydrate(dump);
+
+ // Replay harness drives leaf-level predicates directly on
+ // Resolved; the BSP trees aren't reconstructed in this iteration.
+ Assert.Null(hydrated.BSP);
+ Assert.Null(hydrated.CellBSP);
+ Assert.NotEmpty(hydrated.Resolved);
+ }
+
+ private static CellPhysics MakeFixtureCell()
+ {
+ // Triangle floor at local Z=0 with verts roughly matching poly
+ // 0x0004 from the issue #98 trace (in 0xA9B40143's frame).
+ var resolved = new Dictionary
+ {
+ [0x0004] = new ResolvedPolygon
+ {
+ Id = 0x0004,
+ NumPoints = 3,
+ SidesType = CullMode.Clockwise,
+ Plane = new Plane(new Vector3(0, 0, 1), 0f),
+ Vertices = new[]
+ {
+ new Vector3(-6.2f, 7.6f, 0f),
+ new Vector3(-10.0f, 7.6f, 0f),
+ new Vector3(-10.0f, 2.8f, 0f),
+ },
+ },
+ [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 portalPolys = new Dictionary
+ {
+ [0x0100] = new ResolvedPolygon
+ {
+ Id = 0x0100,
+ NumPoints = 4,
+ SidesType = CullMode.Clockwise,
+ Plane = new Plane(new Vector3(1, 0, 0), 5f),
+ Vertices = new[]
+ {
+ new Vector3(5f, -1f, -1f),
+ new Vector3(5f, 1f, -1f),
+ new Vector3(5f, 1f, 1f),
+ new Vector3(5f, -1f, 1f),
+ },
+ },
+ };
+
+ var portals = new List
+ {
+ new(otherCellId: 0x0146, polygonId: 0x0100, flags: 0x0002),
+ };
+
+ var visible = new HashSet { 0xA9B40143u, 0xA9B40146u };
+
+ var worldTransform =
+ Matrix4x4.CreateRotationZ(MathF.PI) *
+ Matrix4x4.CreateTranslation(130.5f, 11.5f, 94.0f);
+ Matrix4x4.Invert(worldTransform, out var inverse);
+
+ return new CellPhysics
+ {
+ BSP = null,
+ PhysicsPolygons = null,
+ Vertices = null,
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inverse,
+ Resolved = resolved,
+ CellBSP = null,
+ Portals = portals,
+ PortalPolygons = portalPolys,
+ VisibleCellIds = visible,
+ };
+ }
+}