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:
parent
35b37dfb5f
commit
f62a873be3
4 changed files with 484 additions and 1 deletions
218
src/AcDream.Core/Physics/CellDump.cs
Normal file
218
src/AcDream.Core/Physics/CellDump.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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/<cellid>.json</c>
|
||||||
|
/// (relative to the worktree root). Override with
|
||||||
|
/// <c>ACDREAM_DUMP_CELLS_DIR=<dir></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
|
||||||
|
|
|
||||||
186
tests/AcDream.Core.Tests/Physics/CellDumpRoundTripTests.cs
Normal file
186
tests/AcDream.Core.Tests/Physics/CellDumpRoundTripTests.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue