feat(phys): A6.P3 #98 Step 2 — cell-dump probe + roundtrip test

Step 2 of the apparatus plan at
C:\Users\erikn\.claude\plans\i-did-some-work-sharded-acorn.md. Adds a
one-shot cell-dump probe so the issue #98 replay harness can load real
cellar / cottage geometry as JSON fixtures, eliminating live-client
iteration from every fix attempt.

Probe gate:
  ACDREAM_DUMP_CELLS=0xA9B40143,0xA9B40146,0xA9B40147
  ACDREAM_DUMP_CELLS_DIR=tests/AcDream.Core.Tests/Fixtures/issue98 (default)

When set, the first time PhysicsDataCache.CacheCellStruct sees a matching
envCellId, it serializes the resulting CellPhysics to
<dir>/0x<cellid>.json and prints one [cell-dump] line. Zero cost when
unset (gate is a static-readonly IReadOnlySet<uint>.Count check).

DTOs (CellDump.cs):
- CellDump: top-level record holding cell id, WorldTransform,
  InverseWorldTransform, resolved polygons, portal polygons, portal
  infos, visible cell ids.
- PolygonDump / PortalDump / Vector3Dto / PlaneDto / Matrix4x4Dto:
  System.Text.Json-friendly records with explicit From / To converters.

What is intentionally NOT dumped: the DAT-native PhysicsBSPTree and
CellBSPTree trees. The replay harness drives the leaf-level walkable
predicates (WalkableHitsSphere, FindCrossedEdge, PolygonHitsSpherePrecise)
directly on the resolved polygon list, which is enough to expose the
issue #98 rejection (poly 0x0004 in 0xA9B40143 reports
insideEdges=False / overlapsSphere=False at the failing-frame sphere).
If a future replay needs BSP traversal we can extend the DTO + Hydrate
together without breaking fixtures.

Tests (CellDumpRoundTripTests):
- Capture → Hydrate preserves WorldTransform / InverseWorldTransform /
  every polygon's plane + vertices + NumPoints + SidesType.
- Capture → Hydrate preserves portal list + visible cell ids.
- Write to disk → Read back → Hydrate preserves content.
- Hydrate leaves BSP / CellBSP null by design (replay uses leaf-level
  predicates).

Verification:
- dotnet build: green, 0 errors.
- dotnet test: 1160 passed + 8 pre-existing failed (was 1156 + 8 before
  this commit; +4 from CellDumpRoundTripTests). Same 8 pre-existing
  failures, no new regressions.

Next: capture the three cells from the live client (Step 2 acceptance),
then build the replay harness against the fixtures (Step 3).
This commit is contained in:
Erik 2026-05-23 15:16:56 +02:00
parent 35b37dfb5f
commit f62a873be3
4 changed files with 484 additions and 1 deletions

View file

