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:
parent
49a7e90652
commit
5a1eeace73
3 changed files with 1182 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue