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