acdream/src/AcDream.Core/Physics/CellDump.cs
Erik f62a873be3 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).
2026-05-23 15:16:56 +02:00

218 lines
7.9 KiB
C#

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