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>
198 lines
7 KiB
C#
198 lines
7 KiB
C#
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));
|
|
}
|
|
}
|