From b61f5fd4fc2016a471050e8f5a46e1aea15cb9d6 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 17:29:44 +0200 Subject: [PATCH] =?UTF-8?q?probe(physics):=20ACDREAM=5FPROBE=5FREACH=20?= =?UTF-8?q?=E2=80=94=20discriminate=20#334's=20three=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #334 is "large static formations can be walked through on flat ground, and jumping over them drops you inside" (Neftet, user-reported, pre-existing — reproduced on 52175aa1, before AP-22/AP-152/AP-156/AD-10). The report as originally filed pointed at one mechanism. The user's clarification that the formations are permeable on the ground generally, not only at a boundary between two of them, widens it to three, and a DAT sweep can only say which objects COULD fail, never which one IS failing at the spot. So: measure in game, at the failing spot, and let the log choose. The three outcomes this probe must tell apart, at the player's own collision query: (a) the object IS a candidate in the cell and the broadphase reach filter rejects it before its BSP is consulted — AP-158 / #333. That filter measures |currPos - obj.Position|, the part ORIGIN, against the BSP root sphere's RADIUS plus an acdream-invented 2 m slack. The sphere's CENTRE is frequently not the part origin (376 of 973 installed physics-BSP parts sit further from it than half their own radius; worst 20.762 m), so geometry well inside the sphere can be rejected. (b) the object is not in the cell's candidate set at all — a membership or registration failure. AP-156's territory, which did not fix this. (c) the object is a candidate, is not rejected, and still contributes nothing because no usable physics BSP resolves for it. Two line types from Transition.FindObjCollisionsInCell: [reach-obj] one per candidate, carrying mover guid, target entity id, GfxObj id, cell, and its disposition — exempt-self, exempt-missile, rejected-reach, exempt-rule, exempt-ethereal-stepdown, no-shape, bsp-only-skip, tested-{ok,collided,adjusted,slid}. For BSP candidates it also carries the origin-measured distance the filter used, the centre-measured distance it should have used, the budget, the shortfall, and wouldAcceptAtCenter — the boolean that separates a false rejection from an honest one. Identity is on every line (feedback_probe_identity_attribution). [reach-q] one per cell query, with the per-disposition tallies AND the raw entry count, emitted EVEN WHEN THE CELL YIELDS ZERO. That last part is the point: without it, an absence of rejection lines could not distinguish "nothing was rejected" from "nothing was there", and a criterion that cannot fail in the presence of the bug it exists to catch is the trap this campaign has already been caught by once. `blocked` is the control — it proves the probe can see a working collision as well as a missing one. The BSP root sphere is resolved through the SAME production accessor registration uses (GetFlatGfxObj(id).PhysicsBsp root node, per LiveEntityCollisionBuilder and ShadowShapeBuilder), so the probe cannot report geometry differing from what the registry actually emitted — AP-156's lesson was exactly that: one resolver. Volume: [reach-obj] de-duplicates per (mover, target, cell) and re-emits at once whenever the disposition changes or the shortfall crosses a 0.5 m bucket, otherwise at most once a second; [reach-q] de-duplicates per (mover, cell) on the whole tally tuple, so any change in what the cell yielded emits immediately, otherwise at most twice a second. Both emit eagerly on change — which is exactly when the player walks into the formation — and go quiet when nothing is happening. Nothing is aggregated away. Filtered to the player mover, matching PhysicsResolveCapture, so NPC and remote dead-reckoning resolves do not pollute the capture. The helper is static and takes everything by parameter so no closure display class enters the resolve path: Slice I1's 0 B/resolve budget holds with the probe compiled in and switched off. TEMPORARY. Strip with the rest of the physics-probe family once #334 is scored; both the flag and the call site say so. Clean bin/obj, Release build, full suite 11,208 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 --- .../Physics/PhysicsDiagnostics.cs | 230 ++++++++++++++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 201 ++++++++++++++- 2 files changed, 429 insertions(+), 2 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index f8fcd904..e3afc37a 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1146,6 +1146,230 @@ public static class PhysicsDiagnostics public static bool ProbeSweptEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1"; + // ----------------------------------------------------------------------- + // #334 broadphase / candidate-disposition probe — TEMPORARY (2026-08-06) + // + // STRIP THIS WHOLE REGION together with the rest of the physics probe + // family once #334 is scored. + // + // #334 is "large static formations can be walked through on flat ground, + // and jumping over them drops you inside". Three candidate mechanisms + // survive the report, and this probe exists to DISCRIMINATE them, not to + // confirm any one of them: + // + // (a) the object IS a candidate in the cell but the broadphase reach + // filter rejects it before its BSP is consulted (AP-158 / #333); + // (b) the object is NOT in the cell's candidate set at all — a + // membership / registration failure (AP-156's territory, which did + // not fix this, or the static publication path never registered it); + // (c) the object IS a candidate and IS NOT rejected, but resolves to no + // usable shape — an empty shape list or an unresolved physics BSP. + // + // Context for (a): the filter measures |currPos - obj.Position| — the part + // ORIGIN — against obj.Radius (the physics-BSP ROOT sphere radius) plus a + // 2 m acdream-invented slack. The BSP root sphere's CENTRE is frequently + // NOT the part origin (376 of 973 installed physics-BSP parts sit further + // from it than half their own radius; worst 20.762 m). Where that offset + // exceeds the slack, geometry well inside the sphere is rejected. So every + // candidate line carries BOTH distances and the decisive + // wouldAcceptAtCenter boolean. + // + // Context for (c): AP-152 (4abd1b5e) made us emit BSP shapes exclusively + // where primitives were also emitted before. That did NOT cause #334 (the + // user reproduced on a pre-AP-152 build), but the same failure mode can + // exist independently, so no-shape is a first-class disposition here. + // ----------------------------------------------------------------------- + + /// + /// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with + /// the physics-probe family). Emits two line types from + /// Transition.FindObjCollisionsInCell: + /// + /// + /// [reach-q] — one per-cell query summary: the number of + /// shadow entries the cell yielded and the per-disposition tallies. + /// It is emitted even when the cell yields zero entries, which is + /// what makes outcome (b) visible: "cell yielded 0" at a spot where a + /// formation is plainly in front of the player is a registration gap, + /// and is recorded as data rather than as silence. Without this line an + /// absence of rejection lines would be ambiguous between "nothing was + /// rejected" and "nothing was there", which is precisely the + /// unfalsifiable-criterion trap this campaign has already been bitten + /// by. + /// [reach-obj] — one per candidate, carrying its identity + /// (mover guid, target entity id, GfxObj id, cell) and its + /// disposition: exempt-self, exempt-missile, + /// rejected-reach, exempt-rule, + /// exempt-ethereal-stepdown, no-shape, + /// bsp-only-skip, or tested:<result>. For BSP + /// candidates it also carries the origin-measured distance the filter + /// used, the centre-measured distance it should have used, the budget, + /// the shortfall, and wouldAcceptAtCenter. + /// + /// + /// + /// Volume control (this site is hot — it runs per cell per transitional + /// insert, and one resolve performs many inserts). [reach-obj] is + /// de-duplicated per (mover, target, cell) and re-emits immediately + /// whenever the disposition changes or the shortfall crosses a 0.5 m + /// bucket, and otherwise at most once per second. [reach-q] is + /// de-duplicated per (mover, cell) on the full tally tuple, so any change + /// in what the cell yielded emits at once, and otherwise at most twice a + /// second. Both therefore emit eagerly on change — which is exactly when + /// the player walks into the formation — and stay quiet when nothing is + /// happening. Nothing is aggregated away: every distinct state the query + /// passes through appears. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_REACH=1. Zero cost when off + /// (one static bool read per query). + /// + /// + public static bool ProbeReachEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REACH") == "1"; + + private static readonly object _reachGate = new(); + private static readonly Dictionary<(uint Mover, uint Entity, uint Cell), (long Ms, string Disp, int Bucket)> + _reachSeenObj = new(); + private static readonly Dictionary<(uint Mover, uint Cell), (long Ms, long Tally)> + _reachSeenQuery = new(); + + /// + /// One [reach-obj] line. Self-guards on + /// . + /// + /// The moving entity's guid — never omitted; a + /// per-entity probe without an identity produced a wrong root cause once + /// already (feedback_probe_identity_attribution). + /// What happened to this candidate. Use the + /// documented vocabulary on . + /// What the reach filter measured: the distance + /// from the swept sphere's current centre to the target's part ORIGIN. + /// Negative when not applicable. + /// What it should have measured: the distance to + /// the target's physics-BSP root sphere CENTRE. Negative when not + /// applicable. + /// The filter's admission threshold, 2 m slack + /// included. + /// The same threshold WITHOUT the slack — the + /// honest conservative bound once the real centre is used. + public static void LogReachCandidate( + uint moverId, + uint entityId, + uint gfxObjId, + uint cellId, + ShadowCollisionType shape, + string disposition, + bool stepDown, + float distOrigin, + float distCenter, + float objRadius, + float sphereRadius, + float movementLen, + float budget, + float centerBudget, + Vector3 objPos, + Vector3 bspCentreOffset, + Vector3 currPos) + { + if (!ProbeReachEnabled) return; + + float shortfall = distOrigin - budget; + bool wouldAcceptAtCenter = distCenter >= 0f && distCenter <= centerBudget; + int bucket = distOrigin < 0f ? 0 : (int)MathF.Floor(shortfall / 0.5f); + long now = Environment.TickCount64; + + lock (_reachGate) + { + var key = (moverId, entityId, cellId); + if (_reachSeenObj.TryGetValue(key, out var prev) + && string.Equals(prev.Disp, disposition, StringComparison.Ordinal) + && prev.Bucket == bucket + && now - prev.Ms < 1000) + { + return; + } + _reachSeenObj[key] = (now, disposition, bucket); + } + + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[reach-obj] mover=0x{0:X8} obj=0x{1:X8} gfx=0x{2:X8} cell=0x{3:X8} " + + "disp={4} shape={5} stepDown={6} distOrigin={7:F3} distCenter={8:F3} " + + "objR={9:F3} sphereR={10:F3} move={11:F3} budget={12:F3} " + + "centerBudget={13:F3} shortfall={14:F3} wouldAcceptAtCenter={15} " + + "objPos=({16:F2},{17:F2},{18:F2}) " + + "bspCentreOffset=({19:F2},{20:F2},{21:F2}) |bspCentreOffset|={22:F3} " + + "currPos=({23:F2},{24:F2},{25:F2}) t={26}", + moverId, entityId, gfxObjId, cellId, + disposition, shape, stepDown, distOrigin, distCenter, + objRadius, sphereRadius, movementLen, budget, + centerBudget, shortfall, wouldAcceptAtCenter, + objPos.X, objPos.Y, objPos.Z, + bspCentreOffset.X, bspCentreOffset.Y, bspCentreOffset.Z, bspCentreOffset.Length(), + currPos.X, currPos.Y, currPos.Z, now)); + } + + /// + /// One [reach-q] per-cell query summary. MUST be called even when + /// the cell yields zero entries — that is the whole point of the line. + /// Self-guards on . + /// + /// Shadow entries the cell yielded, before any + /// exemption. Zero here at a spot with visible geometry is outcome (b). + /// Candidates that survived the exemptions and were + /// measured by the reach filter. + /// Of those, how many the reach filter + /// rejected — outcome (a). + /// Candidates that passed the filter but resolved to + /// no usable shape — outcome (c). + /// Candidates that actually reached a shape test. + public static void LogReachQuery( + uint moverId, + uint cellId, + bool stepDown, + int inCell, + int exempt, + int reached, + int rejectedReach, + int noShape, + int tested, + int blocked, + Vector3 currPos) + { + if (!ProbeReachEnabled) return; + + // Tally fingerprint: any change in what this cell yielded re-emits at + // once. Deliberately includes every counter, so a state the query + // passes through cannot be swallowed by the throttle. + long tally = (((long)inCell * 31 + exempt) * 31 + reached) * 31; + tally = ((tally + rejectedReach) * 31 + noShape) * 31; + tally = ((tally + tested) * 31 + blocked) * 31 + (stepDown ? 1 : 0); + + long now = Environment.TickCount64; + lock (_reachGate) + { + var key = (moverId, cellId); + if (_reachSeenQuery.TryGetValue(key, out var prev) + && prev.Tally == tally + && now - prev.Ms < 500) + { + return; + } + _reachSeenQuery[key] = (now, tally); + } + + Console.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "[reach-q] mover=0x{0:X8} cell=0x{1:X8} stepDown={2} inCell={3} " + + "exempt={4} reached={5} rejectedReach={6} noShape={7} tested={8} " + + "blocked={9} pos=({10:F2},{11:F2},{12:F2}) t={13}", + moverId, cellId, stepDown, inCell, + exempt, reached, rejectedReach, noShape, tested, + blocked, currPos.X, currPos.Y, currPos.Z, now)); + } + /// /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). /// Emits one [tp-probe] line per teleport-pipeline event with a @@ -1356,6 +1580,12 @@ public static class PhysicsDiagnostics ProbePlacementFailEnabled = false; ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; + ProbeReachEnabled = false; + lock (_reachGate) + { + _reachSeenObj.Clear(); + _reachSeenQuery.Clear(); + } ProbeTeleportEnabled = false; ProbeRemoteTeleportEnabled = false; ProbeRemoteLandingEnabled = false; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index aace5aab..34b5da29 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3705,12 +3705,30 @@ public sealed class Transition if (engine.DataCache is null) return TransitionState.OK; var objsInCell = engine.ShadowObjects.GetObjectsInCell(cellId); - if (objsInCell.Count == 0) return TransitionState.OK; var sp = SpherePath; var oi = ObjectInfo; var ci = CollisionInfo; + // #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with + // the physics-probe family). Filtered to the player mover so NPC / + // remote dead-reckoning resolves do not pollute the capture, matching + // PhysicsResolveCapture's filter. The zero-entry query below is + // reported EXPLICITLY: "this cell yielded nothing" is outcome (b) — + // a registration gap — and must appear as data, never as silence. + bool reachProbe = PhysicsDiagnostics.ProbeReachEnabled && oi.IsPlayer; + + if (objsInCell.Count == 0) + { + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + inCell: 0, exempt: 0, reached: 0, rejectedReach: 0, + noShape: 0, tested: 0, blocked: 0, + currPos: sp.GlobalCurrCenter[0].Origin); + return TransitionState.OK; + } + // #42 diagnostic (2026-05-05): identify which static object causes // the airborne first-frame ~1m push. bool airborneDiag = !oi.Contact @@ -3730,6 +3748,11 @@ public sealed class Transition // (DoStepUp → TransitionalInsert → this) must not observe // registry mutations through the same reference mid-iteration. using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); + + // #334 probe tallies — see LogReachQuery for what each one decides. + int rExempt = 0, rReached = 0, rRejected = 0, rNoShape = 0, + rTested = 0, rBlocked = 0; + foreach (ShadowEntry obj in nearbyObjs.Entries) { // Self-skip — fix #42 (2026-05-05). Mirrors retail @@ -3745,13 +3768,29 @@ public sealed class Transition // horizontal push (validated by [SWEEP-OBJ] traces with // gfxObj=0x02000001 at exactly the entity's own position). if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId) + { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-self", sphereRadius, movement.Length(), currPos); + } continue; + } // OBJECTINFO::missile_ignore 0x0050CEB0 is an all-shapes // exemption. Retail computes it before the BSP-vs-cyl/sphere // dispatch; a true result reaches neither branch. if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags)) + { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-missile", sphereRadius, movement.Length(), currPos); + } continue; + } // Broad-phase: can the moving sphere reach this object? Vector3 deltaToCurr = currPos - obj.Position; @@ -3761,8 +3800,17 @@ public sealed class Transition else distToCurr = deltaToCurr.Length(); float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f; + if (reachProbe) rReached++; if (distToCurr > maxReach) + { + if (reachProbe) + { + rRejected++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "rejected-reach", sphereRadius, movement.Length(), currPos); + } continue; + } // Commit C 2026-04-29 — retail exemption block at the top of // CPhysicsObj::FindObjCollisions @@ -3773,7 +3821,15 @@ public sealed class Transition // landblock entries register with State=0 and Flags=None, // so this is a cheap fall-through for them. if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State)) + { + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-rule", sphereRadius, movement.Length(), currPos); + } continue; + } // Retail CPhysicsObj::FindObjCollisions ethereal branch // (pc:276795-276806 / 0x0050f0a2-0x0050f0c9). A target counts as @@ -3799,7 +3855,16 @@ public sealed class Transition bool etherealForTest = (obj.State & 0x4u) != 0 || (ObjectInfo.Ethereal && (obj.State & 0x1u) == 0); if (etherealForTest && sp.StepDown) - continue; // retail pc:276799 — ethereal target not tested in step-down + { + // retail pc:276799 — ethereal target not tested in step-down + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "exempt-ethereal-stepdown", sphereRadius, movement.Length(), currPos); + } + continue; + } sp.ObstructionEthereal = etherealForTest; // L.2a slice 3 (2026-05-12): snapshot collision-normal state so @@ -3848,6 +3913,15 @@ public sealed class Transition // clear (pc:276989) fires after shape tests; we clear early here to // leave the flag clean for the next iteration. sp.ObstructionEthereal = false; + // #334 outcome (c): the entry IS a candidate, the reach + // filter DID admit it, and it still contributes nothing + // because no usable physics BSP resolved for its GfxObj. + if (reachProbe) + { + rNoShape++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "no-shape", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3915,6 +3989,12 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "bsp-only-skip", sphereRadius, movement.Length(), currPos); + } continue; } @@ -3958,6 +4038,12 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } + if (reachProbe) + { + rExempt++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + "bsp-only-skip", sphereRadius, movement.Length(), currPos); + } continue; } @@ -4121,6 +4207,24 @@ public sealed class Transition // per-object test body before the outer loop continues. sp.ObstructionEthereal = false; + // #334: this candidate actually reached a shape test. `result` is + // post-Layer-2, i.e. what the query will really act on. + if (reachProbe) + { + rTested++; + if (result != TransitionState.OK) rBlocked++; + ProbeReachCandidate(engine, oi, sp, cellId, in obj, + result switch + { + TransitionState.OK => "tested-ok", + TransitionState.Collided => "tested-collided", + TransitionState.Adjusted => "tested-adjusted", + TransitionState.Slid => "tested-slid", + _ => "tested-invalid", + }, + sphereRadius, movement.Length(), currPos); + } + if (result != TransitionState.OK) { if (airborneDiag) @@ -4133,13 +4237,106 @@ public sealed class Transition $"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " + $"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})"); } + // #334 (TEMPORARY): the early exit is a real query outcome, so + // it must be summarised too — otherwise the summary would + // under-report exactly the queries where something DID block, + // and "blocked" is the control that proves the probe can see a + // working collision as well as a missing one. + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + nearbyObjs.Entries.Length, rExempt, rReached, + rRejected, rNoShape, rTested, rBlocked, currPos); return result; } } + if (reachProbe) + PhysicsDiagnostics.LogReachQuery( + oi.SelfEntityId, cellId, sp.StepDown, + nearbyObjs.Entries.Length, rExempt, rReached, + rRejected, rNoShape, rTested, rBlocked, currPos); + return TransitionState.OK; } + /// + /// #334 candidate-disposition probe helper (2026-08-06 — TEMPORARY, strip + /// with the physics-probe family). Static, and takes everything by + /// parameter, so it introduces no closure display class into + /// — Slice I1's 0 B/resolve budget + /// must hold with the probe compiled in and switched off. + /// + /// + /// The target's physics-BSP ROOT sphere is resolved through the SAME + /// production accessor registration used + /// (GetFlatGfxObj(id).PhysicsBsp's root node, per + /// LiveEntityCollisionBuilder and ShadowShapeBuilder), so the + /// probe cannot report geometry that differs from what the registry + /// actually emitted. AP-156's lesson was exactly that: one resolver. + /// + /// + private static void ProbeReachCandidate( + PhysicsEngine engine, + ObjectInfo oi, + SpherePath sp, + uint cellId, + in ShadowEntry obj, + string disposition, + float sphereRadius, + float movementLen, + Vector3 currPos) + { + bool xyOnly = obj.CollisionType == ShadowCollisionType.Cylinder; + + Vector3 dOrigin = currPos - obj.Position; + float distOrigin = xyOnly + ? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y) + : dOrigin.Length(); + + // World-space offset from the part ORIGIN (what the filter measures + // against) to the BSP root sphere CENTRE (what it should measure + // against). Zero for non-BSP shapes, whose Position already IS their + // centre. + Vector3 bspCentreOffset = Vector3.Zero; + if (obj.CollisionType == ShadowCollisionType.BSP) + { + var flatBsp = engine.DataCache?.GetFlatGfxObj(obj.GfxObjId)?.PhysicsBsp; + if (flatBsp is { RootIndex: >= 0 }) + { + bspCentreOffset = Vector3.Transform( + flatBsp.Nodes[flatBsp.RootIndex].BoundingSphere.Origin * obj.Scale, + obj.Rotation); + } + } + + Vector3 dCentre = currPos - (obj.Position + bspCentreOffset); + float distCentre = xyOnly + ? MathF.Sqrt(dCentre.X * dCentre.X + dCentre.Y * dCentre.Y) + : dCentre.Length(); + + float budget = sphereRadius + obj.Radius + movementLen + 2f; + + PhysicsDiagnostics.LogReachCandidate( + moverId: oi.SelfEntityId, + entityId: obj.EntityId, + gfxObjId: obj.GfxObjId, + cellId: cellId, + shape: obj.CollisionType, + disposition: disposition, + stepDown: sp.StepDown, + distOrigin: distOrigin, + distCenter: distCentre, + objRadius: obj.Radius, + sphereRadius: sphereRadius, + movementLen: movementLen, + budget: budget, + centerBudget: budget - 2f, + objPos: obj.Position, + bspCentreOffset: bspCentreOffset, + currPos: currPos); + } + /// /// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel — /// CSortCell::find_collisions (Ghidra 0x005340a0): an outdoor