From 49a7e906524065abecc8a2c5bd31b74659215acd Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 19:49:59 +0200 Subject: [PATCH] =?UTF-8?q?probe(physics):=20ACDREAM=5FPROBE=5FSUPPORT=20+?= =?UTF-8?q?=20ACDREAM=5FWIRE=5FMESH=20=E2=80=94=20separate=20#337's=20thre?= =?UTF-8?q?e=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 23 + docs/ISSUES.md | 85 +++ .../Rendering/CollisionMeshWireframe.cs | 372 ++++++++++++ .../WorldSceneDiagnosticsController.cs | 60 ++ .../Physics/PhysicsDiagnostics.cs | 553 ++++++++++++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 70 +++ src/AcDream.Core/Physics/TransitionTypes.cs | 46 +- .../Rendering/RenderingDiagnostics.cs | 48 ++ .../Physics/SupportProbeClassifierTests.cs | 120 ++++ 9 files changed, 1376 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/Rendering/CollisionMeshWireframe.cs create mode 100644 tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 28efd094..4c6efe7a 100644 --- a/CLAUDE.md +++ b/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=` sets the window (default 30). + TEMPORARY, with the #337 probe family. - `ACDREAM_CAPTURE_RESOLVE=` — live capture of every player-side `PhysicsEngine.ResolveWithTransition` call. Each call appends one JSON Lines record with full inputs, PhysicsBody snapshot before AND diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fa262dd4..b43498d7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/src/AcDream.App/Rendering/CollisionMeshWireframe.cs b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs new file mode 100644 index 00000000..5954e4a8 --- /dev/null +++ b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs @@ -0,0 +1,372 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +/// +/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337 +/// probe family). +/// +/// +/// 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. +/// +/// +/// +/// This draws the actual geometry instead, in three colours that are meant to +/// be read against each other: +/// +/// Cyan — 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. +/// Magenta — 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. +/// Yellow — 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. +/// +/// Dim orange keeps the old broadphase proxy visible so nothing the previous +/// overlay showed has been taken away. +/// +/// +/// +/// Geometry is resolved through the SAME prepared collision accessors the +/// resolver queries (PhysicsDataCache.GetFlatGfxObj / +/// GetVisualBounds) 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. +/// +/// +/// +/// Pure reads. Nothing here mutates physics, registry, or render state; the +/// caller owns the frame. +/// +/// +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); + + /// + /// 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. + /// + private const int MaxLines = 40_000; + + /// Per-object polygon ceiling, so one enormous formation cannot + /// consume the whole budget and hide every other object near it. + private const int MaxPolygonsPerObject = 4_000; + + /// Half-width in metres of the terrain grid drawn under the + /// player, and its sample spacing. + 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)); + + /// + /// Emit the overlay for everything within + /// of + /// . Returns what it drew so the caller can + /// report a capped frame rather than silently showing partial geometry. + /// + 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); + } + + /// + /// 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. + /// + 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 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; + } + + /// + /// 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. + /// + 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 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 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); + } + } + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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); + } + } + } + } + } + + /// + /// The one placement formula, matching the [resolve-bldg] probe's + /// world transform for a shadow part + /// (TransitionTypes.FindObjCollisionsInCell): scale in the part's + /// own frame, then rotate, then translate to the registered position. + /// + private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow) + => shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation); + + /// + /// 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. + /// + 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; + } + } +} + +/// What one emitted. +/// is the interesting one: a +/// non-zero count means objects near the player carry no reachable collision +/// polygons at all. +internal readonly record struct CollisionMeshWireframeStats( + int ObjectsConsidered, + int PolygonsDrawn, + int ObjectsWithoutPhysicsGeometry, + int LinesDrawn, + bool Capped); diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs index e36f8a0c..e3dbb540 100644 --- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -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); } + /// + /// #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. + /// + 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) diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index e3afc37a..ae785f2e 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -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. + // ----------------------------------------------------------------------- + + /// + /// Initial state from ACDREAM_PROBE_SUPPORT=1. Enables the + /// [support] and [geom] 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. + /// + public static bool ProbeSupportEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1"; + + /// 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. + public const float SupportSameSurfaceZ = 0.05f; + + /// 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. + public const float SupportSameSurfaceNormalZ = 0.02f; + + private static readonly object _supportGate = new(); + private static readonly Dictionary _supportSeen = new(); + private static readonly HashSet _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; + + /// + /// Clear the provenance latch. Call once per resolve, before the sweep, so + /// a resolve that establishes no plane reports none rather than the + /// previous resolve's answer. No-op unless + /// . + /// + public static void BeginContactPlaneAttribution() + { + if (!ProbeSupportEnabled) return; + _contactPlaneSourceMember = null; + _contactPlaneSourceLine = 0; + } + + /// + /// Record the site asserting a contact plane. Called by + /// CollisionInfo.SetContactPlane 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. + /// + public static void RecordContactPlaneSource(string member, int line) + { + if (!ProbeSupportEnabled) return; + _contactPlaneSourceMember = member; + _contactPlaneSourceLine = line; + } + + /// + /// member:line of the last site to assert a contact plane since + /// , or "none". + /// + public static string ContactPlaneSource => + _contactPlaneSourceMember is { Length: > 0 } member + ? string.Concat( + member, + ":", + _contactPlaneSourceLine.ToString( + System.Globalization.CultureInfo.InvariantCulture)) + : "none"; + + /// + /// Classify what is under the body. Pure function so the live probe and any + /// offline reader agree on the vocabulary. + /// + /// none — no contact plane: the body is unsupported. + /// terrain — the contact plane sits at the terrain's height + /// AND shares its tilt. + /// object — the contact plane sits clear of the terrain: some + /// collision surface other than the ground is the support. + /// coplanar-tilt-mismatch — 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. + /// no-terrain — no outdoor terrain under this XY (indoors, + /// or the landblock is not resident): the comparison is unavailable + /// and is said so rather than defaulted. + /// + /// + 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"; + } + + /// + /// Evaluate a plane's height at a given XY. Returns + /// 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. + /// + 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; + } + + /// + /// One [support] line. Self-guards on + /// . + /// + /// + /// 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. + /// + /// + /// + /// is never omitted: + /// feedback_probe_identity_attribution — a per-entity probe without + /// an identity produced a wrong root cause once already, and this capture + /// deliberately covers several bodies at once. + /// + /// + 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)); + } + + /// + /// Ask whether [geom] 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 + /// [support] stream. + /// + public static bool ShouldLogGeometry(uint gfxObjId) + { + if (!ProbeSupportEnabled) return false; + lock (_supportGate) + { + return _geomSeen.Add(gfxObjId); + } + } + + /// + /// One [geom] line: is this object's collision geometry where its + /// visual geometry is? Caller MUST have claimed the id through + /// . + /// + /// + /// The verdict vocabulary, and what each one settles: + /// + /// no-physics-bsp / empty-physics-bsp — 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. + /// no-visual-bounds — the comparison could not be made. Said + /// out loud rather than silently treated as agreement. + /// displaced — 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. + /// extent-mismatch — same place, different size. + /// coincident — 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). + /// + /// + /// + 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)); + } + + /// + /// Resolve the collision-vs-visual comparison for one GfxObj straight from + /// the SAME prepared assets the resolver itself queries, and emit its + /// [geom] 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 . + /// + /// + /// 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. + /// + /// + 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); + } + /// /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). /// Emits one [tp-probe] 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; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 74f33484..4668b1f1 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -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:` 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. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 34b5da29..ac3632c8 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -443,8 +443,32 @@ public sealed class CollisionInfo /// 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 diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index e459fb1d..ae40f920 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -783,4 +783,52 @@ public static class RenderingDiagnostics /// 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. + + /// + /// 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 ACDREAM_WIRE_MESH=1. + /// TEMPORARY — strip with the #337 probe family. + /// + public static bool CollisionMeshWireframeEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1"; + + /// + /// Radius in metres around the player within which + /// 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 + /// ACDREAM_WIRE_RADIUS=<metres>. + /// + 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; } diff --git a/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs b/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs new file mode 100644 index 00000000..742164b1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs @@ -0,0 +1,120 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #337 (2026-08-06 — TEMPORARY, delete with the [support] probe). +/// +/// +/// The [support] line's whole value is its support= 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. +/// +/// +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 _)); + } +}