probe(physics): ACDREAM_PROBE_SUPPORT + ACDREAM_WIRE_MESH — separate #337's three candidates

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>
This commit is contained in:
Erik 2026-08-06 19:49:59 +02:00
parent 13fcf38138
commit 49a7e90652
9 changed files with 1376 additions and 1 deletions

View file

@ -0,0 +1,372 @@
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);

View file

@ -64,6 +64,10 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
private readonly DebugVmRenderFactsPublisher _debugVm;
private readonly bool _debugVmConsumerActive;
private int _debugDrawLogCount;
// #337 (TEMPORARY): built on first use so the ordinary overlay path and
// every headless/no-window host pay nothing for it.
private CollisionMeshWireframe? _meshWireframe;
private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false);
public WorldSceneDiagnosticsController(
WorldRenderDiagnostics diagnostics,
@ -215,6 +219,18 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
if (!_state.CollisionWireframesVisible || _lines is null)
return;
// #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the
// broadphase-proxy overlay below with the objects' real physics-BSP
// polygon edges beside their visual mesh boxes and the terrain surface
// — see CollisionMeshWireframe for why the proxy cannot answer the
// question this mode exists for. Default off; F2 keeps its old
// behaviour otherwise.
if (RenderingDiagnostics.CollisionMeshWireframeEnabled)
{
DrawCollisionMesh(in camera);
return;
}
_lines.Begin();
int drawn = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
@ -255,6 +271,50 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
_lines.Flush(camera.Camera.View, camera.Projection);
}
/// <summary>
/// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on
/// the player when there is one, else on the camera, so the fly-camera
/// mode can inspect geometry too.
/// </summary>
private void DrawCollisionMesh(in WorldCameraFrame camera)
{
Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller
? controller.Position
: camera.Position;
_meshWireframe ??= new CollisionMeshWireframe(_physics);
_lines!.Begin();
CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre);
if (_mode.IsPlayerMode && _player.Controller is { } player)
{
_lines.AddCylinder(
player.Position,
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
1.8f,
new Vector3(1f, 0f, 0f));
}
_lines.Flush(camera.Camera.View, camera.Projection);
// A capped frame is showing PARTIAL geometry, which would otherwise be
// indistinguishable from an object that has none — exactly the
// misreading this overlay exists to prevent. Say so, throttled, rather
// than letting the picture lie.
if (stats != _lastMeshStats)
{
_lastMeshStats = stats;
Console.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " +
"noPhysicsGeometry={5} lines={6} capped={7}",
centre.X, centre.Y, centre.Z,
stats.ObjectsConsidered, stats.PolygonsDrawn,
stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped));
}
}
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
{
if (_debugDrawLogCount >= 5)