probe(physics): ACDREAM_PROBE_SUPPORT + ACDREAM_WIRE_MESH — separate #337's three candidates
The user is wedged at the top of Neftet rock plateaus, jumps sink into the mesh, and a corpse falls straight through. ACDREAM_PROBE_REACH already ruled out its own domain: blocked=0, every candidate tested-ok. Three candidates remain — terrain support, a collision mesh not where its visual is, or the transition wedging on an unobstructed path. ACDREAM_PROBE_RESOLVE alone cannot separate them. It prints a three-value contact-plane token, no plane normal, no plane height, no terrain sample and no plane provenance, so all three produce the same line. Two additions: [support] — one line per resolve for EVERY body, not just the player. A corpse is a plain physics body with no player-specific logic, so its fall-through is the cheapest available control on "movement code vs geometry data", and it is invisible to any player-filtered probe. The line samples the outdoor terrain INDEPENDENTLY at the body's own out-XY and prints the contact plane's own height at that same XY. Two heights at one point make support=terrain / object / none a measurement rather than an inference, and cpSrc= names the site that asserted the plane so provenance and classification cross-check. [geom] — once per GfxObj that comes near a mover: the object's physics-BSP vertex cloud against its visual mesh AABB in the same local frame, through the same prepared accessors the resolver queries. verdict=coincident REFUTES the working hypothesis for that object outright; no-physics-bsp / empty-physics-bsp / displaced / extent-mismatch each name a specific data defect. Built to refute, not to confirm — two diagnoses on this defect's lineage have already been refuted by measurement. ACDREAM_WIRE_MESH upgrades the existing F2 overlay, which drew a broadphase proxy cylinder for BSP objects and so could not answer the question at all, to the real physics-BSP polygon edges (cyan) beside the visual mesh box (magenta) and the terrain surface (yellow). Own class per code-structure rule 1. The provenance latch lives on PhysicsDiagnostics, not on CollisionInfo. Two fields there first — the obvious home — broke the flat/graph differential referee and the scratch-reset poison test, both of which compare CollisionInfo member-for-member. Teaching either to skip a member is a one-line green fix that puts a permanent hole in a referee whose whole job is comparing everything. Captured as feedback_probe_state_off_compared_types. Seven tests cover the support classifier's boundaries: a wrong classifier does not fail to answer, it answers confidently wrong. Gates: Release build 0 errors; complete suite 11,225 passed / 4 skipped / 0 failed from a cleaned tree — baseline 11,218/4/0 plus exactly the seven new tests, skips unchanged. Issue #337 filed with the symptom set, what is ruled out, and a table of what each possible output means. All of this is TEMPORARY and recorded for stripping with the physics-probe family. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
13fcf38138
commit
49a7e90652
9 changed files with 1376 additions and 1 deletions
|
|
@ -1370,6 +1370,551 @@ public static class PhysicsDiagnostics
|
|||
blocked, currPos.X, currPos.Y, currPos.Z, now));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// [support] / [geom] — #337 "what is holding this body up, and is the
|
||||
// collision geometry where the visual geometry is?" (2026-08-06 —
|
||||
// TEMPORARY, strip with the physics-probe family).
|
||||
//
|
||||
// WHY A NEW FAMILY RATHER THAN MORE [resolve].
|
||||
// ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out
|
||||
// position + cell, ok, groundedIn, a THREE-VALUE contact-plane token
|
||||
// (valid / lastKnown / none), the collision normal + responsible entity if
|
||||
// something was hit, and one walkable-polygon bool. That is enough to say
|
||||
// THAT the body stopped. It cannot say WHAT held it up, because it prints
|
||||
// no plane normal, no plane height, no terrain sample, and no attribution
|
||||
// for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain
|
||||
// holding the body, (b) an object surface holding it somewhere other than
|
||||
// where the rock is drawn, and (c) an unobstructed transition that simply
|
||||
// fails to advance all produce the SAME [resolve] line. Two diagnoses this
|
||||
// campaign have already been refuted by measurement; a probe that cannot
|
||||
// separate the remaining three is not worth the launch.
|
||||
//
|
||||
// WHAT SEPARATES THEM.
|
||||
// [support] — per resolve, per body (players AND corpses/NPCs, which is
|
||||
// what makes the fall-through case observable at all). It samples the
|
||||
// OUTDOOR TERRAIN independently at the body's own out-XY and prints
|
||||
// the contact plane's own height at that same XY. Two independent
|
||||
// heights at one point:
|
||||
// cpZ@out == terrZ → terrain is the support, whatever set it.
|
||||
// cpZ@out >> terrZ → an object surface is the support.
|
||||
// cpValid=false → nothing is; the body is in free fall.
|
||||
// `cpSrc` names the code site that wrote the plane, so the classifier
|
||||
// and the provenance are cross-checkable rather than one inferring
|
||||
// the other.
|
||||
// [geom] — once per GfxObj that comes near the mover. Compares the
|
||||
// object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in
|
||||
// the same local frame. If the collision geometry is absent, empty,
|
||||
// displaced, or the wrong size, this line says so directly. That is
|
||||
// the working hypothesis's refutation test: `verdict=coincident`
|
||||
// kills "the collision isn't where the visual is" outright, and no
|
||||
// amount of movement-side evidence is then needed to rule it out.
|
||||
//
|
||||
// Neither line gates, orders, or mutates anything. Both are pure reads.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Initial state from <c>ACDREAM_PROBE_SUPPORT=1</c>. Enables the
|
||||
/// <c>[support]</c> and <c>[geom]</c> lines described above. Zero cost when
|
||||
/// off (one static bool read per resolve and per collision candidate).
|
||||
/// TEMPORARY — strip with the rest of the physics-probe family.
|
||||
/// </summary>
|
||||
public static bool ProbeSupportEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1";
|
||||
|
||||
/// <summary>Vertical agreement window, in metres, inside which the contact
|
||||
/// plane's height and the terrain's height at the same XY are called the
|
||||
/// same surface.</summary>
|
||||
public const float SupportSameSurfaceZ = 0.05f;
|
||||
|
||||
/// <summary>Straight-up-component agreement window inside which the contact
|
||||
/// plane's tilt and the terrain triangle's tilt are called the same
|
||||
/// surface. 0.02 is roughly 1 degree near flat.</summary>
|
||||
public const float SupportSameSurfaceNormalZ = 0.02f;
|
||||
|
||||
private static readonly object _supportGate = new();
|
||||
private static readonly Dictionary<uint, (long Ms, long Signature)> _supportSeen = new();
|
||||
private static readonly HashSet<uint> _geomSeen = new();
|
||||
|
||||
// Contact-plane provenance latch. Ten distinct sites call
|
||||
// CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat),
|
||||
// cell BSP, three water paths, and a straight-up fallback — and the plane
|
||||
// they write is indistinguishable once stored, so [support]'s
|
||||
// classification would have no independent cross-check.
|
||||
//
|
||||
// This deliberately does NOT live on CollisionInfo. That object's stored
|
||||
// members are compared member-for-member by the flat/graph differential
|
||||
// referee and by the transition-scratch reset poison test; adding a
|
||||
// diagnostic field there makes both oracles report a difference that is
|
||||
// not a difference, and the only way to keep them green is to teach them
|
||||
// to skip a member — which is how a referee quietly stops refereeing.
|
||||
// [ThreadStatic] because a headless host ticks several sessions in
|
||||
// parallel and physics is synchronous within each.
|
||||
[ThreadStatic] private static string? _contactPlaneSourceMember;
|
||||
[ThreadStatic] private static int _contactPlaneSourceLine;
|
||||
|
||||
/// <summary>
|
||||
/// Clear the provenance latch. Call once per resolve, before the sweep, so
|
||||
/// a resolve that establishes no plane reports <c>none</c> rather than the
|
||||
/// previous resolve's answer. No-op unless
|
||||
/// <see cref="ProbeSupportEnabled"/>.
|
||||
/// </summary>
|
||||
public static void BeginContactPlaneAttribution()
|
||||
{
|
||||
if (!ProbeSupportEnabled) return;
|
||||
_contactPlaneSourceMember = null;
|
||||
_contactPlaneSourceLine = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the site asserting a contact plane. Called by
|
||||
/// <c>CollisionInfo.SetContactPlane</c> with compiler-supplied literals;
|
||||
/// self-guarded, so it is a single flag test when the probe is off and
|
||||
/// allocates nothing when it is on.
|
||||
/// </summary>
|
||||
public static void RecordContactPlaneSource(string member, int line)
|
||||
{
|
||||
if (!ProbeSupportEnabled) return;
|
||||
_contactPlaneSourceMember = member;
|
||||
_contactPlaneSourceLine = line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>member:line</c> of the last site to assert a contact plane since
|
||||
/// <see cref="BeginContactPlaneAttribution"/>, or <c>"none"</c>.
|
||||
/// </summary>
|
||||
public static string ContactPlaneSource =>
|
||||
_contactPlaneSourceMember is { Length: > 0 } member
|
||||
? string.Concat(
|
||||
member,
|
||||
":",
|
||||
_contactPlaneSourceLine.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture))
|
||||
: "none";
|
||||
|
||||
/// <summary>
|
||||
/// Classify what is under the body. Pure function so the live probe and any
|
||||
/// offline reader agree on the vocabulary.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>none</c> — no contact plane: the body is unsupported.</item>
|
||||
/// <item><c>terrain</c> — the contact plane sits at the terrain's height
|
||||
/// AND shares its tilt.</item>
|
||||
/// <item><c>object</c> — the contact plane sits clear of the terrain: some
|
||||
/// collision surface other than the ground is the support.</item>
|
||||
/// <item><c>coplanar-tilt-mismatch</c> — same height, different tilt.
|
||||
/// Reported as its own answer rather than folded into either, because
|
||||
/// it is exactly what a collision mesh laid flat against the ground
|
||||
/// would look like and guessing between the two would be the third
|
||||
/// unverified diagnosis this campaign.</item>
|
||||
/// <item><c>no-terrain</c> — no outdoor terrain under this XY (indoors,
|
||||
/// or the landblock is not resident): the comparison is unavailable
|
||||
/// and is said so rather than defaulted.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string ClassifySupport(
|
||||
bool contactPlaneValid,
|
||||
bool terrainSampled,
|
||||
float contactPlaneZAtXY,
|
||||
float contactPlaneNormalZ,
|
||||
float terrainZ,
|
||||
float terrainNormalZ)
|
||||
{
|
||||
if (!contactPlaneValid) return "none";
|
||||
if (!terrainSampled) return "no-terrain";
|
||||
|
||||
bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ;
|
||||
bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ;
|
||||
|
||||
if (sameHeight && sameTilt) return "terrain";
|
||||
if (sameHeight) return "coplanar-tilt-mismatch";
|
||||
return "object";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate a plane's height at a given XY. Returns <see langword="false"/>
|
||||
/// when the plane is near-vertical, where a height is not defined — a wall
|
||||
/// is never a floor, and reporting a huge number for one would read as a
|
||||
/// displaced surface.
|
||||
/// </summary>
|
||||
public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z)
|
||||
{
|
||||
float nz = plane.Normal.Z;
|
||||
if (MathF.Abs(nz) < 1e-4f)
|
||||
{
|
||||
z = float.NaN;
|
||||
return false;
|
||||
}
|
||||
z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[support]</c> line. Self-guards on
|
||||
/// <see cref="ProbeSupportEnabled"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Volume control: per mover, the line re-emits IMMEDIATELY on any change
|
||||
/// in the state signature — the support classification, the ok / contact /
|
||||
/// walkable / stalled bits, a 0.1 m change in the body's height, or a
|
||||
/// 0.1 m change in its height above terrain — and otherwise at most once
|
||||
/// per 250 ms. A body falling through geometry therefore produces a line
|
||||
/// every 10 cm of descent, and a body standing still produces four lines a
|
||||
/// second. Nothing is aggregated away.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="moverId"/> is never omitted:
|
||||
/// <c>feedback_probe_identity_attribution</c> — a per-entity probe without
|
||||
/// an identity produced a wrong root cause once already, and this capture
|
||||
/// deliberately covers several bodies at once.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void LogSupport(
|
||||
uint moverId,
|
||||
bool isPlayer,
|
||||
Vector3 inPos,
|
||||
uint inCell,
|
||||
Vector3 targetPos,
|
||||
Vector3 outPos,
|
||||
uint outCell,
|
||||
bool ok,
|
||||
bool groundedIn,
|
||||
bool contact,
|
||||
bool onWalkable,
|
||||
bool contactPlaneValid,
|
||||
Plane contactPlane,
|
||||
uint contactPlaneCellId,
|
||||
bool contactPlaneIsWater,
|
||||
string contactPlaneSource,
|
||||
bool lastKnownValid,
|
||||
Plane lastKnownPlane,
|
||||
bool terrainSampled,
|
||||
float terrainZ,
|
||||
Vector3 terrainNormal,
|
||||
uint terrainCellId,
|
||||
bool terrainIsWater,
|
||||
bool walkablePolygon,
|
||||
bool lastWalkablePolygon,
|
||||
float stepUpHeight,
|
||||
float stepDownHeight,
|
||||
Vector3 velocity)
|
||||
{
|
||||
if (!ProbeSupportEnabled) return;
|
||||
|
||||
float cpZ = float.NaN;
|
||||
bool cpZDefined = contactPlaneValid
|
||||
&& TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ);
|
||||
if (!cpZDefined) cpZ = float.NaN;
|
||||
|
||||
float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN;
|
||||
float terrNz = terrainSampled ? terrainNormal.Z : float.NaN;
|
||||
|
||||
string support = ClassifySupport(
|
||||
contactPlaneValid && cpZDefined,
|
||||
terrainSampled,
|
||||
cpZ,
|
||||
cpNz,
|
||||
terrainZ,
|
||||
terrNz);
|
||||
|
||||
float commanded = Vector3.Distance(inPos, targetPos);
|
||||
float moved = Vector3.Distance(inPos, outPos);
|
||||
// "The body was told to move and did not." The 1 cm floor is the
|
||||
// resolver's own no-op scale, not a tuned threshold.
|
||||
bool stalled = commanded > 0.01f && moved <= 0.01f;
|
||||
|
||||
float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN;
|
||||
float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN;
|
||||
|
||||
long now = Environment.TickCount64;
|
||||
long signature = support.GetHashCode();
|
||||
signature = signature * 31 + (ok ? 1 : 0);
|
||||
signature = signature * 31 + (groundedIn ? 1 : 0);
|
||||
signature = signature * 31 + (contact ? 1 : 0);
|
||||
signature = signature * 31 + (onWalkable ? 1 : 0);
|
||||
signature = signature * 31 + (contactPlaneValid ? 1 : 0);
|
||||
signature = signature * 31 + (stalled ? 1 : 0);
|
||||
signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f);
|
||||
signature = signature * 31 + (float.IsNaN(zAboveTerrain)
|
||||
? 0
|
||||
: (long)MathF.Floor(zAboveTerrain * 10f));
|
||||
|
||||
lock (_supportGate)
|
||||
{
|
||||
if (_supportSeen.TryGetValue(moverId, out var prev)
|
||||
&& prev.Signature == signature
|
||||
&& now - prev.Ms < 250)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_supportSeen[moverId] = (now, signature);
|
||||
}
|
||||
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
Console.WriteLine(string.Format(ci,
|
||||
"[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " +
|
||||
"in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " +
|
||||
"tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " +
|
||||
"ok={15} cmd={16:F3} moved={17:F3} stalled={18} " +
|
||||
"groundedIn={19} contact={20} onWalkable={21} " +
|
||||
"cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " +
|
||||
"cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " +
|
||||
"cpZatOut={32:F3} " +
|
||||
"lkcpValid={33} lkcpNz={34:F4} " +
|
||||
"terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " +
|
||||
"terrCell=0x{39:X8} terrWater={40} " +
|
||||
"zAboveTerr={41:F3} cpAboveTerr={42:F3} " +
|
||||
"walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " +
|
||||
"vel=({47:F3},{48:F3},{49:F3})",
|
||||
moverId, isPlayer, now, support,
|
||||
inPos.X, inPos.Y, inPos.Z, inCell,
|
||||
targetPos.X, targetPos.Y, targetPos.Z,
|
||||
outPos.X, outPos.Y, outPos.Z, outCell,
|
||||
ok, commanded, moved, stalled,
|
||||
groundedIn, contact, onWalkable,
|
||||
contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater,
|
||||
contactPlaneValid ? contactPlane.Normal.X : float.NaN,
|
||||
contactPlaneValid ? contactPlane.Normal.Y : float.NaN,
|
||||
cpNz, cpNz, PhysicsGlobals.FloorZ,
|
||||
contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ,
|
||||
cpZ,
|
||||
lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN,
|
||||
terrainSampled, terrainZ, terrNz,
|
||||
terrainSampled && terrNz >= PhysicsGlobals.FloorZ,
|
||||
terrainCellId, terrainIsWater,
|
||||
zAboveTerrain, cpAboveTerrain,
|
||||
walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight,
|
||||
velocity.X, velocity.Y, velocity.Z));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ask whether <c>[geom]</c> has already been emitted for this GfxObj.
|
||||
/// The line is a property of the ASSET, not of any moment, so once per
|
||||
/// process is the whole story and re-emitting it would bury the
|
||||
/// <c>[support]</c> stream.
|
||||
/// </summary>
|
||||
public static bool ShouldLogGeometry(uint gfxObjId)
|
||||
{
|
||||
if (!ProbeSupportEnabled) return false;
|
||||
lock (_supportGate)
|
||||
{
|
||||
return _geomSeen.Add(gfxObjId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[geom]</c> line: is this object's collision geometry where its
|
||||
/// visual geometry is? Caller MUST have claimed the id through
|
||||
/// <see cref="ShouldLogGeometry"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The verdict vocabulary, and what each one settles:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>no-physics-bsp</c> / <c>empty-physics-bsp</c> — the object
|
||||
/// has no collision polygons at all. Everything a body does around it
|
||||
/// follows from that one fact and no movement-side theory is needed.</item>
|
||||
/// <item><c>no-visual-bounds</c> — the comparison could not be made. Said
|
||||
/// out loud rather than silently treated as agreement.</item>
|
||||
/// <item><c>displaced</c> — collision and visual are the same size but
|
||||
/// sit in different places. This is the working hypothesis, and this
|
||||
/// token is the only thing that confirms it.</item>
|
||||
/// <item><c>extent-mismatch</c> — same place, different size.</item>
|
||||
/// <item><c>coincident</c> — collision and visual agree. This REFUTES the
|
||||
/// working hypothesis for this object, and the cause is then on the
|
||||
/// movement side (terrain support, or the transition itself).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void LogGeometry(
|
||||
uint gfxObjId,
|
||||
uint entityId,
|
||||
int bspNodeCount,
|
||||
int bspPolygonCount,
|
||||
int bspVertexCount,
|
||||
Vector3 rootSphereOrigin,
|
||||
float rootSphereRadius,
|
||||
bool physicsBoundsValid,
|
||||
Vector3 physicsMin,
|
||||
Vector3 physicsMax,
|
||||
bool visualBoundsValid,
|
||||
Vector3 visualMin,
|
||||
Vector3 visualMax,
|
||||
float visualRadius,
|
||||
Vector3 entityWorldPosition,
|
||||
float entityScale,
|
||||
float registeredRadius)
|
||||
{
|
||||
Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero;
|
||||
Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero;
|
||||
Vector3 physCentre = physicsBoundsValid
|
||||
? (physicsMin + physicsMax) * 0.5f
|
||||
: Vector3.Zero;
|
||||
Vector3 visCentre = visualBoundsValid
|
||||
? (visualMin + visualMax) * 0.5f
|
||||
: Vector3.Zero;
|
||||
|
||||
float centreDelta = physicsBoundsValid && visualBoundsValid
|
||||
? Vector3.Distance(physCentre, visCentre)
|
||||
: float.NaN;
|
||||
|
||||
// Tolerances are deliberately loose: this line answers "same place,
|
||||
// same size?" at the scale of a rock formation, not to the millimetre.
|
||||
// A physics hull is a coarse stand-in for the render mesh, so a
|
||||
// half-metre of centre drift or a 2x extent ratio is normal; what this
|
||||
// is looking for is the pathological case.
|
||||
float centreTolerance = visualBoundsValid
|
||||
? MathF.Max(0.5f, visualRadius * 0.25f)
|
||||
: 0.5f;
|
||||
|
||||
bool extentMismatch = false;
|
||||
if (physicsBoundsValid && visualBoundsValid)
|
||||
{
|
||||
for (int axis = 0; axis < 3; axis++)
|
||||
{
|
||||
float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z;
|
||||
float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z;
|
||||
// Flat axes (a floor plate) legitimately have ~0 extent in one
|
||||
// dimension on both sides; only compare where the visual has
|
||||
// real size.
|
||||
if (v < 0.1f) continue;
|
||||
float ratio = p / v;
|
||||
if (ratio is < 0.5f or > 2.0f) extentMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
string verdict =
|
||||
bspNodeCount == 0 ? "no-physics-bsp"
|
||||
: bspPolygonCount == 0 ? "empty-physics-bsp"
|
||||
: !visualBoundsValid ? "no-visual-bounds"
|
||||
: !physicsBoundsValid ? "no-physics-bounds"
|
||||
: centreDelta > centreTolerance ? "displaced"
|
||||
: extentMismatch ? "extent-mismatch"
|
||||
: "coincident";
|
||||
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
Console.WriteLine(string.Format(ci,
|
||||
"[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " +
|
||||
"bspNodes={4} bspPolys={5} bspVerts={6} " +
|
||||
"rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " +
|
||||
"physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " +
|
||||
"physExt=({18:F3},{19:F3},{20:F3}) " +
|
||||
"visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " +
|
||||
"visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " +
|
||||
"centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " +
|
||||
"objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " +
|
||||
"physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]",
|
||||
gfxObjId, verdict, entityId, Environment.TickCount64,
|
||||
bspNodeCount, bspPolygonCount, bspVertexCount,
|
||||
rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z,
|
||||
rootSphereRadius, registeredRadius,
|
||||
physicsMin.X, physicsMin.Y, physicsMin.Z,
|
||||
physicsMax.X, physicsMax.Y, physicsMax.Z,
|
||||
physExtent.X, physExtent.Y, physExtent.Z,
|
||||
visualMin.X, visualMin.Y, visualMin.Z,
|
||||
visualMax.X, visualMax.Y, visualMax.Z,
|
||||
visExtent.X, visExtent.Y, visExtent.Z, visualRadius,
|
||||
centreDelta, centreTolerance, extentMismatch,
|
||||
entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z,
|
||||
entityScale,
|
||||
// Rotation is NOT applied to these two world Z ranges: an
|
||||
// axis-aligned box is not rotation-invariant, so a rotated object
|
||||
// would report a box that is merely indicative. Both sides get the
|
||||
// SAME treatment, so their AGREEMENT (the thing being measured)
|
||||
// stays exact regardless.
|
||||
entityWorldPosition.Z + physicsMin.Z * entityScale,
|
||||
entityWorldPosition.Z + physicsMax.Z * entityScale,
|
||||
entityWorldPosition.Z + visualMin.Z * entityScale,
|
||||
entityWorldPosition.Z + visualMax.Z * entityScale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the collision-vs-visual comparison for one GfxObj straight from
|
||||
/// the SAME prepared assets the resolver itself queries, and emit its
|
||||
/// <c>[geom]</c> line. Going through the production accessors is the point:
|
||||
/// AP-156's lesson was that a probe reading geometry by a second route can
|
||||
/// report a shape the registry never emitted. Caller MUST have claimed the
|
||||
/// id through <see cref="ShouldLogGeometry"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The physics box is measured over the vertices of the polygons the BSP
|
||||
/// actually indexes, not over the whole polygon table — a table can carry
|
||||
/// rows no node references, and including those would report collision
|
||||
/// geometry that no query can ever reach.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void LogGeometryFromAssets(
|
||||
uint gfxObjId,
|
||||
uint entityId,
|
||||
FlatGfxObjCollisionAsset? flat,
|
||||
GfxObjVisualBounds? visual,
|
||||
Vector3 entityWorldPosition,
|
||||
float entityScale,
|
||||
float registeredRadius)
|
||||
{
|
||||
int nodeCount = 0;
|
||||
int polygonCount = 0;
|
||||
int vertexCount = 0;
|
||||
Vector3 rootOrigin = Vector3.Zero;
|
||||
float rootRadius = 0f;
|
||||
bool physBoundsValid = false;
|
||||
var physMin = new Vector3(float.PositiveInfinity);
|
||||
var physMax = new Vector3(float.NegativeInfinity);
|
||||
|
||||
FlatPhysicsBsp? bsp = flat?.PhysicsBsp;
|
||||
if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0)
|
||||
{
|
||||
nodeCount = bsp.Nodes.Length;
|
||||
rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin;
|
||||
rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius;
|
||||
|
||||
FlatPolygonTable table = bsp.PolygonTable;
|
||||
foreach (FlatPhysicsBspNode node in bsp.Nodes)
|
||||
{
|
||||
FlatIndexRange range = node.PolygonIndexRange;
|
||||
for (int i = range.Start; i < range.EndExclusive; i++)
|
||||
{
|
||||
int polygonIndex = bsp.PolygonIndexStream[i];
|
||||
if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue;
|
||||
|
||||
polygonCount++;
|
||||
FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange;
|
||||
for (int v = vertices.Start; v < vertices.EndExclusive; v++)
|
||||
{
|
||||
Vector3 p = table.Vertices[v];
|
||||
vertexCount++;
|
||||
physMin = Vector3.Min(physMin, p);
|
||||
physMax = Vector3.Max(physMax, p);
|
||||
physBoundsValid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!physBoundsValid)
|
||||
{
|
||||
physMin = Vector3.Zero;
|
||||
physMax = Vector3.Zero;
|
||||
}
|
||||
|
||||
LogGeometry(
|
||||
gfxObjId: gfxObjId,
|
||||
entityId: entityId,
|
||||
bspNodeCount: nodeCount,
|
||||
bspPolygonCount: polygonCount,
|
||||
bspVertexCount: vertexCount,
|
||||
rootSphereOrigin: rootOrigin,
|
||||
rootSphereRadius: rootRadius,
|
||||
physicsBoundsValid: physBoundsValid,
|
||||
physicsMin: physMin,
|
||||
physicsMax: physMax,
|
||||
visualBoundsValid: visual is not null,
|
||||
visualMin: visual?.Min ?? Vector3.Zero,
|
||||
visualMax: visual?.Max ?? Vector3.Zero,
|
||||
visualRadius: visual?.Radius ?? 0f,
|
||||
entityWorldPosition: entityWorldPosition,
|
||||
entityScale: entityScale,
|
||||
registeredRadius: registeredRadius);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic).
|
||||
/// Emits one <c>[tp-probe]</c> line per teleport-pipeline event with a
|
||||
|
|
@ -1586,6 +2131,14 @@ public static class PhysicsDiagnostics
|
|||
_reachSeenObj.Clear();
|
||||
_reachSeenQuery.Clear();
|
||||
}
|
||||
ProbeSupportEnabled = false;
|
||||
_contactPlaneSourceMember = null;
|
||||
_contactPlaneSourceLine = 0;
|
||||
lock (_supportGate)
|
||||
{
|
||||
_supportSeen.Clear();
|
||||
_geomSeen.Clear();
|
||||
}
|
||||
ProbeTeleportEnabled = false;
|
||||
ProbeRemoteTeleportEnabled = false;
|
||||
ProbeRemoteLandingEnabled = false;
|
||||
|
|
|
|||
|
|
@ -1913,6 +1913,15 @@ public sealed class PhysicsEngine
|
|||
? PhysicsResolveCapture.Snapshot(body)
|
||||
: null;
|
||||
|
||||
// #337 (2026-08-06 — TEMPORARY): arm the [support] probe's
|
||||
// contact-plane provenance latch for this resolve, ahead of everything
|
||||
// including the carried-plane seed below. The seed is itself one of
|
||||
// the ten sites that assert a plane, so it stamps its own name and a
|
||||
// capture can read `cpSrc=ResolveWithTransition:<line>` as "carried
|
||||
// from the body, nothing re-derived it this resolve" without needing a
|
||||
// sentinel value for that case. No-op when the probe is off.
|
||||
PhysicsDiagnostics.BeginContactPlaneAttribution();
|
||||
|
||||
var transition = RentTransition();
|
||||
try
|
||||
{
|
||||
|
|
@ -2299,6 +2308,67 @@ public sealed class PhysicsEngine
|
|||
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
|
||||
}
|
||||
|
||||
// #337 [support] probe (2026-08-06 — TEMPORARY, strip with the
|
||||
// physics-probe family). Runs for EVERY body, not just the player:
|
||||
// a corpse sinking through geometry is a plain physics body with
|
||||
// no player-specific logic, so it is the cheapest possible control
|
||||
// on whether the movement code or the geometry is at fault, and it
|
||||
// is invisible to any player-filtered probe.
|
||||
//
|
||||
// The terrain sample below is INDEPENDENT of whatever the sweep
|
||||
// decided — it asks the landblock directly what the ground height
|
||||
// is under the body's own out-XY. Pairing that with the contact
|
||||
// plane's height at the same XY is what separates "terrain is
|
||||
// holding this body up" from "some object surface is". Read-only:
|
||||
// SampleTerrainWalkable takes no locks, mutates nothing, and is
|
||||
// not on the resolve's committed path.
|
||||
if (PhysicsDiagnostics.ProbeSupportEnabled)
|
||||
{
|
||||
Vector3 outPos = sp.CheckPos;
|
||||
TerrainWalkableSample? terrain =
|
||||
SampleTerrainWalkable(outPos.X, outPos.Y);
|
||||
|
||||
bool terrainSampled = terrain.HasValue
|
||||
&& PhysicsDiagnostics.TryPlaneZAt(
|
||||
terrain.Value.Plane, outPos.X, outPos.Y, out _);
|
||||
float terrainZ = float.NaN;
|
||||
if (terrainSampled)
|
||||
{
|
||||
PhysicsDiagnostics.TryPlaneZAt(
|
||||
terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ);
|
||||
}
|
||||
|
||||
PhysicsDiagnostics.LogSupport(
|
||||
moverId: movingEntityId,
|
||||
isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0,
|
||||
inPos: currentPos,
|
||||
inCell: cellId,
|
||||
targetPos: targetPos,
|
||||
outPos: outPos,
|
||||
outCell: sp.CheckCellId,
|
||||
ok: ok,
|
||||
groundedIn: isOnGround,
|
||||
contact: transition.ObjectInfo.Contact,
|
||||
onWalkable: transition.ObjectInfo.OnWalkable,
|
||||
contactPlaneValid: ci.ContactPlaneValid,
|
||||
contactPlane: ci.ContactPlane,
|
||||
contactPlaneCellId: ci.ContactPlaneCellId,
|
||||
contactPlaneIsWater: ci.ContactPlaneIsWater,
|
||||
contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource,
|
||||
lastKnownValid: ci.LastKnownContactPlaneValid,
|
||||
lastKnownPlane: ci.LastKnownContactPlane,
|
||||
terrainSampled: terrainSampled,
|
||||
terrainZ: terrainZ,
|
||||
terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero,
|
||||
terrainCellId: terrain?.CellId ?? 0u,
|
||||
terrainIsWater: terrain?.IsWater ?? false,
|
||||
walkablePolygon: sp.HasWalkablePolygon,
|
||||
lastWalkablePolygon: sp.HasLastWalkablePolygon,
|
||||
stepUpHeight: stepUpHeight,
|
||||
stepDownHeight: stepDownHeight,
|
||||
velocity: body?.Velocity ?? Vector3.Zero);
|
||||
}
|
||||
|
||||
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
|
||||
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
|
||||
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
|
||||
|
|
|
|||
|
|
@ -443,8 +443,32 @@ public sealed class CollisionInfo
|
|||
/// </summary>
|
||||
internal int ContactPlaneWriteCount { get; private set; }
|
||||
|
||||
public void SetContactPlane(Plane plane, uint cellId, bool isWater = false)
|
||||
public void SetContactPlane(
|
||||
Plane plane,
|
||||
uint cellId,
|
||||
bool isWater = false,
|
||||
// #337 [support] attribution — recorded on PhysicsDiagnostics, not on
|
||||
// this object; see the comment in the body for why. Compiler-supplied
|
||||
// literals: no call site passes these explicitly and none needs to.
|
||||
[System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "",
|
||||
[System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0)
|
||||
{
|
||||
// #337 attribution (2026-08-06 — TEMPORARY, strip with the probe
|
||||
// family). Recorded ABOVE the no-op guard on purpose: the meaning is
|
||||
// "the last site that ASSERTED this plane", not "the site that first
|
||||
// differed from the previous value" — a sweep that re-derives the
|
||||
// identical plane it was seeded with has still told you where the
|
||||
// plane comes from, and that is the fact the capture needs.
|
||||
//
|
||||
// It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's
|
||||
// stored members are compared member-for-member by the flat/graph
|
||||
// differential referee and by the scratch-reset poison test; a
|
||||
// diagnostic field there is state those oracles must then be taught to
|
||||
// ignore, which is how a referee stops refereeing. The latch is
|
||||
// [ThreadStatic] and is armed once per resolve — see
|
||||
// PhysicsDiagnostics.BeginContactPlaneAttribution.
|
||||
PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine);
|
||||
|
||||
// A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes
|
||||
// issue #96 (per-tick CP-write blowup) without removing the
|
||||
// PhysicsEngine.cs L622 seed that step_up depends on. When the
|
||||
|
|
@ -3755,6 +3779,26 @@ public sealed class Transition
|
|||
|
||||
foreach (ShadowEntry obj in nearbyObjs.Entries)
|
||||
{
|
||||
// #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the
|
||||
// physics-probe family). Emitted here, at the TOP of the candidate
|
||||
// loop, so it covers every object the mover comes near regardless
|
||||
// of what the exemptions and the reach filter later do with it —
|
||||
// an object whose collision geometry is absent or displaced must
|
||||
// be reported even when nothing ever tests it. Once per GfxObj per
|
||||
// process: the line describes an ASSET, not a moment.
|
||||
if (obj.CollisionType == ShadowCollisionType.BSP
|
||||
&& PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId))
|
||||
{
|
||||
PhysicsDiagnostics.LogGeometryFromAssets(
|
||||
gfxObjId: obj.GfxObjId,
|
||||
entityId: obj.EntityId,
|
||||
flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId),
|
||||
visual: engine.DataCache.GetVisualBounds(obj.GfxObjId),
|
||||
entityWorldPosition: obj.Position,
|
||||
entityScale: obj.Scale,
|
||||
registeredRadius: obj.Radius);
|
||||
}
|
||||
|
||||
// Self-skip — fix #42 (2026-05-05). Mirrors retail
|
||||
// CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt
|
||||
// 308931: `physobj != arg2->object_info.object` rejects the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue