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