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:
parent
13fcf38138
commit
49a7e90652
9 changed files with 1376 additions and 1 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
372
src/AcDream.App/Rendering/CollisionMeshWireframe.cs
Normal file
372
src/AcDream.App/Rendering/CollisionMeshWireframe.cs
Normal 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);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<metres></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;
|
||||
}
|
||||
|
|
|
|||
120
tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs
Normal file
120
tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs
Normal 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 _));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue