feat(phys): A6.P3 #98 — GfxObj dump infrastructure (ACDREAM_DUMP_GFXOBJS)

Mirror the existing ACDREAM_DUMP_CELLS pattern for GfxObj-owned geometry:
when ACDREAM_DUMP_GFXOBJS lists a hex GfxObj id, the first
PhysicsDataCache.CacheGfxObj for that id writes the full resolved
polygon table to a JSON fixture under
tests/AcDream.Core.Tests/Fixtures/issue98/0x{id:X8}.gfxobj.json (override
dir via ACDREAM_DUMP_GFXOBJS_DIR).

Motivation: the existing [resolve-bldg] probe captures GfxObj-level
metadata (id, BSP root radius, entity origin) but emits
"hitPoly: n/a (BSP path — side-channel not written)" because the
BSPQuery wire site that would populate LastBspHitPoly never landed.
A polygon-level dump at cache time bypasses that gap — one capture run
yields the FULL polygon table, fixture-loadable by the harness's
RegisterCottageGfxObj helper (next commit).

See docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
for the cottage GfxObj 0x01000A2B context: landblock-baked static at
entity origin (130.5, 11.5, 94.0), responsible for the head-sphere cap
from below at world Z=94.0 that issue #98 is documenting.

Test baseline: 1183 + 8 pre-existing failures (serial run; +5 new tests
all pass; was 1178 + 8 pre-session).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-23 20:24:26 +02:00
parent 4d83ba5620
commit cc3afbcbeb
5 changed files with 484 additions and 1 deletions

View file

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2) — JSON-serializable snapshot of
/// a single <see cref="GfxObjPhysics"/> instance, capturing the polygons
/// the engine actually queries during collision. Parallel to
/// <see cref="CellDump"/> but for GfxObj-owned geometry (landblock-baked
/// statics like the cottage building 0x01000A2B in this investigation).
///
/// <para>
/// Polygons are captured in OBJECT-LOCAL frame — the same frame the
/// engine uses at collision time after applying the ShadowEntry's
/// world transform. The harness loads the dump, attaches a synthetic
/// single-leaf BSP, and registers the GfxObj at the appropriate world
/// transform via the test's ShadowObjects.Register call.
/// </para>
///
/// <para>
/// What's intentionally NOT captured: the raw DAT
/// <c>PhysicsBSPTree</c> (the dump's consumer wraps polygons in a
/// single-leaf BSP), the original <c>PhysicsPolygons</c> + raw
/// <c>VertexArray</c> dicts (the engine uses <see cref="ResolvedPolygon"/>
/// during collision; the raw dicts are placeholders post-hydrate).
/// </para>
/// </summary>
public sealed record GfxObjDump(
uint GfxObjId,
Vector3Dto BoundingSphereOrigin,
float BoundingSphereRadius,
IReadOnlyList<PolygonDump> ResolvedPolygons);
public static class GfxObjDumpSerializer
{
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="GfxObjPhysics"/> instance into a
/// JSON-friendly <see cref="GfxObjDump"/> DTO. Polygon vertices
/// stay in object-local frame.
/// </summary>
public static GfxObjDump Capture(uint gfxObjId, GfxObjPhysics gfx)
{
var resolved = new List<PolygonDump>(gfx.Resolved.Count);
foreach (var (id, poly) in gfx.Resolved)
{
var verts = new List<Vector3Dto>(poly.Vertices.Length);
foreach (var v in poly.Vertices)
verts.Add(Vector3Dto.From(v));
resolved.Add(new PolygonDump(
Id: id,
NumPoints: poly.NumPoints,
SidesType: (int)poly.SidesType,
Plane: PlaneDto.From(poly.Plane),
Vertices: verts));
}
// Fall back to (0,0,0) + 0 when BoundingSphere is unset — the
// hydrate path recomputes a covering sphere from the polygon
// vertices in that case.
var bsOrigin = gfx.BoundingSphere?.Origin ?? Vector3.Zero;
var bsRadius = gfx.BoundingSphere?.Radius ?? 0f;
return new GfxObjDump(
GfxObjId: gfxObjId,
BoundingSphereOrigin: Vector3Dto.From(bsOrigin),
BoundingSphereRadius: bsRadius,
ResolvedPolygons: resolved);
}
public static void Write(GfxObjDump 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 GfxObjDump Read(string filePath)
{
using var stream = File.OpenRead(filePath);
var dump = JsonSerializer.Deserialize<GfxObjDump>(stream, ReadOptions);
if (dump is null)
throw new InvalidDataException(
$"GfxObj dump deserialized to null: {filePath}");
return dump;
}
/// <summary>
/// Re-hydrate a <see cref="GfxObjPhysics"/> from a
/// <see cref="GfxObjDump"/>. Constructs a synthetic single-leaf
/// <see cref="PhysicsBSPTree"/> referencing every polygon — sufficient
/// for BSPQuery traversal at collision time. PhysicsPolygons and
/// Vertices are placeholders (the engine reads <see cref="GfxObjPhysics.Resolved"/>
/// during collision, not the raw dat dicts).
/// </summary>
public static GfxObjPhysics Hydrate(GfxObjDump dump)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(
dump.ResolvedPolygons.Count);
foreach (var p in dump.ResolvedPolygons)
{
var verts = new Vector3[p.Vertices.Count];
for (int i = 0; i < verts.Length; i++)
verts[i] = p.Vertices[i].ToVector3();
resolved[p.Id] = new ResolvedPolygon
{
Vertices = verts,
Plane = p.Plane.ToPlane(),
NumPoints = p.NumPoints,
SidesType = (DatReaderWriter.Enums.CullMode)p.SidesType,
Id = p.Id,
};
}
// Bounding sphere: use the captured value, OR fall back to a sphere
// that covers every polygon (computed from the centroid + max
// distance to any vertex).
var bsOrigin = dump.BoundingSphereOrigin.ToVector3();
var bsRadius = dump.BoundingSphereRadius;
if (bsRadius <= 0f && resolved.Count > 0)
{
(bsOrigin, bsRadius) = ComputeCoveringSphere(resolved);
}
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = bsOrigin,
Radius = bsRadius,
},
};
foreach (var id in resolved.Keys)
leaf.Polygons.Add(id);
var bspTree = new PhysicsBSPTree { Root = leaf };
return new GfxObjPhysics
{
BSP = bspTree,
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = resolved,
BoundingSphere = leaf.BoundingSphere,
};
}
private static (Vector3 origin, float radius) ComputeCoveringSphere(
Dictionary<ushort, ResolvedPolygon> resolved)
{
var sum = Vector3.Zero;
int count = 0;
foreach (var poly in resolved.Values)
foreach (var v in poly.Vertices)
{
sum += v;
count++;
}
if (count == 0)
return (Vector3.Zero, 0f);
var centroid = sum / count;
float maxDistSq = 0f;
foreach (var poly in resolved.Values)
foreach (var v in poly.Vertices)
{
float distSq = Vector3.DistanceSquared(centroid, v);
if (distSq > maxDistSq) maxDistSq = distSq;
}
return (centroid, MathF.Sqrt(maxDistSq));
}
}