@ -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;
/// <summary>
/// A6.P3 issue #98 (2026-05-23) — JSON-serializable snapshot of a single
/// <see cref="CellPhysics"/> instance. Captured by
/// <see cref="PhysicsDataCache.CacheCellStruct"/> when
/// <see cref="PhysicsDiagnostics.ProbeDumpCellIds"/> matches; consumed by
/// <see cref="Issue98CellarUpReplayTests"/> (and any future replay test)
/// to drive deterministic checks without loading a live DAT.
///
/// <para>
/// What's intentionally NOT in this dump: the DAT-native
/// <c>PhysicsBSPTree</c> and <c>CellBSPTree</c> trees. The replay harness
/// drives the leaf-level predicates (<c>WalkableHitsSphere</c>,
/// <c>FindCrossedEdge</c>, <c>PolygonHitsSpherePrecise</c>) 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).
/// </para>
/// </summary>
public sealed record CellDump(
uint CellId,
Matrix4x4Dto WorldTransform,
Matrix4x4Dto InverseWorldTransform,
IReadOnlyList<PolygonDump> ResolvedPolygons,
IReadOnlyList<PolygonDump> PortalPolygons,
IReadOnlyList<PortalDump> Portals,
IReadOnlyList<uint> VisibleCellIds);
public sealed record PolygonDump(
ushort Id,
int NumPoints,
int SidesType,
PlaneDto Plane,
IReadOnlyList<Vector3Dto> 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,
};
/// <summary>
/// Snapshot a <see cref="CellPhysics"/> instance into a JSON-friendly
/// <see cref="CellDump"/> DTO. Pure projection; does not allocate the
/// underlying polygon arrays (vertices are re-emitted as DTOs).
/// </summary>
public static CellDump Capture(uint cellId, CellPhysics cell)
{
var resolved = new List<PolygonDump>(cell.Resolved.Count);
foreach (var (id, poly) in cell.Resolved)
resolved.Add(DumpPolygon(id, poly));
var portalPolys = new List<PolygonDump>();
if (cell.PortalPolygons is not null)
{
foreach (var (id, poly) in cell.PortalPolygons)
portalPolys.Add(DumpPolygon(id, poly));
}
var portals = new List<PortalDump>(cell.Portals.Count);
foreach (var p in cell.Portals)
portals.Add(new PortalDump(p.OtherCellId, p.PolygonId, p.Flags));
var visible = new List<uint>(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<Vector3Dto>(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<CellDump>(stream, ReadOptions);
if (dump is null)
throw new InvalidDataException($"Cell dump deserialized to null: {filePath}");
return dump;
}
/// <summary>
/// Re-hydrate a <see cref="CellPhysics"/> from a <see cref="CellDump"/>.
/// The resulting instance has <c>BSP = null</c> and <c>CellBSP = null</c>
/// — replay tests drive the leaf-level predicates directly on
/// <see cref="CellPhysics.Resolved"/>. If a future replay needs tree
/// traversal, extend the DTO + this method together.
/// </summary>
public static CellPhysics Hydrate(CellDump dump)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(dump.ResolvedPolygons.Count);
foreach (var p in dump.ResolvedPolygons)
resolved[p.Id] = HydratePolygon(p);
var portalPolys = new Dictionary<ushort, ResolvedPolygon>(dump.PortalPolygons.Count);
foreach (var p in dump.PortalPolygons)
portalPolys[p.Id] = HydratePolygon(p);
var portals = new List<PortalInfo>(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<uint>(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,
};
}
}

View file

@ -164,7 +164,7 @@ public sealed class PhysicsDataCache
visibleCellIds.Add(lbPrefix | lowId); visibleCellIds.Add(lbPrefix | lowId);
} }
_cellStruct[envCellId] = new CellPhysics var cellPhysics = new CellPhysics
{ {
BSP = cellStruct.PhysicsBSP, BSP = cellStruct.PhysicsBSP,
PhysicsPolygons = cellStruct.PhysicsPolygons, PhysicsPolygons = cellStruct.PhysicsPolygons,
@ -178,6 +178,27 @@ public sealed class PhysicsDataCache
PortalPolygons = portalPolygons, PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds, 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) if (PhysicsDiagnostics.ProbeCellCacheEnabled)
{ {

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -395,6 +396,63 @@ public static class PhysicsDiagnostics
public static bool ProbeStepWalkEnabled { get; set; } = public static bool ProbeStepWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1"; Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1";
/// <summary>
/// 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
/// <see cref="ProbeDumpCellsPath"/>. One-shot per cell (a second cache
/// of the same id is a no-op).
///
/// <para>
/// Configured via <c>ACDREAM_DUMP_CELLS</c> as a comma-separated list of
/// hex cell ids (with or without <c>0x</c> prefix). The output path
/// defaults to
/// <c>tests/AcDream.Core.Tests/Fixtures/issue98/&lt;cellid&gt;.json</c>
/// (relative to the worktree root). Override with
/// <c>ACDREAM_DUMP_CELLS_DIR=&lt;dir&gt;</c>.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public static IReadOnlySet<uint> 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<uint> ParseHexIdList(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return new System.Collections.Generic.HashSet<uint>();
var ids = new System.Collections.Generic.HashSet<uint>();
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;
}
/// <summary> /// <summary>
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c> /// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
/// at the leaf where it returns true. Either /// at the leaf where it returns true. Either

View file

@ -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;
/// <summary>
/// A6.P3 issue #98 (2026-05-23). Sanity check for
/// <see cref="CellDumpSerializer"/>: hand-construct a small
/// <see cref="CellPhysics"/>, snapshot it, serialize → deserialize → hydrate,
/// then assert the round-trip preserves every field the replay harness
/// depends on.
/// </summary>
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<ushort, ResolvedPolygon>
{
[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<ushort, ResolvedPolygon>
{
[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<PortalInfo>
{
new(otherCellId: 0x0146, polygonId: 0x0100, flags: 0x0002),
};
var visible = new HashSet<uint> { 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,
};
}
}