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