View file

@ -46,7 +46,7 @@ public sealed class PhysicsDataCache
if (gfxObj.PhysicsBSP?.Root is null) return;
if (gfxObj.VertexArray is null) return;
_gfxObj[gfxObjId] = new GfxObjPhysics
var physics = new GfxObjPhysics
{
BSP = gfxObj.PhysicsBSP,
PhysicsPolygons = gfxObj.PhysicsPolygons,
@ -54,6 +54,27 @@ public sealed class PhysicsDataCache
Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
};
_gfxObj[gfxObjId] = physics;
if (PhysicsDiagnostics.ProbeDumpGfxObjsEnabled
&& PhysicsDiagnostics.ProbeDumpGfxObjIds.Contains(gfxObjId))
{
try
{
var dump = GfxObjDumpSerializer.Capture(gfxObjId, physics);
var path = System.IO.Path.Combine(
PhysicsDiagnostics.ProbeDumpGfxObjsPath,
System.FormattableString.Invariant($"0x{gfxObjId:X8}.gfxobj.json"));
GfxObjDumpSerializer.Write(dump, path);
Console.WriteLine(System.FormattableString.Invariant(
$"[gfxobj-dump] wrote 0x{gfxObjId:X8} polys={dump.ResolvedPolygons.Count} → {path}"));
}
catch (Exception ex)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[gfxobj-dump] FAILED to dump 0x{gfxObjId:X8}: {ex.GetType().Name}: {ex.Message}"));
}
}
}
/// <summary>

View file

@ -429,6 +429,45 @@ public static class PhysicsDiagnostics
public static bool ProbeDumpCellsEnabled => ProbeDumpCellIds.Count > 0;
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2) — GfxObj-equivalent of
/// <see cref="ProbeDumpCellIds"/>. When non-empty, dumps the polygon
/// table + BSP root metadata of any cached GfxObj whose id matches
/// one of the listed values, as a JSON fixture under
/// <see cref="ProbeDumpGfxObjsPath"/>. One-shot per id (a second
/// cache of the same GfxObj is a no-op).
///
/// <para>
/// Configured via <c>ACDREAM_DUMP_GFXOBJS</c> as a comma-separated
/// list of hex GfxObj ids (with or without <c>0x</c> prefix). Output
/// defaults to <c>tests/AcDream.Core.Tests/Fixtures/issue98</c>
/// (relative to the worktree root) with one file per id named
/// <c>0x{id:X8}.gfxobj.json</c> so it doesn't collide with cell
/// dumps in the same directory. Override directory via
/// <c>ACDREAM_DUMP_GFXOBJS_DIR=&lt;dir&gt;</c>.
/// </para>
///
/// <para>
/// The motivation: the existing <c>[resolve-bldg]</c> probe captures
/// the GfxObj-level metadata (id, BSP root radius, entity origin) but
/// emits <c>hitPoly: n/a (BSP path — side-channel not written)</c>
/// because the BSPQuery wire site that would populate
/// <see cref="LastBspHitPoly"/> never landed. A polygon-level dump
/// at cache time bypasses that gap entirely — one capture run yields
/// the FULL polygon table, suitable for fixture-loading in
/// <c>CellarUpTrajectoryReplayTests</c>'s <c>RegisterCottageGfxObj</c>
/// helper.
/// </para>
/// </summary>
public static IReadOnlySet<uint> ProbeDumpGfxObjIds { get; set; } =
ParseHexIdList(Environment.GetEnvironmentVariable("ACDREAM_DUMP_GFXOBJS"));
public static string ProbeDumpGfxObjsPath { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_GFXOBJS_DIR")
?? "tests/AcDream.Core.Tests/Fixtures/issue98";
public static bool ProbeDumpGfxObjsEnabled => ProbeDumpGfxObjIds.Count > 0;
private static IReadOnlySet<uint> ParseHexIdList(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))