docs(physics): #337 diagnosed — it is #333's query-site broadphase, not the mesh

Report-only. No production code changed.

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. 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 0xC8766009 those two points are 23.556 m apart, so a
mover standing on its plateau is inside the real bounding sphere by ~20 m of
margin and is still rejected. Same defect AP-156 fixed in the flood and #334
fixed in the registration extent walk, left in place at the query site.

Measured, not inferred. An offline replay against the installed DAT
reconstructs all eleven landblock-0x8766 owners and matches the live [geom]
placement exactly (0xC8766002 at (84.699,100.082,13.000) yaw -45.00 vs the
log's objPos + bspCentreOffset). At the position the client fell through, the
production swept query returns a hit on poly 31 at 0.037-0.366 m while the
filter rejects the candidate: distToOrigin=60.434 > maxReach=59.697, distance
to the bounding-sphere CENTRE 37.083 m against a 56.909 m radius. The live
capture recorded that rejection 7,225 times with the probe's own
wouldAcceptAtCenter=True on every one.

Bounded because the dead zone is the shell between maxReach and the true
sphere, up to ~23.5 m thick on the far side. movement.Length() is a budget
term: a 0.25 m walking step gives shortfall +0.60, a 0.72 m step +0.14, and
~0.86 m passes — which is exactly why jumping over the spot works, walking
into it does not, and a corpse falls through.

Three hypotheses refuted by measurement, not by argument:

- "the rock's own mesh never collides" — true of 0xC8766002 and it is
  INNOCENT; its geometry is 22.8 m from the wedge and it has zero brute-force
  hits over a 12,493-point lattice covering the plateau. It is a candidate
  only because it is a 130x147 m owner. The rock actually walked on is
  0xC8766009.
- wrong world transform — the offline placement reproduces the runtime
  exactly, and a uniform displacement cannot produce a bounded pocket.
- BSP traversal hole — a referee ran the production walk against brute force
  at 7,770 on-surface probes across all eleven owners plus 137,423 lattice
  points. Mismatch 0 everywhere. A 0.5 m hole map also shows continuous
  upward-facing coverage across the whole wedge region.

[geom]'s verdict=coincident was never able to decide this: LogGeometry
compares the physics box against the visual box in the object's OWN LOCAL
FRAME, so it proves shape agreement and says nothing about world placement.
Recorded in the doc so the next reader does not re-trust it.

Retail has no per-object distance filter on the BSP branch. Verified
instruction-by-instruction with cdb against the PDB-paired v11.4186 binary:
CPartArray::FindObjCollisions @0x00518180 is 14 instructions of bare
do/while over parts[i]; CPhysicsPart::find_obj_collisions @0x0050d8d0 is 17
instructions of two null checks plus the call to CGfxObj::find_obj_collisions
@0x00534700. No compare, no float math in either. The in-tree comment calling
the filter a retail analog and response-neutral is wrong on both counts.

The support=object cpNz=1.0000 readings inside the rock are not the rock:
ValidateTransition:6076 is retail's stationary-fall failsafe manufacturing a
flat plane through the sphere bottom, and :5997 is the LastKnownContactPlane
restore holding a stale plane. Both are retail-correct responses to a stuck
body, and they are why the client believes it is standing while ACE rejects
the position.

Preferred fix is to delete the pre-check for BSP entries and correct the
comment; fallback is to measure to the bounding-sphere centre, which also
needs BoundsCenter carried on ShadowEntry. Neither is landed.

The reproducer was confirmed to FAIL when un-skipped, with the numbers above
— this campaign has caught eleven green tests covering nothing, so a fixture
that cannot distinguish the bug is worse than none.

Gates: bin/obj deleted, Release build 0 errors, Core suite 4,287 passed /
2 skipped / 0 failed (baseline 4,286/1 plus three new dumps and the one
deliberately skipped reproducer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 21:16:31 +02:00
parent 49a7e90652
commit 5a1eeace73
3 changed files with 1182 additions and 2 deletions

View file

@ -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.

View file

@ -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 |
|---|---|---|---|
| …218906219296 | 51.096 | 51.081 | standing correctly (delta +0.015). **Every horizontal move of 0.250.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.

View file

@ -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;
/// <summary>
/// #337 offline replay (TEMPORARY — strip with the physics-probe family).
///
/// <para>
/// 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 <c>[geom]</c> cannot answer: the <c>[geom]</c> probe
/// compares the physics-BSP vertex cloud against the visual mesh box in the
/// object's OWN LOCAL FRAME, so <c>verdict=coincident</c> 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
///
/// Dat-data dependent; SKIPs cleanly without ACDREAM_DAT_DIR.
/// </summary>
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;
/// <summary>The wedge point, landblock-local, from the live capture.</summary>
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<Vector3[]> WorldPolygons,
List<Vector3[]> 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<LandBlockInfo>((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<Placed>();
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<string>();
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
/// <summary>
/// #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.
/// </summary>
[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<LandBlockInfo>((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<Placed>();
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<Setup>(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})");
}
/// <summary>
/// #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.
/// </summary>
[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<LandBlockInfo>((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);
}
/// <summary>
/// #337 REPRODUCER — currently FAILING, hence skipped.
///
/// <para>
/// The per-object broadphase in
/// <c>Transition.FindObjCollisionsInCell</c> (TransitionTypes.cs, the
/// <c>maxReach</c> test) measures the mover's distance to the shadow
/// entry's <c>Position</c> — the part ORIGIN — and compares it against
/// <c>obj.Radius</c>, 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).
/// </para>
///
/// <para>
/// Retail has no such filter. <c>CPartArray::FindObjCollisions</c>
/// @0x00518180 is a bare loop over parts and
/// <c>CPhysicsPart::find_obj_collisions</c> @0x0050d8d0 does two null
/// checks and calls <c>CGfxObj::find_obj_collisions</c> @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.
/// </para>
///
/// Un-skip this as the acceptance gate for the fix.
/// </summary>
[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<LandBlockInfo>((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.");
}
/// <summary>
/// Pass-through. Concatenated interpolated fragments cannot bind to
/// <see cref="FormattableString"/>, so the test method pins
/// <c>CurrentCulture</c> to invariant instead and this marks the call
/// sites that depend on it.
/// </summary>
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<Placed> 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<Setup>(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<GfxObj>(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<int>();
var worldPolys = new List<Vector3[]>();
var localPolys = new List<Vector3[]>();
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);
}
}
/// <summary>
/// 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.
/// </summary>
private static IEnumerable<Vector3> SurfaceProbes(
List<Vector3[]> 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<Vector3[]> polygons, Vector3 centre, float radius)
{
foreach (Vector3[] poly in polygons)
{
if (DistanceToPolygon(poly, centre) < radius - 1e-4f)
return true;
}
return false;
}
/// <summary>
/// Every physics polygon whose world XY projection contains
/// (<paramref name="x"/>, <paramref name="y"/>), with the Z of its plane
/// at that XY and the Z component of its normal.
/// </summary>
private static List<(uint Ent, uint Gfx, float Z, float Nz)> Column(
List<Placed> 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);
}
}