The user is wedged at the top of Neftet rock plateaus, jumps sink into the mesh, and a corpse falls straight through. ACDREAM_PROBE_REACH already ruled out its own domain: blocked=0, every candidate tested-ok. Three candidates remain — terrain support, a collision mesh not where its visual is, or the transition wedging on an unobstructed path. ACDREAM_PROBE_RESOLVE alone cannot separate them. It prints a three-value contact-plane token, no plane normal, no plane height, no terrain sample and no plane provenance, so all three produce the same line. Two additions: [support] — one line per resolve for EVERY body, not just the player. A corpse is a plain physics body with no player-specific logic, so its fall-through is the cheapest available control on "movement code vs geometry data", and it is invisible to any player-filtered probe. The line samples the outdoor terrain INDEPENDENTLY at the body's own out-XY and prints the contact plane's own height at that same XY. Two heights at one point make support=terrain / object / none a measurement rather than an inference, and cpSrc= names the site that asserted the plane so provenance and classification cross-check. [geom] — once per GfxObj that comes near a mover: the object's physics-BSP vertex cloud against its visual mesh AABB in the same local frame, through the same prepared accessors the resolver queries. verdict=coincident REFUTES the working hypothesis for that object outright; no-physics-bsp / empty-physics-bsp / displaced / extent-mismatch each name a specific data defect. Built to refute, not to confirm — two diagnoses on this defect's lineage have already been refuted by measurement. ACDREAM_WIRE_MESH upgrades the existing F2 overlay, which drew a broadphase proxy cylinder for BSP objects and so could not answer the question at all, to the real physics-BSP polygon edges (cyan) beside the visual mesh box (magenta) and the terrain surface (yellow). Own class per code-structure rule 1. The provenance latch lives on PhysicsDiagnostics, not on CollisionInfo. Two fields there first — the obvious home — broke the flat/graph differential referee and the scratch-reset poison test, both of which compare CollisionInfo member-for-member. Teaching either to skip a member is a one-line green fix that puts a permanent hole in a referee whose whole job is comparing everything. Captured as feedback_probe_state_off_compared_types. Seven tests cover the support classifier's boundaries: a wrong classifier does not fail to answer, it answers confidently wrong. Gates: Release build 0 errors; complete suite 11,225 passed / 4 skipped / 0 failed from a cleaned tree — baseline 11,218/4/0 plus exactly the seven new tests, skips unchanged. Issue #337 filed with the symptom set, what is ruled out, and a table of what each possible output means. All of this is TEMPORARY and recorded for stripping with the physics-probe family. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
372 lines
15 KiB
C#
372 lines
15 KiB
C#
using System.Collections.Immutable;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Rendering;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337
|
|
/// probe family).
|
|
///
|
|
/// <para>
|
|
/// The F2 collision overlay predating this class drew, for a BSP object, a
|
|
/// proxy cylinder sized from the object's registered BROADPHASE radius. That
|
|
/// answers "where does the collision system think this object roughly is" and
|
|
/// nothing more. The open question in Neftet is a different one — whether an
|
|
/// object's collision SURFACES are where its visual mesh is drawn — and a
|
|
/// proxy sphere cannot answer it in either direction.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// This draws the actual geometry instead, in three colours that are meant to
|
|
/// be read against each other:
|
|
/// <list type="bullet">
|
|
/// <item><b>Cyan</b> — the object's real physics-BSP polygon edges, in world
|
|
/// space. These are the surfaces a body can stand on or be stopped by.
|
|
/// Where the cyan mesh sits away from the rock you can see, the collision
|
|
/// is displaced; where a visible rock has no cyan on it at all, it has no
|
|
/// collision geometry.</item>
|
|
/// <item><b>Magenta</b> — the same object's VISUAL mesh bounding box, from
|
|
/// the same prepared assets the renderer draws from. It is the reference
|
|
/// the cyan is judged against, so the comparison does not depend on the
|
|
/// eye's guess about where the visual "really" is.</item>
|
|
/// <item><b>Yellow</b> — the outdoor terrain surface under the player, as a
|
|
/// grid of the physics engine's own sampled heights. If a body is resting
|
|
/// on the yellow rather than on cyan, terrain is holding it up and the
|
|
/// object's collision is not involved at all.</item>
|
|
/// </list>
|
|
/// Dim orange keeps the old broadphase proxy visible so nothing the previous
|
|
/// overlay showed has been taken away.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Geometry is resolved through the SAME prepared collision accessors the
|
|
/// resolver queries (<c>PhysicsDataCache.GetFlatGfxObj</c> /
|
|
/// <c>GetVisualBounds</c>) and placed with the SAME world transform the
|
|
/// collision probes use, so this cannot draw a shape the collision system does
|
|
/// not actually hold. Reading the geometry by a second route is how AP-156
|
|
/// managed to report a sphere the registry never emitted.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Pure reads. Nothing here mutates physics, registry, or render state; the
|
|
/// caller owns the <see cref="DebugLineRenderer"/> frame.
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed class CollisionMeshWireframe
|
|
{
|
|
// Colours, in the order the class comment lists them.
|
|
private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f);
|
|
private static readonly Vector3 VisualColor = new(1f, 0f, 1f);
|
|
private static readonly Vector3 TerrainColor = new(1f, 1f, 0f);
|
|
private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f);
|
|
|
|
/// <summary>
|
|
/// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's
|
|
/// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock
|
|
/// 0x8766 carries the largest single collision owner measured in the game
|
|
/// (an 81-cell footprint), and an uncapped walk of it would be the one
|
|
/// place this overlay falls over.
|
|
/// </summary>
|
|
private const int MaxLines = 40_000;
|
|
|
|
/// <summary>Per-object polygon ceiling, so one enormous formation cannot
|
|
/// consume the whole budget and hide every other object near it.</summary>
|
|
private const int MaxPolygonsPerObject = 4_000;
|
|
|
|
/// <summary>Half-width in metres of the terrain grid drawn under the
|
|
/// player, and its sample spacing.</summary>
|
|
private const float TerrainGridHalfWidth = 12f;
|
|
private const float TerrainGridStep = 2f;
|
|
|
|
private readonly PhysicsEngine _physics;
|
|
|
|
public CollisionMeshWireframe(PhysicsEngine physics)
|
|
=> _physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
|
|
|
/// <summary>
|
|
/// Emit the overlay for everything within
|
|
/// <see cref="RenderingDiagnostics.CollisionMeshWireframeRadius"/> of
|
|
/// <paramref name="centre"/>. Returns what it drew so the caller can
|
|
/// report a capped frame rather than silently showing partial geometry.
|
|
/// </summary>
|
|
public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(lines);
|
|
|
|
float radius = RenderingDiagnostics.CollisionMeshWireframeRadius;
|
|
float radiusSquared = radius * radius;
|
|
var budget = new LineBudget(MaxLines);
|
|
|
|
int objects = 0;
|
|
int polygons = 0;
|
|
int withoutGeometry = 0;
|
|
|
|
PhysicsDataCache? cache = _physics.DataCache;
|
|
|
|
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
|
|
{
|
|
// Objects register their part ORIGIN, which for a BSP part is
|
|
// routinely nowhere near the geometry itself (376 of the 973
|
|
// installed physics-BSP parts sit further from their own bounding
|
|
// centre than half their radius). Admitting on origin distance
|
|
// ALONE would drop exactly the large displaced-centre formations
|
|
// this overlay exists to look at, so the object's own radius is
|
|
// added to the window.
|
|
float reach = radius + shadow.Radius;
|
|
if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach)
|
|
continue;
|
|
|
|
objects++;
|
|
|
|
if (shadow.CollisionType != ShadowCollisionType.BSP)
|
|
{
|
|
DrawBroadphaseProxy(lines, in shadow, budget);
|
|
continue;
|
|
}
|
|
|
|
FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId);
|
|
int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget);
|
|
polygons += drawn;
|
|
if (drawn == 0) withoutGeometry++;
|
|
|
|
DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget);
|
|
DrawBroadphaseProxy(lines, in shadow, budget);
|
|
}
|
|
|
|
DrawTerrainGrid(lines, centre, budget);
|
|
|
|
return new CollisionMeshWireframeStats(
|
|
ObjectsConsidered: objects,
|
|
PolygonsDrawn: polygons,
|
|
ObjectsWithoutPhysicsGeometry: withoutGeometry,
|
|
LinesDrawn: budget.Used,
|
|
Capped: budget.Capped);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walk the object's physics BSP and emit one closed edge loop per polygon
|
|
/// the tree actually indexes. Polygons the tree does not reference are NOT
|
|
/// drawn: no query can reach them, so showing them would overstate the
|
|
/// collision surface. Returns the polygon count emitted.
|
|
/// </summary>
|
|
private static int DrawPhysicsPolygons(
|
|
DebugLineRenderer lines,
|
|
in ShadowEntry shadow,
|
|
FlatGfxObjCollisionAsset? asset,
|
|
Vector3 centre,
|
|
float radiusSquared,
|
|
LineBudget budget)
|
|
{
|
|
FlatPhysicsBsp? bsp = asset?.PhysicsBsp;
|
|
if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0)
|
|
return 0;
|
|
|
|
FlatPolygonTable table = bsp.PolygonTable;
|
|
ImmutableArray<Vector3> vertices = table.Vertices;
|
|
int emitted = 0;
|
|
|
|
foreach (FlatPhysicsBspNode node in bsp.Nodes)
|
|
{
|
|
FlatIndexRange indices = node.PolygonIndexRange;
|
|
for (int i = indices.Start; i < indices.EndExclusive; i++)
|
|
{
|
|
if (emitted >= MaxPolygonsPerObject || budget.Exhausted)
|
|
return emitted;
|
|
|
|
int polygonIndex = bsp.PolygonIndexStream[i];
|
|
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
|
|
|
|
FlatIndexRange span = table.Polygons[polygonIndex].VertexRange;
|
|
if (span.Count < 2) continue;
|
|
|
|
Vector3 first = ToWorld(vertices[span.Start], in shadow);
|
|
// Per-polygon distance rejection, AFTER the world transform:
|
|
// a big object admitted by the object-level window still only
|
|
// needs the faces near the player drawn.
|
|
if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue;
|
|
|
|
Vector3 previous = first;
|
|
for (int v = span.Start + 1; v < span.EndExclusive; v++)
|
|
{
|
|
Vector3 current = ToWorld(vertices[v], in shadow);
|
|
if (!budget.TryAdd()) return emitted;
|
|
lines.AddLine(previous, current, PhysicsColor);
|
|
previous = current;
|
|
}
|
|
|
|
if (span.Count > 2)
|
|
{
|
|
if (!budget.TryAdd()) return emitted;
|
|
lines.AddLine(previous, first, PhysicsColor);
|
|
}
|
|
|
|
emitted++;
|
|
}
|
|
}
|
|
|
|
return emitted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The visual mesh box, placed with the SAME transform as the physics
|
|
/// polygons above. It is drawn as the object's own rotated box (eight
|
|
/// transformed corners, twelve edges) rather than as a world-axis-aligned
|
|
/// box, so a rotated object's magenta lines still bound its actual visual.
|
|
/// </summary>
|
|
private static void DrawVisualBounds(
|
|
DebugLineRenderer lines,
|
|
in ShadowEntry shadow,
|
|
GfxObjVisualBounds? visual,
|
|
LineBudget budget)
|
|
{
|
|
if (visual is null || budget.Exhausted) return;
|
|
|
|
Vector3 min = visual.Min;
|
|
Vector3 max = visual.Max;
|
|
Span<Vector3> corners =
|
|
[
|
|
ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow),
|
|
ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow),
|
|
ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow),
|
|
ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow),
|
|
ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow),
|
|
ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow),
|
|
ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow),
|
|
ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow),
|
|
];
|
|
|
|
ReadOnlySpan<int> edges =
|
|
[
|
|
0, 1, 1, 2, 2, 3, 3, 0,
|
|
4, 5, 5, 6, 6, 7, 7, 4,
|
|
0, 4, 1, 5, 2, 6, 3, 7,
|
|
];
|
|
|
|
for (int e = 0; e < edges.Length; e += 2)
|
|
{
|
|
if (!budget.TryAdd()) return;
|
|
lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The registered broadphase shape — what the pre-#337 overlay showed, and
|
|
/// what the collision system's reach filter measures against. Kept so this
|
|
/// overlay is a superset of the one it replaces.
|
|
/// </summary>
|
|
private static void DrawBroadphaseProxy(
|
|
DebugLineRenderer lines,
|
|
in ShadowEntry shadow,
|
|
LineBudget budget)
|
|
{
|
|
// AddCylinder emits a fixed 36 lines. Reserve them together so a
|
|
// partial ring cannot be drawn.
|
|
if (!budget.TryAdd(36)) return;
|
|
|
|
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
|
|
{
|
|
float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f;
|
|
lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor);
|
|
return;
|
|
}
|
|
|
|
lines.AddCylinder(
|
|
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
|
|
shadow.Radius,
|
|
shadow.Radius * 2f,
|
|
BroadphaseColor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The terrain surface under the player, sampled through the physics
|
|
/// engine's own height resolver — the same numbers the resolver grounds
|
|
/// against, not a re-derivation. Drawn as a grid rather than as the single
|
|
/// containing triangle so the slope around the player reads at a glance.
|
|
/// </summary>
|
|
private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget)
|
|
{
|
|
int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep);
|
|
float originX = centre.X - TerrainGridHalfWidth;
|
|
float originY = centre.Y - TerrainGridHalfWidth;
|
|
|
|
for (int ix = 0; ix <= steps; ix++)
|
|
{
|
|
for (int iy = 0; iy <= steps; iy++)
|
|
{
|
|
float x = originX + ix * TerrainGridStep;
|
|
float y = originY + iy * TerrainGridStep;
|
|
float? z = _physics.SampleTerrainZ(x, y);
|
|
if (z is null) continue;
|
|
|
|
var here = new Vector3(x, y, z.Value);
|
|
|
|
if (ix < steps)
|
|
{
|
|
float nx = x + TerrainGridStep;
|
|
if (_physics.SampleTerrainZ(nx, y) is { } nz)
|
|
{
|
|
if (!budget.TryAdd()) return;
|
|
lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor);
|
|
}
|
|
}
|
|
|
|
if (iy < steps)
|
|
{
|
|
float ny = y + TerrainGridStep;
|
|
if (_physics.SampleTerrainZ(x, ny) is { } nz2)
|
|
{
|
|
if (!budget.TryAdd()) return;
|
|
lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one placement formula, matching the <c>[resolve-bldg]</c> probe's
|
|
/// world transform for a shadow part
|
|
/// (<c>TransitionTypes.FindObjCollisionsInCell</c>): scale in the part's
|
|
/// own frame, then rotate, then translate to the registered position.
|
|
/// </summary>
|
|
private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow)
|
|
=> shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation);
|
|
|
|
/// <summary>
|
|
/// Mutable line counter shared across the draw. A class rather than a
|
|
/// struct so the per-shape helpers can be static and still share it
|
|
/// without ref-plumbing through every signature.
|
|
/// </summary>
|
|
private sealed class LineBudget(int limit)
|
|
{
|
|
public int Used { get; private set; }
|
|
|
|
public bool Capped { get; private set; }
|
|
|
|
public bool Exhausted => Used >= limit;
|
|
|
|
public bool TryAdd(int count = 1)
|
|
{
|
|
if (Used + count > limit)
|
|
{
|
|
Capped = true;
|
|
return false;
|
|
}
|
|
Used += count;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>What one <see cref="CollisionMeshWireframe.Draw"/> emitted.
|
|
/// <paramref name="ObjectsWithoutPhysicsGeometry"/> is the interesting one: a
|
|
/// non-zero count means objects near the player carry no reachable collision
|
|
/// polygons at all.</summary>
|
|
internal readonly record struct CollisionMeshWireframeStats(
|
|
int ObjectsConsidered,
|
|
int PolygonsDrawn,
|
|
int ObjectsWithoutPhysicsGeometry,
|
|
int LinesDrawn,
|
|
bool Capped);
|