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

@ -1475,6 +1475,29 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the
collision geometry where the visual geometry is?** (#337, TEMPORARY).
`[support]`: one line per resolve **for every body, not just the player**
(a corpse falling through geometry is the cheapest control there is on
"movement code vs geometry data"). It samples the outdoor terrain
independently at the body's own out-XY and prints the contact plane's own
height at that same XY, so `support=terrain` / `object` / `none` is a
measurement rather than an inference; `cpSrc=` names the site that wrote
the plane so provenance cross-checks the classification. Edge-eager,
throttled to 4 Hz per body, and emits every 10 cm of vertical movement.
`[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex
cloud against its visual mesh AABB in the same frame, with a verdict
(`coincident` REFUTES "collision isn't where the visual is";
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate
those cases — it carries no plane normal, no plane height, no terrain
sample and no provenance.
- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from
a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan)
beside the same objects' visual mesh boxes (magenta) and the terrain
surface (yellow). Settles "visual versus collision" by eye instead of by
log. `ACDREAM_WIRE_RADIUS=<metres>` sets the window (default 30).
TEMPORARY, with the #337 probe family.
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
`PhysicsEngine.ResolveWithTransition` call. Each call appends one
JSON Lines record with full inputs, PhysicsBody snapshot before AND

View file

@ -24,6 +24,91 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — cause NOT yet established
**Status:** OPEN — instrumented, not diagnosed. Awaiting the live capture below.
**Severity:** HIGH — walk-through, fall-through, and a hard movement stop on world geometry.
**Filed:** 2026-08-06, user-reported in live play after #334's fix landed.
**Component:** physics / collision — possibly geometry data rather than movement code.
### Symptoms, all from the user in live play
1. Walks **up** a rock face onto a plateau fine, then **cannot pass at the top** — wedged, position frozen.
2. Jumping is **"swallowed half way by the rock"** — the body sinks into the visual geometry.
3. **A monster corpse falls straight through the rock.**
Symptom 3 is the load-bearing one. A corpse is a plain physics body with no
player-specific movement logic, so a fall-through there cannot be explained by
anything in the player's controller.
### What is already RULED OUT
`ACDREAM_PROBE_REACH` (`334-fix-gate.log`, and the earlier
`334-neftet-probe.log`). At the frozen position: `blocked=0`, and **every**
candidate returns `tested-ok` — including the landblock's own rock mesh
`gfx=0x010046DE` in cell `0x8766002B`. **No object is blocking the player.**
That probe can only see shadow objects, so it has ruled out its own domain and
can say nothing about terrain or the transition.
Two diagnoses have already been refuted by measurement on this defect's
lineage: the broadphase reach filter (#333/AP-158) and the edge-slide family.
Do not open a third by reasoning from the source.
### The remaining candidates — and what is NOT yet established
- **(a) terrain** is what supports/blocks the body (walkable slope limit,
step-up refusal, terrain Z).
- **(b)** a **collision mesh placed somewhere other than its visual**, so the
body interacts with geometry that is not where the rock is drawn.
- **(c)** the **transition wedging** despite an unobstructed path.
(b) is the current working hypothesis and is **NOT ESTABLISHED**. It is
plausible — a corpse falling through and a jump sinking in are both what
absent-or-displaced collision looks like — but no measurement supports it yet,
and the instruments below were built to REFUTE it, not to confirm it.
Possibly relevant, possibly coincidence: landblock `0x8766` carries the
**largest single collision owner in the game**, an 81-cell (9×9) footprint
measured during #334 — larger than anything else by a wide margin.
### Instruments (2026-08-06 — TEMPORARY, strip with the physics-probe family)
`ACDREAM_PROBE_RESOLVE` alone does **not** separate (a), (b) and (c): it prints
a three-value contact-plane token, no plane normal, no plane height, no terrain
sample and no plane provenance, so all three candidates produce the same line.
Two additions close that:
- **`ACDREAM_PROBE_SUPPORT=1`** → `[support]` + `[geom]`.
- `[support]`, one per resolve **per body** (players AND corpses): samples the
outdoor terrain independently at the body's own out-XY and prints the
contact plane's own height at that same XY. `support=terrain` /
`support=object` / `support=none` is then a measurement, not an inference,
and `cpSrc=` names the code site that wrote the plane so provenance and
classification cross-check each other.
- `[geom]`, once per GfxObj that comes near the mover: compares the object's
physics-BSP vertex cloud against its visual mesh AABB in the same local
frame. `verdict=coincident` **refutes (b)** for that object outright;
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
each name a specific data defect.
- **`ACDREAM_WIRE_MESH=1`** upgrades the existing F2 collision overlay from a
broadphase proxy cylinder to the objects' real physics-BSP polygon edges
(cyan) beside their visual mesh boxes (magenta) and the terrain surface
(yellow). Settles "visual versus collision" by eye.
### How to read the capture
| Observation | What it means |
|---|---|
| `[geom] verdict=no-physics-bsp` or `empty-physics-bsp` on the rock | The rock has **no collision geometry**. All three symptoms follow; nothing on the movement side needs explaining. |
| `[geom] verdict=displaced` | **(b) confirmed.** Fix the placement/registration transform. |
| `[geom] verdict=coincident` on every nearby object | **(b) refuted.** The cause is (a) or (c); read `[support]`. |
| `[support] support=terrain` while standing on the visible plateau | (a): terrain, not the rock, is the support — terrain Z near the plateau top is the thing to look at. |
| `[support] support=object` with `cpAboveTerr` ≈ the plateau height | The rock IS supporting the body; the wedge is (c). |
| `[support] stalled=true ok=true` with `cpWalkable=true` | (c): the transition accepts the move and advances nothing. |
| `[support] support=none` on the corpse throughout its fall | Nothing ever contacts it — consistent with absent collision, and `[geom]` says whose. |
| **No `[support]` line at all** for the corpse's guid while it visibly falls | The client is not simulating that body — the descent is server-driven or presentational, and the client-side collision path is not the place to look. An absence here is a real answer, not a gap in the capture. |
| `[support] cpWalkable=false` at the freeze | Slope-limit refusal — compare `cpNz` against `floorZ` on the same line. |
## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX
**Status:** OPEN

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)

View file

@ -1370,6 +1370,551 @@ public static class PhysicsDiagnostics
blocked, currPos.X, currPos.Y, currPos.Z, now));
}
// -----------------------------------------------------------------------
// [support] / [geom] — #337 "what is holding this body up, and is the
// collision geometry where the visual geometry is?" (2026-08-06 —
// TEMPORARY, strip with the physics-probe family).
//
// WHY A NEW FAMILY RATHER THAN MORE [resolve].
// ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out
// position + cell, ok, groundedIn, a THREE-VALUE contact-plane token
// (valid / lastKnown / none), the collision normal + responsible entity if
// something was hit, and one walkable-polygon bool. That is enough to say
// THAT the body stopped. It cannot say WHAT held it up, because it prints
// no plane normal, no plane height, no terrain sample, and no attribution
// for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain
// holding the body, (b) an object surface holding it somewhere other than
// where the rock is drawn, and (c) an unobstructed transition that simply
// fails to advance all produce the SAME [resolve] line. Two diagnoses this
// campaign have already been refuted by measurement; a probe that cannot
// separate the remaining three is not worth the launch.
//
// WHAT SEPARATES THEM.
// [support] — per resolve, per body (players AND corpses/NPCs, which is
// what makes the fall-through case observable at all). It 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 independent
// heights at one point:
// cpZ@out == terrZ → terrain is the support, whatever set it.
// cpZ@out >> terrZ → an object surface is the support.
// cpValid=false → nothing is; the body is in free fall.
// `cpSrc` names the code site that wrote the plane, so the classifier
// and the provenance are cross-checkable rather than one inferring
// the other.
// [geom] — once per GfxObj that comes near the mover. Compares the
// object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in
// the same local frame. If the collision geometry is absent, empty,
// displaced, or the wrong size, this line says so directly. That is
// the working hypothesis's refutation test: `verdict=coincident`
// kills "the collision isn't where the visual is" outright, and no
// amount of movement-side evidence is then needed to rule it out.
//
// Neither line gates, orders, or mutates anything. Both are pure reads.
// -----------------------------------------------------------------------
/// <summary>
/// Initial state from <c>ACDREAM_PROBE_SUPPORT=1</c>. Enables the
/// <c>[support]</c> and <c>[geom]</c> lines described above. Zero cost when
/// off (one static bool read per resolve and per collision candidate).
/// TEMPORARY — strip with the rest of the physics-probe family.
/// </summary>
public static bool ProbeSupportEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1";
/// <summary>Vertical agreement window, in metres, inside which the contact
/// plane's height and the terrain's height at the same XY are called the
/// same surface.</summary>
public const float SupportSameSurfaceZ = 0.05f;
/// <summary>Straight-up-component agreement window inside which the contact
/// plane's tilt and the terrain triangle's tilt are called the same
/// surface. 0.02 is roughly 1 degree near flat.</summary>
public const float SupportSameSurfaceNormalZ = 0.02f;
private static readonly object _supportGate = new();
private static readonly Dictionary<uint, (long Ms, long Signature)> _supportSeen = new();
private static readonly HashSet<uint> _geomSeen = new();
// Contact-plane provenance latch. Ten distinct sites call
// CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat),
// cell BSP, three water paths, and a straight-up fallback — and the plane
// they write is indistinguishable once stored, so [support]'s
// classification would have no independent cross-check.
//
// This deliberately does NOT live on CollisionInfo. That object's stored
// members are compared member-for-member by the flat/graph differential
// referee and by the transition-scratch reset poison test; adding a
// diagnostic field there makes both oracles report a difference that is
// not a difference, and the only way to keep them green is to teach them
// to skip a member — which is how a referee quietly stops refereeing.
// [ThreadStatic] because a headless host ticks several sessions in
// parallel and physics is synchronous within each.
[ThreadStatic] private static string? _contactPlaneSourceMember;
[ThreadStatic] private static int _contactPlaneSourceLine;
/// <summary>
/// Clear the provenance latch. Call once per resolve, before the sweep, so
/// a resolve that establishes no plane reports <c>none</c> rather than the
/// previous resolve's answer. No-op unless
/// <see cref="ProbeSupportEnabled"/>.
/// </summary>
public static void BeginContactPlaneAttribution()
{
if (!ProbeSupportEnabled) return;
_contactPlaneSourceMember = null;
_contactPlaneSourceLine = 0;
}
/// <summary>
/// Record the site asserting a contact plane. Called by
/// <c>CollisionInfo.SetContactPlane</c> with compiler-supplied literals;
/// self-guarded, so it is a single flag test when the probe is off and
/// allocates nothing when it is on.
/// </summary>
public static void RecordContactPlaneSource(string member, int line)
{
if (!ProbeSupportEnabled) return;
_contactPlaneSourceMember = member;
_contactPlaneSourceLine = line;
}
/// <summary>
/// <c>member:line</c> of the last site to assert a contact plane since
/// <see cref="BeginContactPlaneAttribution"/>, or <c>"none"</c>.
/// </summary>
public static string ContactPlaneSource =>
_contactPlaneSourceMember is { Length: > 0 } member
? string.Concat(
member,
":",
_contactPlaneSourceLine.ToString(
System.Globalization.CultureInfo.InvariantCulture))
: "none";
/// <summary>
/// Classify what is under the body. Pure function so the live probe and any
/// offline reader agree on the vocabulary.
/// <list type="bullet">
/// <item><c>none</c> — no contact plane: the body is unsupported.</item>
/// <item><c>terrain</c> — the contact plane sits at the terrain's height
/// AND shares its tilt.</item>
/// <item><c>object</c> — the contact plane sits clear of the terrain: some
/// collision surface other than the ground is the support.</item>
/// <item><c>coplanar-tilt-mismatch</c> — same height, different tilt.
/// Reported as its own answer rather than folded into either, because
/// it is exactly what a collision mesh laid flat against the ground
/// would look like and guessing between the two would be the third
/// unverified diagnosis this campaign.</item>
/// <item><c>no-terrain</c> — no outdoor terrain under this XY (indoors,
/// or the landblock is not resident): the comparison is unavailable
/// and is said so rather than defaulted.</item>
/// </list>
/// </summary>
public static string ClassifySupport(
bool contactPlaneValid,
bool terrainSampled,
float contactPlaneZAtXY,
float contactPlaneNormalZ,
float terrainZ,
float terrainNormalZ)
{
if (!contactPlaneValid) return "none";
if (!terrainSampled) return "no-terrain";
bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ;
bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ;
if (sameHeight && sameTilt) return "terrain";
if (sameHeight) return "coplanar-tilt-mismatch";
return "object";
}
/// <summary>
/// Evaluate a plane's height at a given XY. Returns <see langword="false"/>
/// when the plane is near-vertical, where a height is not defined — a wall
/// is never a floor, and reporting a huge number for one would read as a
/// displaced surface.
/// </summary>
public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z)
{
float nz = plane.Normal.Z;
if (MathF.Abs(nz) < 1e-4f)
{
z = float.NaN;
return false;
}
z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz;
return true;
}
/// <summary>
/// One <c>[support]</c> line. Self-guards on
/// <see cref="ProbeSupportEnabled"/>.
///
/// <para>
/// Volume control: per mover, the line re-emits IMMEDIATELY on any change
/// in the state signature — the support classification, the ok / contact /
/// walkable / stalled bits, a 0.1 m change in the body's height, or a
/// 0.1 m change in its height above terrain — and otherwise at most once
/// per 250 ms. A body falling through geometry therefore produces a line
/// every 10 cm of descent, and a body standing still produces four lines a
/// second. Nothing is aggregated away.
/// </para>
///
/// <para>
/// <paramref name="moverId"/> is never omitted:
/// <c>feedback_probe_identity_attribution</c> — a per-entity probe without
/// an identity produced a wrong root cause once already, and this capture
/// deliberately covers several bodies at once.
/// </para>
/// </summary>
public static void LogSupport(
uint moverId,
bool isPlayer,
Vector3 inPos,
uint inCell,
Vector3 targetPos,
Vector3 outPos,
uint outCell,
bool ok,
bool groundedIn,
bool contact,
bool onWalkable,
bool contactPlaneValid,
Plane contactPlane,
uint contactPlaneCellId,
bool contactPlaneIsWater,
string contactPlaneSource,
bool lastKnownValid,
Plane lastKnownPlane,
bool terrainSampled,
float terrainZ,
Vector3 terrainNormal,
uint terrainCellId,
bool terrainIsWater,
bool walkablePolygon,
bool lastWalkablePolygon,
float stepUpHeight,
float stepDownHeight,
Vector3 velocity)
{
if (!ProbeSupportEnabled) return;
float cpZ = float.NaN;
bool cpZDefined = contactPlaneValid
&& TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ);
if (!cpZDefined) cpZ = float.NaN;
float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN;
float terrNz = terrainSampled ? terrainNormal.Z : float.NaN;
string support = ClassifySupport(
contactPlaneValid && cpZDefined,
terrainSampled,
cpZ,
cpNz,
terrainZ,
terrNz);
float commanded = Vector3.Distance(inPos, targetPos);
float moved = Vector3.Distance(inPos, outPos);
// "The body was told to move and did not." The 1 cm floor is the
// resolver's own no-op scale, not a tuned threshold.
bool stalled = commanded > 0.01f && moved <= 0.01f;
float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN;
float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN;
long now = Environment.TickCount64;
long signature = support.GetHashCode();
signature = signature * 31 + (ok ? 1 : 0);
signature = signature * 31 + (groundedIn ? 1 : 0);
signature = signature * 31 + (contact ? 1 : 0);
signature = signature * 31 + (onWalkable ? 1 : 0);
signature = signature * 31 + (contactPlaneValid ? 1 : 0);
signature = signature * 31 + (stalled ? 1 : 0);
signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f);
signature = signature * 31 + (float.IsNaN(zAboveTerrain)
? 0
: (long)MathF.Floor(zAboveTerrain * 10f));
lock (_supportGate)
{
if (_supportSeen.TryGetValue(moverId, out var prev)
&& prev.Signature == signature
&& now - prev.Ms < 250)
{
return;
}
_supportSeen[moverId] = (now, signature);
}
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " +
"in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " +
"tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " +
"ok={15} cmd={16:F3} moved={17:F3} stalled={18} " +
"groundedIn={19} contact={20} onWalkable={21} " +
"cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " +
"cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " +
"cpZatOut={32:F3} " +
"lkcpValid={33} lkcpNz={34:F4} " +
"terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " +
"terrCell=0x{39:X8} terrWater={40} " +
"zAboveTerr={41:F3} cpAboveTerr={42:F3} " +
"walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " +
"vel=({47:F3},{48:F3},{49:F3})",
moverId, isPlayer, now, support,
inPos.X, inPos.Y, inPos.Z, inCell,
targetPos.X, targetPos.Y, targetPos.Z,
outPos.X, outPos.Y, outPos.Z, outCell,
ok, commanded, moved, stalled,
groundedIn, contact, onWalkable,
contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater,
contactPlaneValid ? contactPlane.Normal.X : float.NaN,
contactPlaneValid ? contactPlane.Normal.Y : float.NaN,
cpNz, cpNz, PhysicsGlobals.FloorZ,
contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ,
cpZ,
lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN,
terrainSampled, terrainZ, terrNz,
terrainSampled && terrNz >= PhysicsGlobals.FloorZ,
terrainCellId, terrainIsWater,
zAboveTerrain, cpAboveTerrain,
walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight,
velocity.X, velocity.Y, velocity.Z));
}
/// <summary>
/// Ask whether <c>[geom]</c> has already been emitted for this GfxObj.
/// The line is a property of the ASSET, not of any moment, so once per
/// process is the whole story and re-emitting it would bury the
/// <c>[support]</c> stream.
/// </summary>
public static bool ShouldLogGeometry(uint gfxObjId)
{
if (!ProbeSupportEnabled) return false;
lock (_supportGate)
{
return _geomSeen.Add(gfxObjId);
}
}
/// <summary>
/// One <c>[geom]</c> line: is this object's collision geometry where its
/// visual geometry is? Caller MUST have claimed the id through
/// <see cref="ShouldLogGeometry"/>.
///
/// <para>
/// The verdict vocabulary, and what each one settles:
/// <list type="bullet">
/// <item><c>no-physics-bsp</c> / <c>empty-physics-bsp</c> — the object
/// has no collision polygons at all. Everything a body does around it
/// follows from that one fact and no movement-side theory is needed.</item>
/// <item><c>no-visual-bounds</c> — the comparison could not be made. Said
/// out loud rather than silently treated as agreement.</item>
/// <item><c>displaced</c> — collision and visual are the same size but
/// sit in different places. This is the working hypothesis, and this
/// token is the only thing that confirms it.</item>
/// <item><c>extent-mismatch</c> — same place, different size.</item>
/// <item><c>coincident</c> — collision and visual agree. This REFUTES the
/// working hypothesis for this object, and the cause is then on the
/// movement side (terrain support, or the transition itself).</item>
/// </list>
/// </para>
/// </summary>
public static void LogGeometry(
uint gfxObjId,
uint entityId,
int bspNodeCount,
int bspPolygonCount,
int bspVertexCount,
Vector3 rootSphereOrigin,
float rootSphereRadius,
bool physicsBoundsValid,
Vector3 physicsMin,
Vector3 physicsMax,
bool visualBoundsValid,
Vector3 visualMin,
Vector3 visualMax,
float visualRadius,
Vector3 entityWorldPosition,
float entityScale,
float registeredRadius)
{
Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero;
Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero;
Vector3 physCentre = physicsBoundsValid
? (physicsMin + physicsMax) * 0.5f
: Vector3.Zero;
Vector3 visCentre = visualBoundsValid
? (visualMin + visualMax) * 0.5f
: Vector3.Zero;
float centreDelta = physicsBoundsValid && visualBoundsValid
? Vector3.Distance(physCentre, visCentre)
: float.NaN;
// Tolerances are deliberately loose: this line answers "same place,
// same size?" at the scale of a rock formation, not to the millimetre.
// A physics hull is a coarse stand-in for the render mesh, so a
// half-metre of centre drift or a 2x extent ratio is normal; what this
// is looking for is the pathological case.
float centreTolerance = visualBoundsValid
? MathF.Max(0.5f, visualRadius * 0.25f)
: 0.5f;
bool extentMismatch = false;
if (physicsBoundsValid && visualBoundsValid)
{
for (int axis = 0; axis < 3; axis++)
{
float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z;
float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z;
// Flat axes (a floor plate) legitimately have ~0 extent in one
// dimension on both sides; only compare where the visual has
// real size.
if (v < 0.1f) continue;
float ratio = p / v;
if (ratio is < 0.5f or > 2.0f) extentMismatch = true;
}
}
string verdict =
bspNodeCount == 0 ? "no-physics-bsp"
: bspPolygonCount == 0 ? "empty-physics-bsp"
: !visualBoundsValid ? "no-visual-bounds"
: !physicsBoundsValid ? "no-physics-bounds"
: centreDelta > centreTolerance ? "displaced"
: extentMismatch ? "extent-mismatch"
: "coincident";
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " +
"bspNodes={4} bspPolys={5} bspVerts={6} " +
"rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " +
"physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " +
"physExt=({18:F3},{19:F3},{20:F3}) " +
"visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " +
"visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " +
"centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " +
"objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " +
"physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]",
gfxObjId, verdict, entityId, Environment.TickCount64,
bspNodeCount, bspPolygonCount, bspVertexCount,
rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z,
rootSphereRadius, registeredRadius,
physicsMin.X, physicsMin.Y, physicsMin.Z,
physicsMax.X, physicsMax.Y, physicsMax.Z,
physExtent.X, physExtent.Y, physExtent.Z,
visualMin.X, visualMin.Y, visualMin.Z,
visualMax.X, visualMax.Y, visualMax.Z,
visExtent.X, visExtent.Y, visExtent.Z, visualRadius,
centreDelta, centreTolerance, extentMismatch,
entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z,
entityScale,
// Rotation is NOT applied to these two world Z ranges: an
// axis-aligned box is not rotation-invariant, so a rotated object
// would report a box that is merely indicative. Both sides get the
// SAME treatment, so their AGREEMENT (the thing being measured)
// stays exact regardless.
entityWorldPosition.Z + physicsMin.Z * entityScale,
entityWorldPosition.Z + physicsMax.Z * entityScale,
entityWorldPosition.Z + visualMin.Z * entityScale,
entityWorldPosition.Z + visualMax.Z * entityScale));
}
/// <summary>
/// Resolve the collision-vs-visual comparison for one GfxObj straight from
/// the SAME prepared assets the resolver itself queries, and emit its
/// <c>[geom]</c> line. Going through the production accessors is the point:
/// AP-156's lesson was that a probe reading geometry by a second route can
/// report a shape the registry never emitted. Caller MUST have claimed the
/// id through <see cref="ShouldLogGeometry"/>.
///
/// <para>
/// The physics box is measured over the vertices of the polygons the BSP
/// actually indexes, not over the whole polygon table — a table can carry
/// rows no node references, and including those would report collision
/// geometry that no query can ever reach.
/// </para>
/// </summary>
public static void LogGeometryFromAssets(
uint gfxObjId,
uint entityId,
FlatGfxObjCollisionAsset? flat,
GfxObjVisualBounds? visual,
Vector3 entityWorldPosition,
float entityScale,
float registeredRadius)
{
int nodeCount = 0;
int polygonCount = 0;
int vertexCount = 0;
Vector3 rootOrigin = Vector3.Zero;
float rootRadius = 0f;
bool physBoundsValid = false;
var physMin = new Vector3(float.PositiveInfinity);
var physMax = new Vector3(float.NegativeInfinity);
FlatPhysicsBsp? bsp = flat?.PhysicsBsp;
if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0)
{
nodeCount = bsp.Nodes.Length;
rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin;
rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius;
FlatPolygonTable table = bsp.PolygonTable;
foreach (FlatPhysicsBspNode node in bsp.Nodes)
{
FlatIndexRange range = node.PolygonIndexRange;
for (int i = range.Start; i < range.EndExclusive; i++)
{
int polygonIndex = bsp.PolygonIndexStream[i];
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
polygonCount++;
FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange;
for (int v = vertices.Start; v < vertices.EndExclusive; v++)
{
Vector3 p = table.Vertices[v];
vertexCount++;
physMin = Vector3.Min(physMin, p);
physMax = Vector3.Max(physMax, p);
physBoundsValid = true;
}
}
}
}
if (!physBoundsValid)
{
physMin = Vector3.Zero;
physMax = Vector3.Zero;
}
LogGeometry(
gfxObjId: gfxObjId,
entityId: entityId,
bspNodeCount: nodeCount,
bspPolygonCount: polygonCount,
bspVertexCount: vertexCount,
rootSphereOrigin: rootOrigin,
rootSphereRadius: rootRadius,
physicsBoundsValid: physBoundsValid,
physicsMin: physMin,
physicsMax: physMax,
visualBoundsValid: visual is not null,
visualMin: visual?.Min ?? Vector3.Zero,
visualMax: visual?.Max ?? Vector3.Zero,
visualRadius: visual?.Radius ?? 0f,
entityWorldPosition: entityWorldPosition,
entityScale: entityScale,
registeredRadius: registeredRadius);
}
/// <summary>
/// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic).
/// Emits one <c>[tp-probe]</c> line per teleport-pipeline event with a
@ -1586,6 +2131,14 @@ public static class PhysicsDiagnostics
_reachSeenObj.Clear();
_reachSeenQuery.Clear();
}
ProbeSupportEnabled = false;
_contactPlaneSourceMember = null;
_contactPlaneSourceLine = 0;
lock (_supportGate)
{
_supportSeen.Clear();
_geomSeen.Clear();
}
ProbeTeleportEnabled = false;
ProbeRemoteTeleportEnabled = false;
ProbeRemoteLandingEnabled = false;

View file

@ -1913,6 +1913,15 @@ public sealed class PhysicsEngine
? PhysicsResolveCapture.Snapshot(body)
: null;
// #337 (2026-08-06 — TEMPORARY): arm the [support] probe's
// contact-plane provenance latch for this resolve, ahead of everything
// including the carried-plane seed below. The seed is itself one of
// the ten sites that assert a plane, so it stamps its own name and a
// capture can read `cpSrc=ResolveWithTransition:<line>` as "carried
// from the body, nothing re-derived it this resolve" without needing a
// sentinel value for that case. No-op when the probe is off.
PhysicsDiagnostics.BeginContactPlaneAttribution();
var transition = RentTransition();
try
{
@ -2299,6 +2308,67 @@ public sealed class PhysicsEngine
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
}
// #337 [support] probe (2026-08-06 — TEMPORARY, strip with the
// physics-probe family). Runs for EVERY body, not just the player:
// a corpse sinking through geometry is a plain physics body with
// no player-specific logic, so it is the cheapest possible control
// on whether the movement code or the geometry is at fault, and it
// is invisible to any player-filtered probe.
//
// The terrain sample below is INDEPENDENT of whatever the sweep
// decided — it asks the landblock directly what the ground height
// is under the body's own out-XY. Pairing that with the contact
// plane's height at the same XY is what separates "terrain is
// holding this body up" from "some object surface is". Read-only:
// SampleTerrainWalkable takes no locks, mutates nothing, and is
// not on the resolve's committed path.
if (PhysicsDiagnostics.ProbeSupportEnabled)
{
Vector3 outPos = sp.CheckPos;
TerrainWalkableSample? terrain =
SampleTerrainWalkable(outPos.X, outPos.Y);
bool terrainSampled = terrain.HasValue
&& PhysicsDiagnostics.TryPlaneZAt(
terrain.Value.Plane, outPos.X, outPos.Y, out _);
float terrainZ = float.NaN;
if (terrainSampled)
{
PhysicsDiagnostics.TryPlaneZAt(
terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ);
}
PhysicsDiagnostics.LogSupport(
moverId: movingEntityId,
isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0,
inPos: currentPos,
inCell: cellId,
targetPos: targetPos,
outPos: outPos,
outCell: sp.CheckCellId,
ok: ok,
groundedIn: isOnGround,
contact: transition.ObjectInfo.Contact,
onWalkable: transition.ObjectInfo.OnWalkable,
contactPlaneValid: ci.ContactPlaneValid,
contactPlane: ci.ContactPlane,
contactPlaneCellId: ci.ContactPlaneCellId,
contactPlaneIsWater: ci.ContactPlaneIsWater,
contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource,
lastKnownValid: ci.LastKnownContactPlaneValid,
lastKnownPlane: ci.LastKnownContactPlane,
terrainSampled: terrainSampled,
terrainZ: terrainZ,
terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero,
terrainCellId: terrain?.CellId ?? 0u,
terrainIsWater: terrain?.IsWater ?? false,
walkablePolygon: sp.HasWalkablePolygon,
lastWalkablePolygon: sp.HasLastWalkablePolygon,
stepUpHeight: stepUpHeight,
stepDownHeight: stepDownHeight,
velocity: body?.Velocity ?? Vector3.Zero);
}
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.

View file

@ -443,8 +443,32 @@ public sealed class CollisionInfo
/// </summary>
internal int ContactPlaneWriteCount { get; private set; }
public void SetContactPlane(Plane plane, uint cellId, bool isWater = false)
public void SetContactPlane(
Plane plane,
uint cellId,
bool isWater = false,
// #337 [support] attribution — recorded on PhysicsDiagnostics, not on
// this object; see the comment in the body for why. Compiler-supplied
// literals: no call site passes these explicitly and none needs to.
[System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "",
[System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0)
{
// #337 attribution (2026-08-06 — TEMPORARY, strip with the probe
// family). Recorded ABOVE the no-op guard on purpose: the meaning is
// "the last site that ASSERTED this plane", not "the site that first
// differed from the previous value" — a sweep that re-derives the
// identical plane it was seeded with has still told you where the
// plane comes from, and that is the fact the capture needs.
//
// It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's
// stored members are compared member-for-member by the flat/graph
// differential referee and by the scratch-reset poison test; a
// diagnostic field there is state those oracles must then be taught to
// ignore, which is how a referee stops refereeing. The latch is
// [ThreadStatic] and is armed once per resolve — see
// PhysicsDiagnostics.BeginContactPlaneAttribution.
PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine);
// A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes
// issue #96 (per-tick CP-write blowup) without removing the
// PhysicsEngine.cs L622 seed that step_up depends on. When the
@ -3755,6 +3779,26 @@ public sealed class Transition
foreach (ShadowEntry obj in nearbyObjs.Entries)
{
// #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the
// physics-probe family). Emitted here, at the TOP of the candidate
// loop, so it covers every object the mover comes near regardless
// of what the exemptions and the reach filter later do with it —
// an object whose collision geometry is absent or displaced must
// be reported even when nothing ever tests it. Once per GfxObj per
// process: the line describes an ASSET, not a moment.
if (obj.CollisionType == ShadowCollisionType.BSP
&& PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId))
{
PhysicsDiagnostics.LogGeometryFromAssets(
gfxObjId: obj.GfxObjId,
entityId: obj.EntityId,
flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId),
visual: engine.DataCache.GetVisualBounds(obj.GfxObjId),
entityWorldPosition: obj.Position,
entityScale: obj.Scale,
registeredRadius: obj.Radius);
}
// Self-skip — fix #42 (2026-05-05). Mirrors retail
// CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt
// 308931: `physobj != arg2->object_info.object` rejects the

View file

@ -783,4 +783,52 @@ public static class RenderingDiagnostics
/// </summary>
public static string? FrameHistoryPath { get; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY");
// ── #337 collision-mesh wireframe (2026-08-06 — TEMPORARY) ──────────────
//
// The F2 collision overlay already existed, but for a BSP object it drew a
// proxy cylinder sized from the REGISTERED BROADPHASE RADIUS. That shows
// where the collision system thinks the object roughly is; it cannot show
// where the collision SURFACES are, which is the only thing that answers
// "is the collision geometry where the visual geometry is". The knobs
// below turn F2 into the real answer: the actual physics-BSP polygon
// edges, in world space, next to the same object's visual mesh box.
//
// Off by default, so F2 keeps its old cheap behaviour for anyone who wants
// it and this costs nothing until asked for.
/// <summary>
/// When true, the F2 collision overlay draws each nearby object's REAL
/// physics-BSP polygon edges (cyan) and, beside them, the same object's
/// visual mesh bounding box (magenta), plus the terrain triangle under the
/// player (yellow). Any separation between the cyan surfaces and the
/// object you can see is the "collision is not where the visual is"
/// defect, read directly off the screen instead of inferred from a log.
/// Initial state from <c>ACDREAM_WIRE_MESH=1</c>.
/// TEMPORARY — strip with the #337 probe family.
/// </summary>
public static bool CollisionMeshWireframeEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1";
/// <summary>
/// Radius in metres around the player within which
/// <see cref="CollisionMeshWireframeEnabled"/> resolves polygon geometry.
/// A whole landblock of rock is far more geometry than a line list wants;
/// 30 m covers everything you can wedge against. Override with
/// <c>ACDREAM_WIRE_RADIUS=&lt;metres&gt;</c>.
/// </summary>
public static float CollisionMeshWireframeRadius { get; set; } =
ParsePositiveFloat(
Environment.GetEnvironmentVariable("ACDREAM_WIRE_RADIUS"),
fallback: 30f);
private static float ParsePositiveFloat(string? raw, float fallback)
=> float.TryParse(
raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float value)
&& value > 0f
? value
: fallback;
}

View file

@ -0,0 +1,120 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #337 (2026-08-06 — TEMPORARY, delete with the <c>[support]</c> probe).
///
/// <para>
/// The <c>[support]</c> line's whole value is its <c>support=</c> verdict:
/// terrain, an object surface, or nothing. If that classifier is wrong, a
/// capture does not merely fail to answer — it answers CONFIDENTLY WRONG, and
/// this campaign has already spent two diagnoses on confident wrong answers.
/// These cover the decision boundaries directly, so the live capture can be
/// read at face value.
/// </para>
/// </summary>
public sealed class SupportProbeClassifierTests
{
private const float FlatNormalZ = 1f;
[Fact]
public void NoContactPlane_IsUnsupported()
{
Assert.Equal(
"none",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: false,
terrainSampled: true,
contactPlaneZAtXY: 100f,
contactPlaneNormalZ: FlatNormalZ,
terrainZ: 100f,
terrainNormalZ: FlatNormalZ));
}
[Fact]
public void PlaneAtTerrainHeightAndTilt_IsTerrain()
{
Assert.Equal(
"terrain",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: true,
contactPlaneZAtXY: 41.25f,
contactPlaneNormalZ: 0.94f,
terrainZ: 41.26f,
terrainNormalZ: 0.94f));
}
[Fact]
public void PlaneWellAboveTerrain_IsObject()
{
// The rock-plateau shape: the body rests six metres above the ground.
Assert.Equal(
"object",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: true,
contactPlaneZAtXY: 47.5f,
contactPlaneNormalZ: FlatNormalZ,
terrainZ: 41.5f,
terrainNormalZ: 0.9f));
}
[Fact]
public void SameHeightDifferentTilt_IsReportedSeparately()
{
// A collision surface lying flat against sloped ground. This must NOT
// collapse into either answer: it is precisely the ambiguous case, and
// guessing between them is what the probe exists to avoid.
Assert.Equal(
"coplanar-tilt-mismatch",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: true,
contactPlaneZAtXY: 41.5f,
contactPlaneNormalZ: 1.0f,
terrainZ: 41.5f,
terrainNormalZ: 0.72f));
}
[Fact]
public void NoTerrainUnderTheBody_SaysSoRatherThanGuessing()
{
Assert.Equal(
"no-terrain",
PhysicsDiagnostics.ClassifySupport(
contactPlaneValid: true,
terrainSampled: false,
contactPlaneZAtXY: 12f,
contactPlaneNormalZ: FlatNormalZ,
terrainZ: float.NaN,
terrainNormalZ: float.NaN));
}
[Fact]
public void PlaneHeightIsEvaluatedAtTheBodysOwnXy()
{
// A 45-degree ramp through the origin: height must track X, or a body
// standing on a slope would read as displaced from its own support.
var slope = new Plane(Vector3.Normalize(new Vector3(-1f, 0f, 1f)), 0f);
Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 0f, 0f, out float atOrigin));
Assert.Equal(0f, atOrigin, 3);
Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 10f, 0f, out float atTen));
Assert.Equal(10f, atTen, 3);
}
[Fact]
public void VerticalPlaneHasNoHeight()
{
// A wall is never a floor. Reporting a height for one would read as a
// wildly displaced surface and manufacture a false positive.
var wall = new Plane(new Vector3(1f, 0f, 0f), -5f);
Assert.False(PhysicsDiagnostics.TryPlaneZAt(wall, 0f, 0f, out _));
}
}