diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b43498d7..9311bf3d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,9 +24,29 @@ 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 +## #337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — DIAGNOSED, fix not landed -**Status:** OPEN — instrumented, not diagnosed. Awaiting the live capture below. +**Status:** OPEN — **mechanism proven offline 2026-08-06**, fix proposed and +awaiting approval. It is **#333**: the per-object broadphase in +`Transition.FindObjCollisionsInCell` measures to the shadow entry's part +ORIGIN and compares against the BSP ROOT BOUNDING SPHERE's radius. Those are +23.6 m apart for `0xC8766009` / `gfx=0x01004751`, so a mover on the plateau is +rejected before the query it would have passed. Retail has no such filter +(`CPartArray::FindObjCollisions` @0x00518180 and +`CPhysicsPart::find_obj_collisions` @0x0050d8d0 verified instruction-by- +instruction on the PDB-paired binary). `0xC8766002`, the owner with 11,014 +`tested-ok` and zero hits, is **innocent** — its geometry is 22.8 m away. +Full evidence + the proposed fix: +[`docs/research/2026-08-06-337-neftet-wedge-mechanism.md`](research/2026-08-06-337-neftet-wedge-mechanism.md). +Reproducer + offline replay: +`tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs` +(the acceptance gate is the skipped +`TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`). + +**Historical framing below is superseded by that document** — in particular +"the mesh never collides" was true only of the innocent neighbour, and both +the wrong-transform and BSP-traversal-hole hypotheses are refuted by +measurement. **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. diff --git a/docs/research/2026-08-06-337-neftet-wedge-mechanism.md b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md new file mode 100644 index 00000000..54cad1ec --- /dev/null +++ b/docs/research/2026-08-06-337-neftet-wedge-mechanism.md @@ -0,0 +1,232 @@ +# #337 — the Neftet plateau wedge: mechanism, measured + +**Date:** 2026-08-06 +**Status:** mechanism proven offline; fix proposed, NOT landed. +**Reproducer:** `tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs` +**Related:** #333 (filed: the broadphase reach filter has AP-156's defect at +the query site), #334 (`13fcf381`, registration extent walk), AP-156 +(`b52967de`, flood sphere placement). + +--- + +## Verdict in one paragraph + +The collision mesh is present, correctly shaped, correctly placed in the +world, and the BSP traversal reaches every part of it. **The mover never gets +as far as the query.** `Transition.FindObjCollisionsInCell`'s per-object +broadphase measures the mover's distance to the shadow entry's `Position` — +the part **origin** — and compares it against `obj.Radius`, which is the +physics-BSP **root bounding sphere's radius**. For this rock those two points +are 23.6 m apart, so a mover standing on the plateau is inside the real +bounding sphere by ~20 m of margin and is still rejected. Retail has no such +filter at all. Everything else in the symptom set — the wedge, the sink, the +corpse fall-through, ACE's spawn refusal, the 16,278 m/s rejection, the +character vanishing from a retail observer's view — is downstream of that one +rejection. + +--- + +## What was refuted, and by what measurement + +### 1. "The rock's own mesh never collides" — TRUE for `0xC8766002`, and it is INNOCENT + +`0xC8766002` / `gfx=0x010046DE` really does report 11,014 `tested-ok` with +zero `tested-adjusted`, `tested-collided` or `tested-slid` across the whole +capture. That is correct behaviour: **its geometry is 22.8 m away from the +wedge point.** Over a 30 × 30 × 12 m lattice covering the whole plateau +(12,493 points) it has **zero** brute-force hits — it simply is not there. + +It is a candidate in that cell only because it is a 130 × 147 m owner whose +registration legitimately spans the cell. The probe families that reach it are +working exactly as designed. This object was a red herring. + +The neighbours do collide: `0xC8766003` 767 adjusted / 26 collided, +`0xC8766009` 1,135 adjusted / 708 slid / 58 collided. The rock the player +actually walks on is **`0xC8766009` / `gfx=0x01004751`**. + +### 2. Wrong world transform — REFUTED + +The offline reconstruction from the installed DAT reproduces the runtime +placement exactly: `0xC8766002` at `(84.699, 100.082, 13.000)` yaw −45.00° +against the live `[geom]` line's `objPos=(84.70,100.08,13.00)` and its +`bspCentreOffset` (which implies −45.00°). All eleven owners match. The +transform is right. + +### 3. BSP traversal hole / bounding-sphere early-out — REFUTED + +A referee ran the production walk (`FlatBspQuery.SphereIntersectsPoly`, +node bounding-sphere early-outs and all) against brute force over every +polygon the tree indexes, at 7,770 probe points placed on both faces of every +polygon of all eleven owners, plus 137,423 lattice points over the plateau. + +**Mismatch = 0 everywhere.** There is no pocket the traversal cannot reach. + +### 4. Missing / degenerate geometry, a hole in the walking surface — REFUTED + +A 0.5 m hole map over the plateau shows continuous upward-facing physics +coverage across the whole wedge region; the only gaps are outside the rock's +footprint. At the wedge XY the column is closed: an up-facing polygon at +z = 50.985 (nz = +0.9805) over a down-facing one at z = 40.597 (nz = −0.9325), +i.e. 10.4 m of solid rock, with the player's feet at z = 50.011 — **0.975 m +inside it.** + +### 5. `[geom] verdict=coincident` — confirmed to mean less than it looks + +`LogGeometry` compares `physicsMin/Max` against `visualMin/Max`, both taken +from the same GfxObj asset in the object's **own local frame**. No world +transform enters the comparison. `coincident` proves shape agreement only. +It happens to be true here, but it could never have been the discriminator. + +--- + +## The mechanism, measured + +### The frame-by-frame (337-support.log, cell `0x8766002B`) + +Landblock-local coordinates; the log's Y carries a +576 m live-centre offset, +removed here. Player feet position; Setup `0x02000001` gives sphere[0] at +feet + 0.475 with r = 0.480 and sphere[1] at feet + 1.350. + +| t | feet z | surface z at that XY | what happened | +|---|---|---|---| +| …218906–219296 | 51.096 | 51.081 | standing correctly (delta +0.015). **Every horizontal move of 0.25–0.53 m returns `moved=0.000, stalled=True`** on an 11° walkable slope. This is the wedge. | +| 219328 | 51.096 → 51.389 | | player jumps to escape | +| …–219937 | → 54.638 | | free rise, `support=none` | +| …–221125 | 54.638 → 50.021 | 51.183 | **falls straight through the surface.** Six consecutive resolves accept the full commanded step with no contact plane. | +| 221156 | 50.021 | | descent finally blocked, still no contact plane | +| 221671 | 49.908 | 51.127 | `cpSrc=ValidateTransition:6076` — retail's stationary-fall failsafe **manufactures** a flat plane at z = 49.903, 1.2 m below the real rock surface | + +The `support=object cpNz=1.0000` readings inside the rock are not the rock. +`ValidateTransition:6076` is retail's `FramesStationaryFall > 1` synthetic +up-plane through the sphere bottom; `ValidateTransition:5997` is retail's +`LastKnownContactPlane` restore, i.e. a **stale** plane retained from frames +when the object was still admitted. Both are retail-correct responses to a +stuck body — which is why the client believes it is standing while ACE +believes the position is invalid. + +### The replay: the query would have hit + +`Issue337NeftetRockGeometryInspectionTests.ReplayTheDescentThatFellThrough…` +runs each of those six descent steps as a swept sphere against `0xC8766009`'s +real BSP: + +``` +CONTROL climb step ent=0xC8766009 sphere[0] swept=True(poly 22) static=True nearest=0.070 m +CONTROL stand step ent=0xC8766009 sphere[0] swept=True(poly 26) static=True nearest=0.003 m +FALL 2 51.269->50.961 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.248 m +FALL 3 50.961->50.670 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.037 m +FALL 4 50.670->50.335 sphere[0] swept=True(poly 31 t=0.0000) static=True nearest=0.366 m +``` + +The geometry is right there and the production primitive returns the hit. +Path 6 of `FlatBspQuery.FindCollisionsCore` (the airborne dispatch) would have +called `path.SetCollide(...)` and returned `Adjusted`, blocking the fall. +**It was never called.** + +### Why it was never called + +`Transition.FindObjCollisionsInCell` (`src/AcDream.Core/Physics/TransitionTypes.cs`, +the `maxReach` test in the candidate loop): + +```csharp +Vector3 deltaToCurr = currPos - obj.Position; // ← part ORIGIN +... +float maxReach = sphereRadius + obj.Radius // ← BSP ROOT SPHERE radius + + movement.Length() + 2f; +if (distToCurr > maxReach) continue; +``` + +For `0xC8766009`: origin `(159.107, 36.629, 0.005)`, root bounding sphere +centred at local `(−1.753, 14.259, 18.667)` with radius 56.909 — i.e. the +sphere centre sits **23.556 m** from the origin the filter measures to. + +At the fall position, measured (reproducer output): + +``` +distToOrigin = 60.434 m > maxReach = 59.697 m → REJECTED +distance to the BSP bounding-sphere CENTRE = 37.083 m ≪ 56.909 m radius +``` + +The live capture recorded the same thing 7,225 times for this owner, and the +probe's own `wouldAcceptAtCenter` column says `True` on **every** rejection: + +``` +currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=59.64 shortfall=+0.60 acceptAtCentre=True move=0.255 +currPos=(126.57,36.59,50.71) distOrigin=60.24 budget=60.10 shortfall=+0.14 acceptAtCentre=True move=0.715 +``` + +### Why it is bounded, and why jumping over it works + +The dead zone is the shell between `distOrigin = maxReach` and the true +bounding sphere. Because the sphere centre is offset 23.556 m from the origin, +that shell is up to ~23.5 m thick on the far side of the object — here it +covers the top of the plateau and nothing else. Everywhere closer to the +origin, the same mesh collides normally. That is the "fails in a bounded +region, works elsewhere on the same object" property. + +`movement.Length()` is a term in the budget. A walking step of 0.25 m gives +shortfall +0.60 (rejected); a step of 0.72 m gives +0.14; a step of ~0.86 m +passes. **A jump's larger per-frame movement inflates the budget and lets the +object back through the filter.** That is why jumping over the spot works and +walking into it does not, and why a corpse — small per-frame movement — falls +straight through. + +--- + +## Retail + +There is no per-object distance filter on retail's BSP branch. Verified +instruction-by-instruction with cdb against the PDB-paired v11.4186 binary +(`C:\Users\erikn\Downloads\acclient.exe`, `check_exe_pdb.py` → `MATCH`, +GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`): + +- `acclient!CPartArray::FindObjCollisions` @ **0x00518180** — 14 instructions: + a bare `do/while` over `parts[i]` calling `CPhysicsPart::find_obj_collisions` + and breaking on `!= OK`. No compare, no float math. +- `acclient!CPhysicsPart::find_obj_collisions` @ **0x0050d8d0** — 17 + instructions: null-check `gfxobj`, null-check `gfxobj->physics_bsp` + (`[ecx+78h]`), `SPHEREPATH::cache_localspace_sphere`, + `CGfxObj::find_obj_collisions` @ 0x00534700. No compare, no float math. + +`CPhysicsObj::FindObjCollisions` @ 0x0050f050 reaches that pair through +`CPartArray::FindObjCollisions` at 0x0050f192; its cylsphere/sphere loops +(0x0050f1c4 / 0x0050f251) call the real `intersects_sphere` per primitive — +they are tests, not pre-filters. Retail's only spatial rejection is the BSP +node bounding-sphere test inside the walk, which is correctly centred. + +The in-tree comment claiming the filter is "the analog of the part +sorting-sphere early-outs inside retail's `CPhysicsObj::FindObjCollisions` — +response-neutral, pure perf" is **wrong on both counts.** + +--- + +## Proposed fix + +**Preferred — remove the per-object distance pre-check for BSP entries.** +Retail has none, and the BSP walk's own root node bounding-sphere test is the +correctly-centred early-out that makes it unnecessary. Size: delete ~10 lines +in `Transition.FindObjCollisionsInCell` plus the probe's `rejected-reach` +branch; correct the false retail-analog comment in the same commit. No +divergence-register row is created; if #333's row exists it is deleted. + +**Fallback if a perf gate demands a filter** — measure to the bounding-sphere +centre, the AP-156 correction applied at the query site: +`obj.Position + Vector3.Transform(BoundsCenter * Scale, obj.Rotation)`. +`ShadowShape.BoundsCenter` already carries this value; `ShadowEntry` does not, +so this variant also touches `ShadowEntry` and both registration paths +(`Register` and `RegisterMultiPart`). Larger, and it keeps a non-retail +construct that then needs a register row. + +Acceptance gate: un-skip +`Issue337NeftetRockGeometryInspectionTests.TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn`. +Verified to fail today with the numbers above. + +--- + +## Separate observation, not part of this defect + +Setup `0x02000001` authors `StepUpHeight = 0.600` and +`StepDownHeight = 1.500`. The live `[support]` lines show the player resolving +with `stepUp=0.400 stepDown=0.400`. A 1.5 m step-down is what keeps a mover +attached to a descending slope; 0.4 m is not. Worth its own investigation — +it does not cause this wedge, and it was not chased here. diff --git a/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs new file mode 100644 index 00000000..0bc8d4d8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs @@ -0,0 +1,928 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Text; +using AcDream.Core.Physics; +using AcDream.Core.Tests.Conformance; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #337 offline replay (TEMPORARY — strip with the physics-probe family). +/// +/// +/// Reconstructs, from the installed DAT alone, the exact world placement of +/// every landblock-static collision owner in Neftet landblock 0x8766, and +/// asks the one question [geom] cannot answer: the [geom] probe +/// compares the physics-BSP vertex cloud against the visual mesh box in the +/// object's OWN LOCAL FRAME, so verdict=coincident proves only that the +/// two agree on SHAPE. It says nothing about WHERE the object sits in the +/// world. This test places the geometry in the world and measures. +/// +/// +/// +/// Live measurement being replayed (337-support.log / 334-fix-gate.log): +/// the player wedges at (134.314, 55.716, 50.011) in cell 0x8766002B, and +/// owner 0xC8766002 / gfx 0x010046DE was tested 11,014 times there with +/// 0 adjusted, 0 collided, 0 slid — while its two neighbours 0xC8766003 and +/// 0xC8766009 adjusted 767 and 1,135 times against the same mover. +/// +/// +/// Dat-data dependent; SKIPs cleanly without ACDREAM_DAT_DIR. +/// +public sealed class Issue337NeftetRockGeometryInspectionTests +{ + private const uint Landblock = 0x8766_0000u; + private const uint GfxObjMask = 0x01000000u; + private const uint SetupMask = 0x02000000u; + private const uint TypeMask = 0xFF000000u; + + /// The wedge point, landblock-local, from the live capture. + private static readonly Vector3 Wedge = new(134.313934f, 55.716248f, 50.010666f); + + private readonly ITestOutputHelper _out; + + public Issue337NeftetRockGeometryInspectionTests(ITestOutputHelper o) => _out = o; + + private sealed record Placed( + uint EntityId, + uint DatId, + uint GfxObjId, + Vector3 Position, + Quaternion Rotation, + List WorldPolygons, + List LocalPolygons, + FlatPhysicsBsp Bsp, + Vector3 WorldMin, + Vector3 WorldMax, + int BspNodes, + int BspPolys); + + [Fact] + public void DumpNeftetLandblockStaticsAndProbeTheWedgePoint() + { + System.Globalization.CultureInfo.CurrentCulture = + System.Globalization.CultureInfo.InvariantCulture; + + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + + var sb = new StringBuilder(); + sb.AppendLine(Inv( + $"landblock 0x{Landblock:X8}: Objects={info!.Objects.Count} Buildings={info.Buildings.Count}")); + + uint lbX = (Landblock >> 24) & 0xFFu; + uint lbY = (Landblock >> 16) & 0xFFu; + uint counter = 0; + + var cache = new PhysicsDataCache(); + var placed = new List(); + + foreach (var stab in info.Objects) + { + if (!IsSupported(stab.Id)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + placed.AddRange(Place(dats, cache, entityId, stab.Id, + stab.Frame.Origin, stab.Frame.Orientation)); + } + + foreach (var building in info.Buildings) + { + if (!IsSupported(building.ModelId)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + placed.AddRange(Place(dats, cache, entityId, building.ModelId, + building.Frame.Origin, building.Frame.Orientation)); + } + + sb.AppendLine($"placed collision parts: {placed.Count}"); + sb.AppendLine(); + sb.AppendLine("--- every part: world box, and the wedge point in ITS OWN local frame ---"); + foreach (var p in placed.OrderBy(p => p.EntityId).ThenBy(p => p.GfxObjId)) + { + Vector3 local = Vector3.Transform( + Wedge - p.Position, Quaternion.Inverse(p.Rotation)); + bool inWorldBox = Inside(p.WorldMin, p.WorldMax, Wedge); + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} dat=0x{p.DatId:X8} gfx=0x{p.GfxObjId:X8} " + + $"pos=({p.Position.X:F3},{p.Position.Y:F3},{p.Position.Z:F3}) " + + $"rot=({p.Rotation.W:F4},{p.Rotation.X:F4},{p.Rotation.Y:F4},{p.Rotation.Z:F4}) " + + $"yawDeg={YawDegrees(p.Rotation):F2} " + + $"nodes={p.BspNodes} polys={p.BspPolys} " + + $"worldMin=({p.WorldMin.X:F2},{p.WorldMin.Y:F2},{p.WorldMin.Z:F2}) " + + $"worldMax=({p.WorldMax.X:F2},{p.WorldMax.Y:F2},{p.WorldMax.Z:F2}) " + + $"wedgeLocal=({local.X:F2},{local.Y:F2},{local.Z:F2}) " + + $"wedgeInWorldBox={inWorldBox}")); + } + + sb.AppendLine(); + sb.AppendLine(Inv( + $"--- vertical column through the wedge XY ({Wedge.X:F3},{Wedge.Y:F3}) ---")); + sb.AppendLine("(every physics polygon whose WORLD XY projection contains that point,"); + sb.AppendLine(" with the Z of the polygon's plane at that XY — this is the surface"); + sb.AppendLine(" a falling body would land on and the ceiling it would be under)"); + + var hits = new List<(uint Ent, uint Gfx, float Z, float Nz, int PolyIndex)>(); + foreach (var p in placed) + { + for (int i = 0; i < p.WorldPolygons.Count; i++) + { + Vector3[] poly = p.WorldPolygons[i]; + if (!XyContains(poly, Wedge.X, Wedge.Y)) continue; + if (!TryPlaneZ(poly, Wedge.X, Wedge.Y, out float z, out float nz)) continue; + hits.Add((p.EntityId, p.GfxObjId, z, nz, i)); + } + } + + foreach (var h in hits.OrderByDescending(h => h.Z)) + { + sb.AppendLine(Inv( + $" z={h.Z,9:F3} normalZ={h.Nz,7:F4} ent=0x{h.Ent:X8} gfx=0x{h.Gfx:X8} poly#{h.PolyIndex}")); + } + if (hits.Count == 0) + sb.AppendLine(" (no physics polygon covers that XY at all)"); + + sb.AppendLine(); + sb.AppendLine(Inv( + $"player sphere centre is at z={Wedge.Z:F3}")); + var above = hits.Where(h => h.Z > Wedge.Z).OrderBy(h => h.Z).ToList(); + var below = hits.Where(h => h.Z <= Wedge.Z).OrderByDescending(h => h.Z).ToList(); + sb.AppendLine(Inv( + $" nearest surface BELOW: {(below.Count == 0 ? "none" : $"z={below[0].Z:F3} ent=0x{below[0].Ent:X8} gfx=0x{below[0].Gfx:X8} (gap {Wedge.Z - below[0].Z:F3} m)")}")); + sb.AppendLine(Inv( + $" nearest surface ABOVE: {(above.Count == 0 ? "none" : $"z={above[0].Z:F3} ent=0x{above[0].Ent:X8} gfx=0x{above[0].Gfx:X8} (gap {above[0].Z - Wedge.Z:F3} m)")}")); + + sb.AppendLine(); + sb.AppendLine("--- nearest physics polygon to the wedge point, per owner ---"); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + float best = float.MaxValue; + int bestIndex = -1; + for (int i = 0; i < p.WorldPolygons.Count; i++) + { + float d = DistanceToPolygon(p.WorldPolygons[i], Wedge); + if (d < best) { best = d; bestIndex = i; } + } + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} nearestPolyDist={best:F3} m (poly#{bestIndex})")); + } + + // ------------------------------------------------------------------ + // REFEREE. Two independent answers to the SAME question, per owner: + // (a) the production BSP walk, FlatBspQuery.SphereIntersectsPoly — + // the Path-5 static-overlap primitive the resolver actually runs, + // node bounding-sphere early-outs and all; + // (b) brute force over every polygon the tree indexes. + // They must agree. Where (b) says "a polygon is inside the sphere" and + // (a) says "no", the geometry is present and the TRAVERSAL cannot reach + // it — a bounded pocket, which is exactly the reported symptom. Where + // both say no, the mesh genuinely is not there and the owner is + // innocent. + // + // Sample points are taken ON the mesh (vertices + centroids, nudged + // both ways along the polygon normal) so the referee covers the whole + // surface rather than an arbitrary lattice, and any disagreement + // localises to a named polygon. + // ------------------------------------------------------------------ + const float SphereRadius = 0.48f; // the live mover's radius + sb.AppendLine(); + sb.AppendLine(Inv( + $"--- referee: BSP walk vs brute force, sphere r={SphereRadius:F3} ---")); + + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + int samples = 0, mismatch = 0, bothHit = 0; + var mmMin = new Vector3(float.PositiveInfinity); + var mmMax = new Vector3(float.NegativeInfinity); + var firstMismatches = new List(); + + foreach (Vector3 probe in SurfaceProbes(p.LocalPolygons, SphereRadius)) + { + samples++; + bool walk = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, probe, SphereRadius, out _, out _); + bool brute = BruteHit(p.LocalPolygons, probe, SphereRadius); + if (walk == brute) { if (walk) bothHit++; continue; } + mismatch++; + Vector3 world = p.Position + Vector3.Transform(probe, p.Rotation); + mmMin = Vector3.Min(mmMin, world); + mmMax = Vector3.Max(mmMax, world); + if (firstMismatches.Count < 8) + { + firstMismatches.Add(Inv( + $" local=({probe.X:F2},{probe.Y:F2},{probe.Z:F2}) " + + $"world=({world.X:F2},{world.Y:F2},{world.Z:F2}) " + + $"walk={walk} brute={brute}")); + } + } + + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} polys={p.BspPolys} " + + $"samples={samples} bothHit={bothHit} MISMATCH={mismatch}")); + if (mismatch > 0) + { + sb.AppendLine(Inv( + $" mismatch world box=({mmMin.X:F2},{mmMin.Y:F2},{mmMin.Z:F2})..({mmMax.X:F2},{mmMax.Y:F2},{mmMax.Z:F2})")); + foreach (string line in firstMismatches) sb.AppendLine(line); + } + } + + // ------------------------------------------------------------------ + // The exact live query, replayed. The wedge point, plus every point on + // a 1 m lattice through the plateau, both answers side by side. + // ------------------------------------------------------------------ + sb.AppendLine(); + sb.AppendLine("--- the live wedge query, replayed per owner ---"); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + Vector3 local = Vector3.Transform( + Wedge - p.Position, Quaternion.Inverse(p.Rotation)); + bool walk = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, local, SphereRadius, out ushort id, out _); + bool brute = BruteHit(p.LocalPolygons, local, SphereRadius); + float nearest = float.MaxValue; + foreach (var poly in p.LocalPolygons) + nearest = MathF.Min(nearest, DistanceToPolygon(poly, local)); + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} " + + $"walkHit={walk} (poly {id}) bruteHit={brute} nearestPoly={nearest:F3} m")); + } + + sb.AppendLine(); + sb.AppendLine("--- 1 m lattice over the plateau (x 120..150, y 40..70, z 44..56) ---"); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + int lat = 0, latWalk = 0, latBrute = 0, latMismatch = 0; + var missMin = new Vector3(float.PositiveInfinity); + var missMax = new Vector3(float.NegativeInfinity); + for (float x = 120f; x <= 150f; x += 1f) + for (float y = 40f; y <= 70f; y += 1f) + for (float z = 44f; z <= 56f; z += 1f) + { + var world = new Vector3(x, y, z); + Vector3 local = Vector3.Transform( + world - p.Position, Quaternion.Inverse(p.Rotation)); + lat++; + bool walk = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, local, SphereRadius, out _, out _); + bool brute = BruteHit(p.LocalPolygons, local, SphereRadius); + if (walk) latWalk++; + if (brute) latBrute++; + if (walk == brute) continue; + latMismatch++; + missMin = Vector3.Min(missMin, world); + missMax = Vector3.Max(missMax, world); + } + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} gfx=0x{p.GfxObjId:X8} points={lat} " + + $"walkHits={latWalk} bruteHits={latBrute} MISMATCH={latMismatch}" + + (latMismatch > 0 + ? $" box=({missMin.X:F1},{missMin.Y:F1},{missMin.Z:F1})..({missMax.X:F1},{missMax.Y:F1},{missMax.Z:F1})" + : string.Empty))); + } + + // ------------------------------------------------------------------ + // The recorded live track, replayed. Each row is a position the client + // actually held, taken from 337-support.log; the Y values on the climb + // rows carry the +576 m live-centre offset that log was written in + // (live centre 0x8763, three landblocks south of 0x8766), removed here. + // For each, the walkable surface directly above/below it. + // ------------------------------------------------------------------ + sb.AppendLine(); + sb.AppendLine("--- recorded live positions vs the surface at their own XY ---"); + (string Tag, Vector3 P, float CpNz)[] track = + [ + ("climb cpNz=0.7401", new Vector3(185.53f, 617.52f - 576f, 3.839f), 0.7401f), + ("climb cpNz=0.7078", new Vector3(172.63f, 600.23f - 576f, 17.910f), 0.7078f), + ("climb cpNz=0.8640", new Vector3(154.04f, 596.93f - 576f, 32.543f), 0.8640f), + ("climb cpNz=0.8216", new Vector3(143.69f, 611.83f - 576f, 43.352f), 0.8216f), + ("climb cpNz=0.9532", new Vector3(138.38f, 621.53f - 576f, 48.567f), 0.9532f), + ("STALL cpNz=0.9805", new Vector3(130.81f, 625.88f - 576f, 51.097f), 0.9805f), + ("stand cpNz=0.9532", new Vector3(134.08f, 623.63f - 576f, 50.074f), 0.9532f), + ("stand cpNz=0.9532", new Vector3(135.77f, 625.86f - 576f, 49.994f), 0.9532f), + ("stand cpNz=1.0000", new Vector3(131.38f, 627.49f - 576f, 49.908f), 1.0000f), + ("STALL cpNz=0.9805", new Vector3(131.68f, 627.58f - 576f, 51.096f), 0.9805f), + ("WEDGE support=none", Wedge, float.NaN), + ]; + + foreach (var row in track) + { + var col = Column(placed, row.P.X, row.P.Y); + var up = col.Where(c => c.Nz > 0f).OrderByDescending(c => c.Z).ToList(); + var upBelowHead = up.Where(c => c.Z <= row.P.Z + 0.05f).ToList(); + string surf = up.Count == 0 + ? "NO UPWARD SURFACE IN COLUMN" + : Inv($"topUp z={up[0].Z:F3} (ent=0x{up[0].Ent:X8}) delta={row.P.Z - up[0].Z:+0.000;-0.000}"); + sb.AppendLine(Inv( + $" {row.Tag} p=({row.P.X:F2},{row.P.Y:F2},{row.P.Z:F3}) " + + $"colPolys={col.Count} upFacing={up.Count} atOrBelowFeet={upBelowHead.Count} {surf}")); + } + + // ------------------------------------------------------------------ + // HOLE MAP. Over the plateau, at 0.5 m XY resolution, does ANY + // upward-facing physics polygon exist above z=35? A bounded region + // with none is a hole in the rock's walking surface — a body that + // reaches it stops being supported, sinks, and a corpse dropped on it + // falls through, while a jump that clears it lands fine on the far + // side. That is the reported symptom exactly. + // ------------------------------------------------------------------ + sb.AppendLine(); + sb.AppendLine("--- hole map: upward-facing physics coverage over the plateau ---"); + sb.AppendLine(" (0.5 m XY grid, x 118..152, y 38..72; '.'=covered above z=35, " + + "'#'=NO upward surface, '*'=the wedge XY)"); + int covered = 0, holes = 0; + var holeMin = new Vector2(float.PositiveInfinity); + var holeMax = new Vector2(float.NegativeInfinity); + for (float y = 72f; y >= 38f; y -= 0.5f) + { + var line = new StringBuilder(Inv($" y={y,6:F1} ")); + for (float x = 118f; x <= 152f; x += 0.5f) + { + var col = Column(placed, x, y); + bool hasUp = col.Any(c => c.Nz > 0f && c.Z > 35f); + bool isWedge = MathF.Abs(x - Wedge.X) < 0.25f + && MathF.Abs(y - Wedge.Y) < 0.25f; + if (hasUp) covered++; + else + { + holes++; + holeMin = Vector2.Min(holeMin, new Vector2(x, y)); + holeMax = Vector2.Max(holeMax, new Vector2(x, y)); + } + line.Append(isWedge ? '*' : hasUp ? '.' : '#'); + } + sb.AppendLine(line.ToString()); + } + sb.AppendLine(Inv( + $" covered={covered} holes={holes}" + + (holes > 0 + ? $" holeBox=({holeMin.X:F1},{holeMin.Y:F1})..({holeMax.X:F1},{holeMax.Y:F1})" + : string.Empty))); + + string outPath = Path.Combine(Path.GetTempPath(), "issue337-neftet-geometry.txt"); + File.WriteAllText(outPath, sb.ToString()); + _out.WriteLine(sb.ToString()); + _out.WriteLine($"(also written to {outPath})"); + } + + // ---------------------------------------------------------------- helpers + + /// + /// #337 descent replay. The live capture shows the player standing on the + /// plateau at feet z=51.096 (surface ~51.08), jumping to escape a + /// horizontal stall, and then falling STRAIGHT THROUGH that same surface — + /// four consecutive resolves accept a full-length downward step with no + /// contact plane, ending 1.1 m inside solid rock. This replays those exact + /// steps against the exact mesh, with a control step from the climb where + /// the client DID land correctly on the same mesh. + /// + [Fact] + public void ReplayTheDescentThatFellThroughTheSurface() + { + System.Globalization.CultureInfo.CurrentCulture = + System.Globalization.CultureInfo.InvariantCulture; + + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + + uint lbX = (Landblock >> 24) & 0xFFu; + uint lbY = (Landblock >> 16) & 0xFFu; + uint counter = 0; + var cache = new PhysicsDataCache(); + var placed = new List(); + foreach (var stab in info!.Objects) + { + if (!IsSupported(stab.Id)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + placed.AddRange(Place(dats, cache, entityId, stab.Id, + stab.Frame.Origin, stab.Frame.Orientation)); + } + + var sb = new StringBuilder(); + + // The player's verbatim dat sphere list — the same rows + // PhysicsEngine hands to SpherePath.InitPath (TS-46). + var playerSetup = dats.Get(0x0200_0001u); + Assert.NotNull(playerSetup); + FlatSetupCollision playerFlat = + FlatCollisionAssetBuilder.FlattenSetup(playerSetup!); + sb.AppendLine("--- player setup 0x02000001 collision volume ---"); + sb.AppendLine(Inv( + $" height={playerFlat.Height:F3} radius={playerFlat.Radius:F3} " + + $"stepUp={playerFlat.StepUpHeight:F3} stepDown={playerFlat.StepDownHeight:F3} " + + $"spheres={playerFlat.Spheres.Length} cylinders={playerFlat.Cylinders.Length}")); + for (int i = 0; i < playerFlat.Spheres.Length; i++) + { + var s = playerFlat.Spheres[i]; + sb.AppendLine(Inv( + $" sphere[{i}] origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}")); + } + for (int i = 0; i < playerFlat.Cylinders.Length; i++) + { + var c = playerFlat.Cylinders[i]; + sb.AppendLine(Inv( + $" cylsphere[{i}] origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}")); + } + + // Sphere rows actually used. The live reach probe recorded + // sphereR=0.480, so this asserts the replay is using the same volume + // the client used rather than a lookalike. + var rows = playerFlat.Spheres.Length > 0 + ? playerFlat.Spheres.ToArray() + : playerFlat.Cylinders + .Select(c => new FlatCollisionSphere(c.Origin, c.Radius)) + .ToArray(); + + sb.AppendLine(); + sb.AppendLine("--- surface column at the fall XY ---"); + foreach ((string tag, float x, float y) in new[] + { + ("stand-before-jump", 131.683f, 627.581f - 576f), + ("fall/landing ", 131.13f, 627.61f - 576f), + ("final wedge ", 131.38f, 627.49f - 576f), + ("login wedge ", Wedge.X, Wedge.Y), + }) + { + var col = Column(placed, x, y).OrderByDescending(c => c.Z).ToList(); + sb.AppendLine(Inv($" {tag} xy=({x:F3},{y:F3}) polys={col.Count}")); + foreach (var c in col) + { + sb.AppendLine(Inv( + $" z={c.Z,9:F3} nz={c.Nz,8:F4} ent=0x{c.Ent:X8} gfx=0x{c.Gfx:X8}")); + } + } + + // The steps, feet-space, straight from 337-support.log (Y carries the + // +576 m live-centre offset that log was written in). + (string Tag, Vector3 From, Vector3 To, bool ClientHit)[] steps = + [ + // Control: the climb. The client accepted these with a contact + // plane from StepSphereDown, i.e. it DID land on this mesh. + ("CONTROL climb step", new Vector3(143.69f, 611.83f - 576f, 43.352f), + new Vector3(143.69f, 611.83f - 576f, 42.852f), true), + ("CONTROL stand step", new Vector3(135.77f, 625.86f - 576f, 49.994f), + new Vector3(135.77f, 625.86f - 576f, 49.494f), true), + // The failing descent, verbatim, four consecutive resolves. + ("FALL 1 51.389->51.269", new Vector3(131.13f, 627.61f - 576f, 51.389f), + new Vector3(131.13f, 627.61f - 576f, 51.269f), false), + ("FALL 2 51.269->50.961", new Vector3(131.13f, 627.61f - 576f, 51.269f), + new Vector3(131.13f, 627.61f - 576f, 50.961f), false), + ("FALL 3 50.961->50.670", new Vector3(131.13f, 627.61f - 576f, 50.961f), + new Vector3(131.13f, 627.61f - 576f, 50.670f), false), + ("FALL 4 50.670->50.335", new Vector3(131.13f, 627.61f - 576f, 50.670f), + new Vector3(131.13f, 627.61f - 576f, 50.335f), false), + ("FALL 5 50.335->50.021", new Vector3(131.13f, 627.61f - 576f, 50.335f), + new Vector3(131.13f, 627.61f - 576f, 50.021f), false), + ("FALL 6 50.021->49.656 (client BLOCKED here)", + new Vector3(131.13f, 627.61f - 576f, 50.021f), + new Vector3(131.13f, 627.61f - 576f, 49.656f), false), + ]; + + sb.AppendLine(); + sb.AppendLine("--- swept-sphere descent replay against every owner ---"); + foreach (var step in steps) + { + sb.AppendLine(Inv( + $" {step.Tag} (client recorded a contact plane: {step.ClientHit})")); + foreach (var p in placed.OrderBy(p => p.EntityId)) + { + var invRot = Quaternion.Inverse(p.Rotation); + for (int i = 0; i < rows.Length; i++) + { + Vector3 worldFrom = step.From + rows[i].Origin; + Vector3 worldTo = step.To + rows[i].Origin; + Vector3 localTo = Vector3.Transform(worldTo - p.Position, invRot); + Vector3 localFrom = Vector3.Transform(worldFrom - p.Position, invRot); + Vector3 localMove = localTo - localFrom; + + bool swept = FlatBspQuery.SphereIntersectsPolyWithTime( + p.Bsp, localTo, rows[i].Radius, localMove, + out ushort sweptId, out _, out float sweptTime); + bool stat = FlatBspQuery.SphereIntersectsPoly( + p.Bsp, localTo, rows[i].Radius, out ushort statId, out _); + float nearest = float.MaxValue; + foreach (var poly in p.LocalPolygons) + nearest = MathF.Min(nearest, DistanceToPolygon(poly, localTo)); + if (!swept && !stat && nearest > 3f) continue; // far away, silent + sb.AppendLine(Inv( + $" ent=0x{p.EntityId:X8} sphere[{i}] r={rows[i].Radius:F3} " + + $"swept={swept}(poly {sweptId} t={sweptTime:F4}) " + + $"static={stat}(poly {statId}) nearestPoly={nearest:F3} m")); + } + } + } + + string outPath = Path.Combine(Path.GetTempPath(), "issue337-descent-replay.txt"); + File.WriteAllText(outPath, sb.ToString()); + _out.WriteLine(sb.ToString()); + _out.WriteLine($"(also written to {outPath})"); + } + + /// + /// #337 REPRODUCER. At the position the client fell through the plateau, + /// the BSP query returns a hit — the geometry is present, correctly + /// placed, and reachable by the production traversal. This asserts that + /// load-bearing fact, which must hold both before and after any fix; if it + /// ever stops holding, the diagnosis in + /// docs/research/2026-08-06-337-neftet-wedge-mechanism.md is void. + /// + [Fact] + public void TheBspQueryReturnsAHitAtThePositionTheClientFellThrough() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; // CI without dats — the sibling dumps skip too. + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + + Placed rock = ResolveOwner(dats, info!, 0xC876_6009u); + + // Feet position and per-sphere layout, verbatim from the live capture + // (337-support.log t=303221015..303221125) and Setup 0x02000001. + var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f); + var footSphereOffset = new Vector3(0f, 0f, 0.475f); + const float SphereRadius = 0.48f; + + Vector3 local = Vector3.Transform( + feet + footSphereOffset - rock.Position, + Quaternion.Inverse(rock.Rotation)); + + bool hit = FlatBspQuery.SphereIntersectsPoly( + rock.Bsp, local, SphereRadius, out ushort polygonId, out _); + + Assert.True( + hit, + "The rock's physics BSP must report the plateau surface under the " + + "mover at the position the live client fell through it. If this " + + "fails, the defect is in the geometry or the traversal after all."); + Assert.NotEqual(0, polygonId); + } + + /// + /// #337 REPRODUCER — currently FAILING, hence skipped. + /// + /// + /// The per-object broadphase in + /// Transition.FindObjCollisionsInCell (TransitionTypes.cs, the + /// maxReach test) measures the mover's distance to the shadow + /// entry's Position — the part ORIGIN — and compares it against + /// obj.Radius, which is the physics-BSP ROOT BOUNDING SPHERE's + /// radius. For this rock those two are 23.6 m apart, so a mover standing + /// on its plateau is inside the bounding sphere by ~20 m of margin and + /// still fails the test. It is the same defect AP-156 fixed in the flood + /// and #334 fixed in the registration extent walk, left in place at the + /// query site (filed as #333). + /// + /// + /// + /// Retail has no such filter. CPartArray::FindObjCollisions + /// @0x00518180 is a bare loop over parts and + /// CPhysicsPart::find_obj_collisions @0x0050d8d0 does two null + /// checks and calls CGfxObj::find_obj_collisions @0x00534700 — + /// verified instruction-by-instruction against the PDB-paired + /// v11.4186 binary. Neither contains a compare or any float math. The + /// only spatial rejection retail performs is the BSP node bounding-sphere + /// test inside the walk, which is correctly centred. + /// + /// + /// Un-skip this as the acceptance gate for the fix. + /// + [Fact(Skip = "#337: fails until the query-site broadphase measures to the " + + "BSP bounding-sphere centre (or is removed, as retail has none).")] + public void TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn() + { + string? datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var info = dats.Get((Landblock & 0xFFFF0000u) | 0xFFFEu); + Assert.NotNull(info); + Placed rock = ResolveOwner(dats, info!, 0xC876_6009u); + + var feet = new Vector3(131.13f, 627.61f - 576f, 50.961f); + Vector3 currPos = feet + new Vector3(0f, 0f, 0.475f); + const float SphereRadius = 0.48f; + const float Movement = 0.308f; // the live step length + + float ownerRadius = rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Radius; + + // Verbatim from the production predicate. + float distToOrigin = (currPos - rock.Position).Length(); + float maxReach = SphereRadius + ownerRadius + Movement + 2f; + + Assert.True( + distToOrigin <= maxReach, + $"broadphase rejected a candidate the mover is standing on: " + + $"distToOrigin={distToOrigin:F3} > maxReach={maxReach:F3}. " + + $"Measured to the BSP bounding-sphere CENTRE it is " + + $"{(currPos - (rock.Position + Vector3.Transform(rock.Bsp.Nodes[rock.Bsp.RootIndex].BoundingSphere.Origin, rock.Rotation))).Length():F3} m, " + + $"comfortably inside the same radius."); + } + + private static Placed ResolveOwner( + DatCollection dats, LandBlockInfo info, uint wantedEntityId) + { + uint lbX = (Landblock >> 24) & 0xFFu; + uint lbY = (Landblock >> 16) & 0xFFu; + uint counter = 0; + var cache = new PhysicsDataCache(); + foreach (var stab in info.Objects) + { + if (!IsSupported(stab.Id)) continue; + uint entityId = LandblockStaticEntityIdAllocatorAllocate(lbX, lbY, ref counter); + if (entityId != wantedEntityId) continue; + var parts = Place(dats, cache, entityId, stab.Id, + stab.Frame.Origin, stab.Frame.Orientation).ToList(); + Assert.Single(parts); + return parts[0]; + } + throw new InvalidOperationException( + $"landblock static 0x{wantedEntityId:X8} not found."); + } + + /// + /// Pass-through. Concatenated interpolated fragments cannot bind to + /// , so the test method pins + /// CurrentCulture to invariant instead and this marks the call + /// sites that depend on it. + /// + private static string Inv(string s) => s; + + private static uint LandblockStaticEntityIdAllocatorAllocate( + uint lbX, uint lbY, ref uint counter) + => LandblockStaticEntityIdAllocator.Allocate(lbX, lbY, ref counter); + + private static bool IsSupported(uint id) + { + uint type = id & TypeMask; + return type == GfxObjMask || type == SetupMask; + } + + private static IEnumerable Place( + DatCollection dats, + PhysicsDataCache cache, + uint entityId, + uint datId, + Vector3 origin, + Quaternion orientation) + { + var parts = new List<(uint GfxObjId, Vector3 LocalPos, Quaternion LocalRot)>(); + + if ((datId & TypeMask) == GfxObjMask) + { + parts.Add((datId, Vector3.Zero, Quaternion.Identity)); + } + else + { + var setup = dats.Get(datId); + if (setup is null) yield break; + for (int i = 0; i < setup.Parts.Count; i++) + { + Vector3 lp = Vector3.Zero; + Quaternion lr = Quaternion.Identity; + if (!setup.PlacementFrames.TryGetValue( + DatReaderWriter.Enums.Placement.Resting, out var pf) + && !setup.PlacementFrames.TryGetValue( + DatReaderWriter.Enums.Placement.Default, out pf)) + { + pf = setup.PlacementFrames.Values.FirstOrDefault(); + } + if (pf?.Frames is not null && i < pf.Frames.Count) + { + lp = pf.Frames[i].Origin; + lr = pf.Frames[i].Orientation; + } + parts.Add((setup.Parts[i], lp, lr)); + } + } + + foreach (var part in parts) + { + var gfx = dats.Get(part.GfxObjId); + if (gfx is null) continue; + cache.CacheGfxObj(part.GfxObjId, gfx); + FlatGfxObjCollisionAsset flat = + FlatCollisionAssetBuilder.FlattenGfxObj(gfx); + FlatPhysicsBsp bsp = flat.PhysicsBsp; + if (bsp.RootIndex < 0 || bsp.Nodes.Length == 0) continue; + + Vector3 partWorldPos = origin + Vector3.Transform(part.LocalPos, orientation); + Quaternion partWorldRot = orientation * part.LocalRot; + + var seen = new HashSet(); + var worldPolys = new List(); + var localPolys = new List(); + var min = new Vector3(float.PositiveInfinity); + var max = new Vector3(float.NegativeInfinity); + + foreach (var node in bsp.Nodes) + { + var range = node.PolygonIndexRange; + for (int i = range.Start; i < range.EndExclusive; i++) + { + int pi = bsp.PolygonIndexStream[i]; + if ((uint)pi >= (uint)bsp.PolygonTable.Polygons.Length) continue; + if (!seen.Add(pi)) continue; + var poly = bsp.PolygonTable.Polygons[pi]; + var vr = poly.VertexRange; + var verts = new Vector3[vr.Count]; + var lverts = new Vector3[vr.Count]; + for (int v = 0; v < vr.Count; v++) + { + Vector3 local = bsp.PolygonTable.Vertices[vr.Start + v]; + Vector3 world = partWorldPos + + Vector3.Transform(local, partWorldRot); + lverts[v] = local; + verts[v] = world; + min = Vector3.Min(min, world); + max = Vector3.Max(max, world); + } + worldPolys.Add(verts); + localPolys.Add(lverts); + } + } + + if (worldPolys.Count == 0) continue; + + yield return new Placed( + entityId, datId, part.GfxObjId, partWorldPos, partWorldRot, + worldPolys, localPolys, bsp, min, max, + bsp.Nodes.Length, worldPolys.Count); + } + } + + /// + /// Points just off the mesh surface, both sides, at every vertex and + /// centroid. A sphere centred here demonstrably contains mesh, so the + /// production walk must report a hit. + /// + private static IEnumerable SurfaceProbes( + List polygons, float radius) + { + float nudge = radius * 0.5f; + foreach (Vector3[] poly in polygons) + { + if (poly.Length < 3) continue; + Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]); + float len = n.Length(); + if (len < 1e-9f) continue; + n /= len; + + Vector3 centroid = Vector3.Zero; + foreach (Vector3 v in poly) centroid += v; + centroid /= poly.Length; + + yield return centroid + n * nudge; + yield return centroid - n * nudge; + foreach (Vector3 v in poly) + { + // Pull slightly toward the centroid so a vertex probe sits on + // the polygon rather than exactly on its corner. + Vector3 inset = Vector3.Lerp(v, centroid, 0.15f); + yield return inset + n * nudge; + yield return inset - n * nudge; + } + } + } + + private static bool BruteHit(List polygons, Vector3 centre, float radius) + { + foreach (Vector3[] poly in polygons) + { + if (DistanceToPolygon(poly, centre) < radius - 1e-4f) + return true; + } + return false; + } + + /// + /// Every physics polygon whose world XY projection contains + /// (, ), with the Z of its plane + /// at that XY and the Z component of its normal. + /// + private static List<(uint Ent, uint Gfx, float Z, float Nz)> Column( + List placed, float x, float y) + { + var result = new List<(uint, uint, float, float)>(); + foreach (var p in placed) + { + if (x < p.WorldMin.X || x > p.WorldMax.X + || y < p.WorldMin.Y || y > p.WorldMax.Y) continue; + foreach (Vector3[] poly in p.WorldPolygons) + { + if (!XyContains(poly, x, y)) continue; + if (!TryPlaneZ(poly, x, y, out float z, out float nz)) continue; + result.Add((p.EntityId, p.GfxObjId, z, nz)); + } + } + return result; + } + + private static bool Inside(Vector3 min, Vector3 max, Vector3 p) + => p.X >= min.X && p.X <= max.X + && p.Y >= min.Y && p.Y <= max.Y + && p.Z >= min.Z && p.Z <= max.Z; + + private static float YawDegrees(Quaternion q) + { + // Rotation of +X about Z only; indicative for the mostly-Z-only + // orientations landblock stabs carry. + Vector3 x = Vector3.Transform(Vector3.UnitX, q); + return MathF.Atan2(x.Y, x.X) * 180f / MathF.PI; + } + + private static bool XyContains(Vector3[] poly, float x, float y) + { + bool inside = false; + for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++) + { + float yi = poly[i].Y, yj = poly[j].Y; + if ((yi > y) == (yj > y)) continue; + float t = (y - yi) / (yj - yi); + float xAt = poly[i].X + t * (poly[j].X - poly[i].X); + if (x < xAt) inside = !inside; + } + return inside; + } + + private static bool TryPlaneZ(Vector3[] poly, float x, float y, out float z, out float nz) + { + z = 0f; nz = 0f; + if (poly.Length < 3) return false; + Vector3 n = Vector3.Cross(poly[1] - poly[0], poly[2] - poly[0]); + float len = n.Length(); + if (len < 1e-9f) return false; + n /= len; + if (MathF.Abs(n.Z) < 1e-6f) return false; + nz = n.Z; + // n · (P - poly0) = 0 -> z = poly0.z - (n.x*(x-p0.x) + n.y*(y-p0.y)) / n.z + z = poly[0].Z - (n.X * (x - poly[0].X) + n.Y * (y - poly[0].Y)) / n.Z; + return true; + } + + private static float DistanceToPolygon(Vector3[] poly, Vector3 p) + { + float best = float.MaxValue; + for (int i = 2; i < poly.Length; i++) + best = MathF.Min(best, DistanceToTriangle(poly[0], poly[i - 1], poly[i], p)); + if (poly.Length == 2) + best = MathF.Min(best, DistanceToSegment(poly[0], poly[1], p)); + return best; + } + + private static float DistanceToTriangle(Vector3 a, Vector3 b, Vector3 c, Vector3 p) + { + Vector3 ab = b - a, ac = c - a, ap = p - a; + float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap); + if (d1 <= 0 && d2 <= 0) return Vector3.Distance(p, a); + Vector3 bp = p - b; + float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp); + if (d3 >= 0 && d4 <= d3) return Vector3.Distance(p, b); + float vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) + return Vector3.Distance(p, a + ab * (d1 / (d1 - d3))); + Vector3 cp = p - c; + float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp); + if (d6 >= 0 && d5 <= d6) return Vector3.Distance(p, c); + float vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) + return Vector3.Distance(p, a + ac * (d2 / (d2 - d6))); + float va = d3 * d6 - d5 * d4; + if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) + return Vector3.Distance(p, b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)))); + float denom = 1f / (va + vb + vc); + return Vector3.Distance(p, a + ab * (vb * denom) + ac * (vc * denom)); + } + + private static float DistanceToSegment(Vector3 a, Vector3 b, Vector3 p) + { + Vector3 ab = b - a; + float t = Vector3.Dot(p - a, ab) / MathF.Max(1e-9f, Vector3.Dot(ab, ab)); + t = Math.Clamp(t, 0f, 1f); + return Vector3.Distance(p, a + ab * t); + } +}