acdream/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
Erik 49a7e90652 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>
2026-08-06 19:49:59 +02:00

2531 lines
118 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// L.2a slice 1 (2026-05-12) — runtime-toggleable physics probe flags.
/// Initialized from env vars at process start; flippable at runtime via
/// the DebugPanel mirror (or by direct assignment). Log call sites read
/// these statics so a checkbox toggle takes effect on the next resolve
/// without relaunching.
///
/// <para>
/// L.2d slice 1 (2026-05-13) adds <see cref="ProbeBuildingEnabled"/> +
/// the <see cref="LastBspHitPoly"/> diagnostic side-channel. Future
/// slices may fold the older <c>ACDREAM_DUMP_*</c> env vars into this
/// class for unified runtime toggling. Until then, those older flags
/// remain sticky-at-startup per their original implementation.
/// </para>
/// </summary>
public static class PhysicsDiagnostics
{
/// <summary>
/// Slice I5 graph/flat referee cadence. Zero disables the diagnostic;
/// N samples every Nth collision-traversal entry. The graph path remains
/// authoritative regardless of the comparison result.
/// </summary>
public static int CollisionShadowSampleEvery { get; set; } =
ParsePositiveInt(
Environment.GetEnvironmentVariable(
"ACDREAM_COLLISION_SHADOW_EVERY"));
/// <summary>
/// Directory for deterministic Slice I5 mismatch artifacts.
/// </summary>
public static string CollisionShadowArtifactDirectory { get; set; } =
Environment.GetEnvironmentVariable(
"ACDREAM_COLLISION_SHADOW_DIR")
?? Path.Combine(
Environment.CurrentDirectory,
".test-out",
"collision-shadow");
/// <summary>
/// When true, <see cref="PhysicsEngine.ResolveWithTransition"/> emits
/// one structured <c>[resolve]</c> line per call: input + target +
/// output position/cell, grounded state, contact-plane status,
/// collision-normal validity, walkable polygon status, moving entity
/// id. Initial state from <c>ACDREAM_PROBE_RESOLVE=1</c>.
/// </summary>
public static bool ProbeResolveEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
/// <summary>
/// When true, every change to <c>PlayerMovementController.CellId</c>
/// emits one <c>[cell-transit]</c> line: old → new cell, current
/// world position, reason tag (<c>resolver</c> / <c>teleport</c>).
/// Initial state from <c>ACDREAM_PROBE_CELL=1</c>.
/// </summary>
public static bool ProbeCellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
/// <summary>
/// C4 route 7 (pickup/parent/delete) connected-gate confirmation
/// signal — TEMPORARY, part of the existing probe family. When true,
/// one <c>[child-cell]</c> line is emitted per Runtime committed-child
/// canonical cell write: parent guid, child guid, old and new cell,
/// and a cause tag (<c>attach</c> / <c>headless-attach</c> /
/// <c>propagate</c> / <c>withdraw</c> / <c>delete</c>). A clean-looking
/// session with zero <c>cause=propagate</c> lines during a landblock
/// crossing is a not-run, not a pass. Initial state from
/// <c>ACDREAM_PROBE_CHILD_CELL=1</c>.
/// </summary>
public static bool ProbeChildCellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CHILD_CELL") == "1";
/// <summary>
/// Issue #309's connected-gate confirmation signal (2026-08-04, C4 route
/// 4b-2). The gate's quiescence steps need a ForcePosition to land INSIDE
/// a transient collision-prefix quiescence window, which a tester cannot
/// synchronise with a teleport or portal arrival — so without a signal a
/// clean teleport and a correctly-parked one look identical and the gate
/// passes while broken.
///
/// <para>When true, <c>RuntimeSetPositionState</c> emits one
/// <c>[park]</c> line when a placement parks (entity, park cause, the
/// blocking landblock prefix if any, the caller's pre-snap cell, the
/// POST-snap cell the rollback would restore into, and whether the
/// rollback was captured or declined), and one <c>[park-restore]</c> line
/// when a cancelled park's withdrawal is rolled back or its residency arm
/// is declined at restore time. Low volume: parks are rare, and nothing
/// is emitted for an ordinary committing placement. Zero cost when off
/// (one static-bool read per park).</para>
///
/// <para>Initial state from <c>ACDREAM_PROBE_PARK=1</c>.</para>
/// </summary>
public static bool ProbeParkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PARK") == "1";
/// <summary>
/// #283 reachability probe (2026-08-03). Runtime rebases its world frame
/// the instant an accepted Position carries TeleportAdvanced, while App's
/// <c>LiveWorldOriginState</c> rebases only once
/// <c>StreamingOriginRecenterCoordinator</c> observes old-window
/// retirement completion — many frames later. Between those edges the two
/// owners can disagree by the source-to-destination landblock delta, and
/// anything converted in that gap lands a multiple of 192 m from the
/// geometry App is building.
///
/// <para>A 2026-08-03 connected run answered that question: 11 reveals
/// across six landblocks spanning ~45 km recorded ZERO disagreements,
/// because the recenter detaches every resident landblock before adopting
/// the new origin and so serializes the two rebases. Disagreement is now
/// a terminal invariant
/// (<c>LiveWorldOriginState.EnsureAgreesWithRuntimeFrame</c>) rather than
/// something to observe.</para>
///
/// <para>When true, this now emits one verbose <c>[world-frame] agree</c>
/// line per projected conversion recording the centre both owners used —
/// what you want when investigating a placement that looks displaced but
/// is NOT a frame disagreement. Measurement only; it never gates
/// placement. Initial state from
/// <c>ACDREAM_PROBE_WORLD_FRAME=1</c>.</para>
/// </summary>
public static bool ProbeWorldFrameEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_WORLD_FRAME") == "1";
/// <summary>
/// Stuck-cast/missing-attack investigation (2026-07-30). When true,
/// every REMOTE ground-contact edge (HitGround / LeaveGround — each of
/// which drains the mover's pending action animations via retail's
/// <c>HandleEnterWorld</c>) emits one <c>[remote-edge]</c> line with the
/// server guid. Correlates eaten attack animations with spurious
/// contact flickers. Rides <c>ACDREAM_DUMP_MOTION=1</c> so one flag
/// captures the whole animation story.
/// </summary>
public static bool DumpMotionEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1";
/// <summary>
/// L.2d slice 1 (2026-05-13). When true, every BSP-shadow-entry hit
/// attributed by <c>TransitionTypes.FindObjCollisions</c> emits a
/// multi-line <c>[resolve-bldg]</c> entry: which part (partIdx vs 0),
/// physics-BSP root radius vs visual AABB radius, world-space entity
/// origin, and the specific hit polygon's vertices in both
/// object-local and world space. Designed to distinguish the three
/// L.2d hypotheses (wrong BSP loaded / over-registered parts /
/// BSPQuery flaw) from a single Holtburg-doorway capture.
///
/// <para>
/// Also gates a one-time <c>[entity-source]</c> log line at every
/// <c>ShadowObjects.Register(...)</c> call site in <c>GameWindow</c>
/// — makes <c>entityId=0xA9B479</c> in a probe line greppable to its
/// source registration within the same log file.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_BUILDING=1</c>. Mirrorable
/// via <c>DebugVM.ProbeBuilding</c> when <c>ACDREAM_DEVTOOLS=1</c>.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeBuildingEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_BUILDING") == "1";
/// <summary>
/// A6.P5 (2026-05-25) — dump the cellSet that
/// <c>BuildCellSetAndPickContaining</c> produces. One line per call:
/// seed cell, sphere world XY, candidate count, and the full candidate
/// list (hex). Pair with <c>[bsp-test]</c> / <c>[resolve]</c> to see
/// whether the door's outdoor cell is reachable from the player's
/// current indoor cell via the portal-walk.
/// </summary>
public static bool ProbeCellSetEnabled { get; set; }
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1";
/// <summary>
/// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe.
/// One <c>[sticky]</c> line per StickyManager lifecycle event (STICK /
/// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed
/// <c>AdjustOffset</c> tick (guid, signed gap distance, applied delta,
/// heading delta), plus <c>[sticky-snap-skip]</c> lines at the NPC
/// UpdatePosition handler when a server hard-snap is suppressed because
/// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck
/// count); capture-session only. All lines carry the guid
/// (feedback_probe_identity_attribution).
/// </summary>
public static bool ProbeStickyEnabled { get; set; }
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1";
/// <summary>
/// Bug A investigation (2026-08-04, live route 4a test — see
/// <c>docs/ISSUES.md</c> #32 and
/// <c>docs/research/2026-08-04-remote-landing-investigation.md</c>): a
/// PLAYER remote sometimes stays in the falling animation after landing,
/// then snaps to the grounded pose after a delay. Three hypotheses were
/// identified, with non-overlapping fixes, so this probe captures the
/// state needed to discriminate them at BOTH remote landing-detection
/// sites: the UpdatePosition landing block in
/// <c>LiveEntityNetworkUpdateController</c> (site=<c>controller</c>) and
/// the per-tick VectorUpdate landing branch in
/// <c>RuntimeRemotePhysicsUpdater</c> (site=<c>per-tick</c>).
///
/// <para>
/// When true, emits one <c>[remote-landing]</c> line per landing edge via
/// <see cref="LogRemoteLanding"/>, capturing: the airborne flag on entry,
/// whether the Gravity state bit is still set (the
/// <c>MotionInterpreter.HitGround</c> gate at
/// <c>MotionInterpreter.cs</c>:~2435 no-ops silently when it is NOT —
/// hypothesis 1), the Contact/OnWalkable transient bits, whether a
/// <c>DefaultSink</c> is bound (hypothesis 2 — nothing to dispatch
/// through), the per-tick site's <c>resolveResult.IsOnGround</c> (n/a at
/// the controller site, which has no resolver call), and the
/// sequencer's current style/motion id (hypothesis 3 — the sequencer
/// disagrees with what the re-apply should produce). A companion
/// <c>[remote-landing-gate]</c> line fires via
/// <see cref="LogRemoteLandingGateNoOp"/> whenever a landing site is
/// reached but Gravity is already clear, so the HitGround call about to
/// happen is a silent no-op — the single most valuable signal for
/// hypothesis 1.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_REMOTE_LANDING=1</c>. TEMPORARY —
/// strip once the discriminating live-test capture has landed.
/// </para>
/// </summary>
public static bool ProbeRemoteLandingEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_LANDING") == "1";
/// <summary>
/// C4 route 4b-3 (2026-08-04) live-execution proof (process rule 5): one
/// <c>[remote-teleport]</c> line per routed teleport arm
/// (<c>LiveEntityNetworkUpdateController.ApplyRemoteContactRouting</c>'s
/// teleport dispatch), so a connected test can confirm the new arm
/// actually executed rather than inferring it from a clean-looking
/// session (#309's lesson — a session with zero probe lines is a
/// not-run). Initial state from <c>ACDREAM_PROBE_REMOTE_TELEPORT=1</c>.
/// TEMPORARY — strip with the rest of the probe family once the
/// two-client connected teleport gate has landed.
/// </summary>
public static bool ProbeRemoteTeleportEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_TELEPORT") == "1";
/// <summary>
/// Emit one <c>[remote-teleport]</c> line for one routed teleport arm.
/// Self-guards on <see cref="ProbeRemoteTeleportEnabled"/>, so callers
/// need not pre-check. <paramref name="cause"/> is <c>"teleport-ts"</c>
/// or <c>"cellless"</c> (D1's two classifier predicates); the guid, the
/// hook's own currency result, and the placement status are the fields
/// the connected gate recipe reads back.
/// </summary>
public static void LogRemoteTeleport(
uint guid,
string cause,
bool hookRan,
string placementStatus)
{
if (!ProbeRemoteTeleportEnabled) return;
Console.WriteLine(System.FormattableString.Invariant(
$"[remote-teleport] guid=0x{guid:X8} cause={cause} hookRan={hookRan} placement={placementStatus}"));
}
/// <summary>
/// Emit one <c>[remote-landing]</c> line for a remote landing-detection
/// edge. Caller MUST guard with
/// <c>if (!ProbeRemoteLandingEnabled) return;</c> before calling.
/// <paramref name="resolveIsOnGround"/> is <see langword="null"/> at the
/// controller site (no per-frame resolver call at that edge).
/// </summary>
public static void LogRemoteLanding(
string site,
uint guid,
bool airborneBefore,
bool gravitySet,
bool contact,
bool onWalkable,
bool hasDefaultSink,
bool? resolveIsOnGround,
uint sequencerStyle,
uint sequencerMotion)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string onGroundText = resolveIsOnGround.HasValue
? resolveIsOnGround.Value.ToString()
: "n/a";
Console.WriteLine(string.Format(ci,
"[remote-landing] site={0} guid=0x{1:X8} t={2} airborneBefore={3} " +
"gravitySet={4} contact={5} onWalkable={6} hasDefaultSink={7} " +
"resolveIsOnGround={8} seqStyle=0x{9:X8} seqMotion=0x{10:X8}",
site, guid, Environment.TickCount64, airborneBefore,
gravitySet, contact, onWalkable, hasDefaultSink, onGroundText,
sequencerStyle, sequencerMotion));
}
/// <summary>
/// Emit one <c>[remote-landing-gate]</c> line when a landing edge is
/// reached but the Gravity state bit is already clear, so the imminent
/// <c>MotionInterpreter.HitGround</c> call will silently no-op (the
/// gate at <c>MotionInterpreter.cs</c>:~2435) — hypothesis 1 for Bug A.
/// Caller MUST guard with
/// <c>if (!ProbeRemoteLandingEnabled) return;</c> before calling.
/// </summary>
public static void LogRemoteLandingGateNoOp(string site, uint guid)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[remote-landing-gate] site={site} guid=0x{guid:X8} t={Environment.TickCount64} NOOP gravityAlreadyClear=true"));
}
// ── [remote-landing-after] — the OUTCOME half of the Bug A probe ──────
//
// docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md §6.1: the
// [remote-landing] line above reads state immediately BEFORE
// MovementManager.HitGround, so it cannot separate (a) the edge never
// firing, from (b) HitGround firing and something re-asserting Falling,
// from (c) the motion-table sink refusing the cycle. The companion line
// below reads the same entity immediately AFTER the call, at the same
// two sites, and pairs 1:1 with it (same site + guid, next line for
// that guid).
//
// Dispatch capture: MotionTableDispatchSink.ApplyMotion discards the
// MotionTableManagerError code (it returns bool) and HitGround itself
// returns void, so nothing at the call site can observe what the sink
// did. These [ThreadStatic] latches carry it across the synchronous
// HitGround call without changing any signature: the call site calls
// BeginRemoteLandingDispatchCapture() right before HitGround, the sink
// records each ApplyMotion, and LogRemoteLandingAfter reports the count
// plus the LAST ApplyMotion — which for the landing re-apply
// (ApplyInterpretedMovement, MotionInterpreter.cs:2842-2903) is the
// decisive one: either Falling (:2867) or InterpretedState.ForwardCommand
// (:2878). Thread-static because the whole window is synchronous on the
// ticking thread, and headless hosts tick several sessions in parallel.
//
// Every member here is inert unless ProbeRemoteLandingEnabled is true.
// TEMPORARY — strip with the rest of the ACDREAM_PROBE_REMOTE_LANDING
// family once the discriminating live capture has landed.
[ThreadStatic] private static int _remoteLandingApplyCalls;
[ThreadStatic] private static uint _remoteLandingLastApplyMotion;
[ThreadStatic] private static uint _remoteLandingLastApplyResult;
/// <summary>
/// Arm the per-call sink-dispatch capture read back by
/// <see cref="LogRemoteLandingAfter"/>. Call immediately before
/// <c>MovementManager.HitGround</c>. No-op unless
/// <see cref="ProbeRemoteLandingEnabled"/>.
/// </summary>
public static void BeginRemoteLandingDispatchCapture()
{
if (!ProbeRemoteLandingEnabled) return;
_remoteLandingApplyCalls = 0;
_remoteLandingLastApplyMotion = 0;
_remoteLandingLastApplyResult = 0;
}
/// <summary>
/// Record one <c>IInterpretedMotionSink.ApplyMotion</c> dispatch and its
/// raw <c>MotionTableManagerError</c> code. Called by
/// <see cref="Motion.MotionTableDispatchSink"/>; self-guarded, so it is
/// a single flag test when the probe is off.
/// </summary>
public static void RecordRemoteLandingDispatch(uint motion, uint result)
{
if (!ProbeRemoteLandingEnabled) return;
_remoteLandingApplyCalls++;
_remoteLandingLastApplyMotion = motion;
_remoteLandingLastApplyResult = result;
}
/// <summary>
/// Emit one <c>[remote-landing-after]</c> line for the landing edge whose
/// <c>[remote-landing]</c> line was just written. Caller MUST guard with
/// <c>if (!ProbeRemoteLandingEnabled) return;</c> before calling, and MUST
/// emit it before any post-HitGround ownership re-check can return — a
/// before-line with no after-line therefore means the call site threw.
/// <paramref name="hitGroundInvoked"/> is <see langword="false"/> if a
/// gate short-circuited between the two lines (no such gate exists at
/// either site today; the field exists so the absence is stated rather
/// than inferred from a missing line).
/// </summary>
public static void LogRemoteLandingAfter(
string site,
uint guid,
bool hitGroundInvoked,
uint sequencerStyle,
uint sequencerMotion,
uint forwardCommand)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[remote-landing-after] site={0} guid=0x{1:X8} t={2} " +
"hitGroundInvoked={3} seqStyle=0x{4:X8} seqMotion=0x{5:X8} " +
"fwdCmd=0x{6:X8} sinkApplyCalls={7} sinkLastMotion=0x{8:X8} " +
"sinkLastResult=0x{9:X8}",
site, guid, Environment.TickCount64,
hitGroundInvoked, sequencerStyle, sequencerMotion,
forwardCommand, _remoteLandingApplyCalls,
_remoteLandingLastApplyMotion, _remoteLandingLastApplyResult));
}
// ── [remote-slide-*] — Bug B (remote ledge/roof slide) capture ────────
//
// docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2 names TWO
// live blip producers and states that NO existing probe distinguishes
// them:
// • Candidate 1 — AP-87's `bodyToTarget > 4 m` body snap in
// RuntimeRemoteSteadyStatePosition.ApplyInterpolate (:129-137).
// • Candidate 2 — InterpolationManager's own retail-faithful
// `node_fail_counter > 3` snap-to-tail (:458-467; retail
// InterpolationManager::UseTime @0x00555f20). That code is CORRECT
// and is only reachable because §1 froze the body; this probe
// OBSERVES it and must never be read as a reason to change it.
// Both emit `[remote-slide-snap]` with a distinct `producer=` tag, so
// one grep finds every blip and the tag alone answers "which one".
//
// The same family also settles the diagnosis's two load-bearing NOT
// ESTABLISHED items:
// • #1 (Shape A vs Shape B) — `[remote-slide-up] wireGrounded=` is the
// raw ACE PositionFlags.IsGrounded bit for the accepted packet,
// emitted at the ONE routing point both remote arms pass through,
// AHEAD of the NoPositionOperation early returns, so a Shape-A slide
// (every packet `wireGrounded=false disp=NoPositionOperation`) is
// visible even though acdream writes nothing for it.
// • #2 (is the roof steep in OUR collision data) —
// `[remote-slide-tick] bodyCpNz=/rsCpNz=` against `floorZ=`.
// `[remote-slide-vec]` covers the 0xF74E half of NOT ESTABLISHED #4: an
// absence of lines during a slide is itself the answer.
//
// Pure reads only. Nothing here gates, orders, or mutates production
// state; the throttle dictionary and the attribution latch are
// probe-owned and [ThreadStatic] because a headless host ticks several
// sessions in parallel.
//
// TEMPORARY — strip the whole ACDREAM_PROBE_REMOTE_SLIDE family once the
// two-client roof capture has landed.
private static readonly string? RemoteSlideProbeRaw =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_SLIDE");
/// <summary>
/// Initial state from <c>ACDREAM_PROBE_REMOTE_SLIDE</c>. <c>1</c> enables
/// the family for every remote; a comma-separated hex GUID list (e.g.
/// <c>0x50000123,0x8001ABCD</c>) enables it only for those GUIDs, which is
/// what keeps a live two-client capture readable. Unset/empty = inert.
/// </summary>
public static bool ProbeRemoteSlideEnabled { get; set; } =
!string.IsNullOrWhiteSpace(RemoteSlideProbeRaw);
/// <summary>
/// Optional GUID allow-list for <see cref="ProbeRemoteSlideEnabled"/>.
/// Empty means "every remote".
/// </summary>
public static IReadOnlySet<uint> ProbeRemoteSlideGuids { get; set; } =
RemoteSlideProbeRaw is null || RemoteSlideProbeRaw.Trim() == "1"
? new HashSet<uint>()
: ParseHexIdList(RemoteSlideProbeRaw);
/// <summary>
/// The single gate every <c>[remote-slide-*]</c> call site checks first.
/// One static bool read plus (only when enabled) one set lookup.
/// </summary>
public static bool ShouldLogRemoteSlide(uint guid) =>
ProbeRemoteSlideEnabled
&& (ProbeRemoteSlideGuids.Count == 0
|| ProbeRemoteSlideGuids.Contains(guid));
// Neither InterpolationManager nor RuntimeRemoteSteadyStatePosition has
// access to a server GUID (RemoteMotion does not carry one), and
// claude-memory/feedback_probe_identity_attribution.md makes the GUID
// mandatory on a per-entity probe. Rather than widen either production
// signature, the two per-remote windows that call into them stamp this
// latch first — the same [ThreadStatic] shape the [remote-landing-after]
// dispatch capture already uses, and for the same reason (the whole
// window is synchronous on the ticking thread).
[ThreadStatic] private static uint _remoteSlideAttributionGuid;
/// <summary>
/// Stamp the GUID that any <c>[remote-slide-*]</c> line emitted from
/// inside the following synchronous per-remote window belongs to. No-op
/// unless <see cref="ProbeRemoteSlideEnabled"/>.
/// </summary>
public static void BeginRemoteSlideAttribution(uint guid)
{
if (!ProbeRemoteSlideEnabled) return;
_remoteSlideAttributionGuid = guid;
}
/// <summary>The GUID stamped by the innermost
/// <see cref="BeginRemoteSlideAttribution"/>; <c>0</c> when unknown.</summary>
public static uint RemoteSlideAttributionGuid => _remoteSlideAttributionGuid;
/// <summary>
/// Per-GUID rate limit for the ~30 Hz <c>[remote-slide-tick]</c> line.
/// A resting remote emits at most one line per this interval; any change
/// in the caller-supplied signature (the contact/walkable/airborne/
/// gravity/steep/moved bit pattern) emits immediately, so every
/// transition is captured at full fidelity.
/// </summary>
private const long RemoteSlideTickThrottleMs = 200;
[ThreadStatic]
private static Dictionary<uint, (long Ms, int Signature)>? _remoteSlideTickGate;
/// <summary>
/// Edge-or-throttle admission for <see cref="LogRemoteSlideTick"/>.
/// Returns true when the line should be emitted; updates the per-GUID
/// gate as a side effect. Probe-owned state only.
/// </summary>
public static bool ShouldEmitRemoteSlideTick(uint guid, int signature)
{
if (!ShouldLogRemoteSlide(guid)) return false;
_remoteSlideTickGate ??= new Dictionary<uint, (long, int)>();
long now = Environment.TickCount64;
if (_remoteSlideTickGate.TryGetValue(guid, out var previous)
&& previous.Signature == signature
&& now - previous.Ms < RemoteSlideTickThrottleMs)
{
return false;
}
_remoteSlideTickGate[guid] = (now, signature);
return true;
}
/// <summary>
/// One <c>[remote-slide-up]</c> line per accepted remote Position, from
/// the single point BOTH remote arms pass through — ahead of the
/// <c>NoPositionOperation</c> early returns, so a Shape-A slide (which
/// acdream answers by writing nothing) still produces a line.
/// Caller MUST guard with <see cref="ShouldLogRemoteSlide"/>.
/// </summary>
public static void LogRemoteSlideUp(
uint guid,
bool wireGrounded,
Vector3? wireVelocity,
string disposition,
float? playerDistance,
float bodyToTarget,
float bodySnapThreshold,
bool willBeDrTicked,
// Read at packet ENTRY. The player-remote arm stamps
// LastServerPosTime between here and the routing call, so the value
// ApplyInterpolate actually tests can differ — the
// [remote-slide-snap] producer=ap87-4m line reports that one. Hence
// the distinct firstUpAtEntry= field name.
bool firstUp,
bool airborne,
bool contact,
bool onWalkable,
bool gravity,
Vector3 bodyVelocity,
bool contactPlaneValid,
float contactPlaneNormalZ,
Vector3 wirePosition,
Vector3 bodyPosition,
int interpQueueDepth,
int interpFailCount)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string wireVel = wireVelocity is { } wv
? string.Format(ci, "({0:F3},{1:F3},{2:F3})", wv.X, wv.Y, wv.Z)
: "null";
string playerDist = playerDistance is { } pd
? pd.ToString("F2", ci)
: "n/a";
Console.WriteLine(string.Format(ci,
"[remote-slide-up] guid=0x{0:X8} t={1} wireGrounded={2} wireVel={3} " +
"disp={4} playerDist={5} bodyToTarget={6:F3} snapThreshold={7:F3} " +
"willBeDrTicked={8} firstUpAtEntry={9} airborne={10} contact={11} " +
"onWalkable={12} gravity={13} bodyVel=({14:F3},{15:F3},{16:F3}) " +
"cpValid={17} cpNz={18:F4} floorZ={19:F4} steep={20} " +
"wirePos=({21:F3},{22:F3},{23:F3}) bodyPos=({24:F3},{25:F3},{26:F3}) " +
"queueDepth={27} failCount={28}",
guid, Environment.TickCount64, wireGrounded, wireVel,
disposition, playerDist, bodyToTarget, bodySnapThreshold,
willBeDrTicked, firstUp, airborne, contact,
onWalkable, gravity,
bodyVelocity.X, bodyVelocity.Y, bodyVelocity.Z,
contactPlaneValid, contactPlaneNormalZ, PhysicsGlobals.FloorZ,
contactPlaneValid && contactPlaneNormalZ < PhysicsGlobals.FloorZ,
wirePosition.X, wirePosition.Y, wirePosition.Z,
bodyPosition.X, bodyPosition.Y, bodyPosition.Z,
interpQueueDepth, interpFailCount));
}
/// <summary>
/// One <c>[remote-slide-vec]</c> line per accepted remote 0xF74E
/// VectorUpdate. NOT ESTABLISHED #4 asks whether ACE relays one at all
/// during a slide — the ABSENCE of these lines across a captured slide
/// window is the answer, which is why this sits on the committed path
/// rather than inside the <c>Velocity.Z &gt; 0.5f</c> airborne branch.
/// Caller MUST guard with <see cref="ShouldLogRemoteSlide"/>.
/// </summary>
public static void LogRemoteSlideVector(
uint guid,
Vector3 wireVelocity,
Vector3 wireOmega,
bool willMarkAirborne,
bool airborneBefore,
bool contact,
bool onWalkable,
bool gravity,
Vector3 bodyVelocity,
bool contactPlaneValid,
float contactPlaneNormalZ)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[remote-slide-vec] guid=0x{0:X8} t={1} " +
"wireVel=({2:F3},{3:F3},{4:F3}) wireOmega=({5:F3},{6:F3},{7:F3}) " +
"willMarkAirborne={8} airborneBefore={9} contact={10} " +
"onWalkable={11} gravity={12} bodyVel=({13:F3},{14:F3},{15:F3}) " +
"cpValid={16} cpNz={17:F4} floorZ={18:F4} steep={19}",
guid, Environment.TickCount64,
wireVelocity.X, wireVelocity.Y, wireVelocity.Z,
wireOmega.X, wireOmega.Y, wireOmega.Z,
willMarkAirborne, airborneBefore, contact,
onWalkable, gravity,
bodyVelocity.X, bodyVelocity.Y, bodyVelocity.Z,
contactPlaneValid, contactPlaneNormalZ, PhysicsGlobals.FloorZ,
contactPlaneValid && contactPlaneNormalZ < PhysicsGlobals.FloorZ));
}
/// <summary>
/// Blip producer <b>Candidate 1</b> — AP-87's <c>bodyToTarget &gt; 4 m</c>
/// body snap (<c>RuntimeRemoteSteadyStatePosition.ApplyInterpolate</c>).
/// Tagged <c>producer=ap87-4m</c>. Caller MUST guard with
/// <see cref="ShouldLogRemoteSlide"/>.
/// </summary>
public static void LogRemoteSlideBodySnap(
uint guid,
bool firstUp,
bool willBeDrTicked,
float bodyToTarget,
float threshold,
Vector3 bodyPosition,
Vector3 targetPosition,
int interpQueueDepth,
int interpFailCount)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[remote-slide-snap] producer=ap87-4m guid=0x{0:X8} t={1} " +
"firstUp={2} willBeDrTicked={3} bodyToTarget={4:F3} threshold={5:F3} " +
"body=({6:F3},{7:F3},{8:F3}) target=({9:F3},{10:F3},{11:F3}) " +
"queueDepth={12} failCount={13}",
guid, Environment.TickCount64,
firstUp, willBeDrTicked, bodyToTarget, threshold,
bodyPosition.X, bodyPosition.Y, bodyPosition.Z,
targetPosition.X, targetPosition.Y, targetPosition.Z,
interpQueueDepth, interpFailCount));
}
/// <summary>
/// The non-blip outcome of the same seam: the packet fed the queue. Its
/// presence is what tells Shape B (queue fed, so Candidate 2 can arm)
/// apart from Shape A (queue never fed, so only Candidate 1 can fire).
/// Caller MUST guard with <see cref="ShouldLogRemoteSlide"/>.
/// </summary>
public static void LogRemoteSlideEnqueue(
uint guid,
float bodyToTarget,
Vector3 targetPosition,
int interpQueueDepth,
int interpFailCount)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[remote-slide-enq] guid=0x{0:X8} t={1} bodyToTarget={2:F3} " +
"target=({3:F3},{4:F3},{5:F3}) queueDepth={6} failCount={7}",
guid, Environment.TickCount64, bodyToTarget,
targetPosition.X, targetPosition.Y, targetPosition.Z,
interpQueueDepth, interpFailCount));
}
/// <summary>
/// Blip producer <b>Candidate 2</b> — the retail-faithful
/// <c>node_fail_counter &gt; 3</c> snap-to-tail inside
/// <see cref="InterpolationManager"/> (retail
/// <c>InterpolationManager::UseTime</c> @0x00555f20). Tagged
/// <c>producer=interp-stall</c>. This line is OBSERVATION ONLY: the code
/// it reports on is a correct port firing correctly on a body frozen
/// upstream, and the diagnosis explicitly rules it out of scope for any
/// fix. Self-guarded on <see cref="ProbeRemoteSlideEnabled"/> so the
/// snap site pays one bool read when off; GUID comes from
/// <see cref="RemoteSlideAttributionGuid"/>.
/// </summary>
public static void LogRemoteSlideStallSnap(
int failCount,
int threshold,
int queueDepth,
Vector3 bodyPosition,
Vector3 tailPosition,
float distanceToHead)
{
uint guid = _remoteSlideAttributionGuid;
if (!ShouldLogRemoteSlide(guid)) return;
Vector3 tailDelta = tailPosition - bodyPosition;
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[remote-slide-snap] producer=interp-stall guid=0x{0:X8} t={1} " +
"failCount={2} threshold={3} queueDepth={4} " +
"body=({5:F3},{6:F3},{7:F3}) tail=({8:F3},{9:F3},{10:F3}) " +
"tailDelta=({11:F3},{12:F3},{13:F3}) tailDeltaLen={14:F3} " +
"distToHead={15:F3}",
guid, Environment.TickCount64,
failCount, threshold, queueDepth,
bodyPosition.X, bodyPosition.Y, bodyPosition.Z,
tailPosition.X, tailPosition.Y, tailPosition.Z,
tailDelta.X, tailDelta.Y, tailDelta.Z, tailDelta.Length(),
distanceToHead));
}
/// <summary>
/// One <c>[remote-slide-tick]</c> line per admitted remote physics tick
/// (see <see cref="ShouldEmitRemoteSlideTick"/> for the edge-or-throttle
/// rule). Confirms LIVE what the diagnosis asserts from source.
///
/// <para>
/// The first three parameters were written against the PRE-FIX code and
/// their meaning changed with it; they keep their C# names because the
/// diagnosis doc quotes them, but they are emitted under different LOG
/// keys (see the format string). <paramref name="forcedContact"/> and
/// <paramref name="forcedWalkable"/> once meant "the per-tick
/// <c>TransientState |= Contact | OnWalkable</c> force flipped a bit that
/// was clear" (Link 1); that force is deleted, and they now report the
/// INVERSE fact — the body entered this tick WITHOUT that transient — and
/// are logged as <c>entryNoContact=</c>/<c>entryNoWalkable=</c>.
/// <paramref name="velocityBeforeZero"/> once named the vector the
/// per-tick <c>Body.Velocity = Zero</c> discarded (Link 2); nothing
/// discards it now, so it is simply the velocity the tick started with.
/// </para>
///
/// <para>
/// The <c>rs*</c> fields are the sweep's own retail classification, which
/// pre-fix the visible tick never committed (Link 4) and post-fix must
/// agree with the <c>contact=</c>/<c>onWalkable=</c> columns beside them.
/// <paramref name="bodyContactPlaneNormalZ"/> vs <c>floorZ</c> settles NOT
/// ESTABLISHED #2.
/// </para>
/// Caller MUST guard with <see cref="ShouldEmitRemoteSlideTick"/>.
/// </summary>
public static void LogRemoteSlideTick(
uint guid,
bool airborne,
bool forcedContact,
bool forcedWalkable,
Vector3 velocityBeforeZero,
bool resolved,
bool resolveInContact,
bool resolveOnWalkable,
bool resolveIsOnGround,
bool resolveContactPlaneValid,
float resolveContactPlaneNormalZ,
bool bodyContactPlaneValid,
float bodyContactPlaneNormalZ,
bool contact,
bool onWalkable,
bool gravity,
Vector3 velocity,
Vector3 acceleration,
Vector3 preIntegratePosition,
Vector3 postIntegratePosition,
Vector3 resolvedPosition)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
// n1 (2026-08-04): the two keys below USED to read
// "forcedContact/forcedWalkable" and meant "the deleted per-tick
// force flipped a clear bit". The force is gone; the same
// expressions now mean the body ENTERED the tick WITHOUT that
// transient — the exact inverse of what the old name implied. The
// log keys are renamed so a post-fix capture cannot be misread
// against a pre-fix one; the C# parameter names are unchanged
// because the diagnosis doc quotes them.
"[remote-slide-tick] guid=0x{0:X8} t={1} airborne={2} " +
"entryNoContact={3} entryNoWalkable={4} " +
"velBeforeZero=({5:F3},{6:F3},{7:F3}) resolved={8} " +
"rsInContact={9} rsOnWalkable={10} rsIsOnGround={11} " +
"rsCpValid={12} rsCpNz={13:F4} " +
"bodyCpValid={14} bodyCpNz={15:F4} floorZ={16:F4} steep={17} " +
"contact={18} onWalkable={19} gravity={20} " +
"vel=({21:F3},{22:F3},{23:F3}) accel=({24:F3},{25:F3},{26:F3}) " +
"pre=({27:F3},{28:F3},{29:F3}) post=({30:F3},{31:F3},{32:F3}) " +
"out=({33:F3},{34:F3},{35:F3}) moved={36:F4}",
guid, Environment.TickCount64, airborne,
forcedContact, forcedWalkable,
velocityBeforeZero.X, velocityBeforeZero.Y, velocityBeforeZero.Z,
resolved,
resolveInContact, resolveOnWalkable, resolveIsOnGround,
resolveContactPlaneValid, resolveContactPlaneNormalZ,
bodyContactPlaneValid, bodyContactPlaneNormalZ, PhysicsGlobals.FloorZ,
bodyContactPlaneValid && bodyContactPlaneNormalZ < PhysicsGlobals.FloorZ,
contact, onWalkable, gravity,
velocity.X, velocity.Y, velocity.Z,
acceleration.X, acceleration.Y, acceleration.Z,
preIntegratePosition.X, preIntegratePosition.Y, preIntegratePosition.Z,
postIntegratePosition.X, postIntegratePosition.Y, postIntegratePosition.Z,
resolvedPosition.X, resolvedPosition.Y, resolvedPosition.Z,
Vector3.Distance(preIntegratePosition, resolvedPosition)));
}
public static void LogCellSetBuild(
uint seedCellId,
System.Numerics.Vector3 sphereCenter,
System.Collections.Generic.IReadOnlyCollection<uint> cellSet)
{
if (!ProbeCellSetEnabled) return;
var ids = new System.Text.StringBuilder();
bool first = true;
foreach (uint id in cellSet)
{
if (!first) ids.Append(',');
ids.Append(System.FormattableString.Invariant($"0x{id:X8}"));
first = false;
}
Console.WriteLine(System.FormattableString.Invariant(
$"[cellset-build] seed=0x{seedCellId:X8} sphere=({sphereCenter.X:F3},{sphereCenter.Y:F3},{sphereCenter.Z:F3}) count={cellSet.Count} ids={ids}"));
}
/// <summary>
/// L.2d slice 1 (2026-05-13). Diagnostic side-channel: the
/// <see cref="ResolvedPolygon"/> that <see cref="BSPQuery"/>
/// recorded for the most recent collision-normal write.
/// <see cref="TransitionTypes.FindObjCollisions"/> clears this to
/// <see langword="null"/> before each shadow-entry test and reads it
/// back after, so emitting the <c>[resolve-bldg]</c> probe line can
/// reference the actual hit poly without plumbing an out-param
/// through BSPQuery's recursive private methods.
///
/// <para>
/// Written by <see cref="BSPQuery"/> only when
/// <see cref="ProbeBuildingEnabled"/> is true, so this stays
/// zero-cost in normal play. Cylinder collisions leave this
/// <see langword="null"/> — the probe line emits
/// <c>hitPoly: n/a (cylinder)</c> in that case.
/// </para>
///
/// <para>
/// Not threadsafe — physics runs on a single thread. If that
/// changes, this needs <c>[ThreadStatic]</c> or rethink. Deviation
/// from spec component 4 (which described an out-param); the
/// side-channel keeps BSPQuery's signature stable and the diagnostic
/// path off the production code surface.
/// </para>
/// </summary>
public static ResolvedPolygon? LastBspHitPoly { get; set; }
/// <summary>
/// B.6 slice 1 (2026-05-14) — baseline trace for the local-player
/// server-initiated auto-walk path (issue #63). When true, the
/// following events emit one-line <c>[autowalk-*]</c> logs:
/// <list type="bullet">
/// <item><description><c>[autowalk-out]</c> on every <c>SendUse</c>
/// / <c>SendPickUp</c> the local player issues — these are the
/// packets that may trigger ACE's server-side <c>CreateMoveToChain</c>
/// when the target is out of <c>WithinUseRadius</c>.</description></item>
/// <item><description><c>[autowalk-mt]</c> on every inbound
/// <c>UpdateMotion</c> for the local player — captures the
/// <c>MovementType + MoveToPath + speed/runRate</c> ACE sends.</description></item>
/// <item><description><c>[autowalk-up]</c> on every inbound
/// <c>UpdatePosition</c> for the local player — answers "what's
/// ACE's broadcast cadence during auto-walk?"</description></item>
/// </list>
/// Initial state from <c>ACDREAM_PROBE_AUTOWALK=1</c>.
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-14-phase-b6-design.md</c>
/// §"Required investigation".
/// </para>
/// </summary>
public static bool ProbeAutoWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_AUTOWALK") == "1";
/// <summary>
/// 2026-05-16. Logs one line per `IsUseableTarget` call that takes
/// the null-useability fallback path (creature pass / BF_DOOR pass /
/// BF_LIFESTONE pass / etc.). Used to measure how often ACE's seed
/// DB ships entities without `_useability` set — settles whether
/// the fallback is live code or theoretical defense.
///
/// <para>
/// Retail has NO fallback; null/zero useability blocks Use entirely
/// (acclient_2013_pseudo_c.txt:402923 ItemHolder::UseObject —
/// IsUseable==0 falls through to "cannot be used" branch). Our
/// fallback exists because ACE genuinely sends null for many seed
/// weenies. The probe quantifies "many".
/// </para>
///
/// <para>Toggle via env var <c>ACDREAM_PROBE_USEABILITY_FALLBACK=1</c>
/// or DebugPanel checkbox.</para>
/// </summary>
public static bool ProbeUseabilityFallbackEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1";
/// <summary>
/// L.4-diag (2026-04-30) → promoted into <see cref="PhysicsDiagnostics"/>
/// 2026-05-16 per CLAUDE.md "Code Structure Rules" §5 (diagnostic owner
/// classes, not per-call-site env reads). Gates the <c>[steep-roof]</c>
/// trace family that fires from four sites during the rooftop-bounce
/// investigation:
/// <list type="bullet">
/// <item><description><c>PhysicsEngine.ResolveWithTransition</c> —
/// <c>[steep-roof] KILL-VELOCITY-APPLIED</c> when retail-faithful
/// <c>kill_velocity</c> zeroes the body's velocity on steep-slope
/// impact.</description></item>
/// <item><description><c>TransitionTypes</c> (<c>FindEnvCollisions</c>
/// post-step) — per-frame plane-normal trace on the active
/// <see cref="CollisionInfo"/>.</description></item>
/// <item><description><c>PlayerMovementController</c> — two sites
/// emitting <c>[steep-roof]</c> + the per-frame bounce trace when
/// the post-collision velocity disagrees with retail.</description></item>
/// </list>
/// Initial state from <c>ACDREAM_DUMP_STEEP_ROOF=1</c>. Runtime-toggleable
/// via the property setter; not yet wired to a DebugPanel checkbox (open
/// follow-up if a debugging session calls for it).
/// </summary>
public static bool DumpSteepRoofEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). When true, emits one
/// <c>[indoor-bsp]</c> line per <see cref="BSPQuery.FindCollisions"/>
/// call made from <see cref="Transition.FindEnvCollisions"/>'s indoor
/// cell-BSP branch. Captures the cell id, sphere local position,
/// resulting <see cref="TransitionState"/>, and the hit poly's id,
/// local-normal, and side-type — pinpoints why indoor collision
/// returns spurious collisions (#84) and helps cross-check the
/// outdoor-in approach path (#85).
///
/// <para>
/// While true, this also un-gates the diagnostic
/// <see cref="LastBspHitPoly"/> side-channel inside
/// <see cref="BSPQuery"/> — see the OR'd condition at every poly
/// write site. Zero-cost when off.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
/// Runtime-toggleable via DebugPanel.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeIndoorBspEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
/// <summary>
/// Indoor walking Phase D follow-up (2026-05-19). When true, emits one
/// <c>[cell-cache]</c> line each time <see cref="PhysicsDataCache.CacheCellStruct"/>
/// caches a new EnvCell. Reports per-cell polygon counts and BSP root
/// structure so the caller can cross-reference with <c>[indoor-bsp]</c>
/// lines to distinguish between:
/// <list type="bullet">
/// <item><description>Empty data (physicsPolyCount=0 or resolvedCount=0)
/// — candidate (a)/(c) in the poly=n/a investigation.</description></item>
/// <item><description>Non-zero polygon counts but bspRootPolyCount=0 at
/// root + tree has children — correct structure for non-leaf root,
/// leaves hold the poly refs; not a bug.</description></item>
/// <item><description>Non-zero polygon counts but bspRootPolyCount=0 at
/// root AND root is a leaf (bspRootHasChildren=false) — BSP leaf with
/// zero poly refs, candidate (b)/(d).</description></item>
/// </list>
/// This diagnostic fires at most once per EnvCell (cache is no-op after
/// first population). It does NOT have a DebugPanel mirror yet — this is
/// a one-shot capture tool, not a persistent toggle. Promote to full
/// infrastructure after the root cause is identified.
///
/// <para>Initial state from <c>ACDREAM_PROBE_CELL_CACHE=1</c>.</para>
/// </summary>
public static bool ProbeCellCacheEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL_CACHE") == "1";
/// <summary>
/// ContactPlane retention spike (2026-05-20). When true, every write to
/// <c>CollisionInfo.ContactPlane{,Valid,CellId,IsWater}</c> and
/// <c>LastKnownContactPlane{,Valid,CellId,IsWater}</c> emits one
/// <c>[cp-write]</c> line: field, old → new value, caller method (walked
/// from the stack), and source line. Maps the per-frame lifecycle of the
/// contact plane to confirm/refute the hypothesis that
/// <c>FindEnvCollisions</c> indoor branch is rewriting CP every frame
/// instead of retaining it across frames.
///
/// <para>
/// Only logs when the value actually changes (suppresses no-op writes to
/// reduce log volume). Initial state from
/// <c>ACDREAM_PROBE_CONTACT_PLANE=1</c>. Spike-only — remove once the fix
/// lands and the diagnostic value is captured.
/// </para>
/// </summary>
public static bool ProbeContactPlaneEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CONTACT_PLANE") == "1";
/// <summary>
/// Indoor walking ISSUES #83 H-disambiguation spike (2026-05-21).
/// When true, two diagnostic emissions activate:
/// <list type="bullet">
/// <item><description>One <c>[walk-miss]</c> line per
/// <see cref="Transition.TryFindIndoorWalkablePlane"/> MISS
/// event, dumping foot world/local position, the nearest
/// walkable polygon in the cell (with XY-containment flag and
/// vertical gap), and whether the LandCell terrain at the same
/// XY would have grounded the player.</description></item>
/// <item><description>One <c>[floor-polys]</c> line per indoor
/// cell cached, enumerating each walkable-eligible polygon's
/// id, normal Z, local-XY bounding box, and plane Z at the
/// bbox center.</description></item>
/// </list>
/// Together these answer H1 (multi-cell iteration missing) vs H2
/// (probe distance too short) vs H3 (poly absent /
/// <c>walkable_hits_sphere</c> rejection) for the ISSUES #83
/// stuck-falling bug. Spike-only — remove once the root cause is
/// identified and the fix lands.
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_WALK_MISS=1</c>.
/// No DebugPanel mirror — one-shot diagnostic.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeWalkMissEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_MISS") == "1";
/// <summary>
/// Phase A6.P1 cdb probe spike (2026-05-21). When true, every BSP
/// collision response site emits a structured <c>[push-back]</c> line:
/// input/output sphere center, plane geometry, push-back delta, walk
/// interp, and the dispatcher's selected path. Direct comparison to
/// retail's cdb breakpoint set documented at
/// <c>tools/cdb/a6-probe.cdb</c>.
///
/// <para>
/// Three emission sites: <c>BSPQuery.AdjustSphereToPlane</c>
/// (the suspected over-correction site), <see cref="BSPQuery.FindCollisions"/>
/// (the 6-path dispatcher), and <see cref="Transition.CheckOtherCells"/>
/// (multi-cell BSP iteration outcomes). All three are zero-cost when
/// off — checked via early-out at each site.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_PUSH_BACK=1</c>.
/// Runtime-toggleable via DebugVM mirror.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-21-phase-a6-indoor-physics-fidelity-design.md</c>.
/// </para>
/// </summary>
public static bool ProbePushBackEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PUSH_BACK") == "1";
/// <summary>
/// A6.P3 slice 4 investigation (2026-05-22) — dumps the polygon's
/// vertices + plane + sidesType + cell id whenever a push-back fires.
/// Lets us compare our extracted polygon (from our cache) against
/// WorldBuilder's straight-from-dat read for the same poly index, to
/// falsify the "is our dat-read producing wrong polygon geometry?"
/// hypothesis for issue #98 (cellar-up stuck at top step).
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_POLY_DUMP=1</c>.
/// Heavy output (one dump per AdjustSphereToPlane call); use briefly
/// to capture a specific scenario, then turn off.
/// </para>
/// </summary>
public static bool ProbePolyDumpEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_POLY_DUMP") == "1";
/// <summary>
/// Emit one <c>[poly-dump]</c> line with the full polygon geometry:
/// cell id, polygon index, num vertices, sides flag, plane normal+D,
/// and all vertex coordinates. Used in conjunction with the push-back
/// probe to identify which dat polygon a push-back hit so we can
/// cross-reference against WorldBuilder's straight-from-dat read.
///
/// <para>Caller MUST guard with <c>if (!ProbePolyDumpEnabled) return;</c>.</para>
/// </summary>
public static void LogPolyDump(uint cellId, ResolvedPolygon poly)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var sb = new System.Text.StringBuilder(256);
sb.AppendFormat(ci,
"[poly-dump] cell=0x{0:X8} polyId=0x{1:X4} numPts={2} sides={3} " +
"n=({4:F6},{5:F6},{6:F6}) d={7:F6} verts=[",
cellId, poly.Id, poly.NumPoints, poly.SidesType,
poly.Plane.Normal.X, poly.Plane.Normal.Y, poly.Plane.Normal.Z, poly.Plane.D);
for (int i = 0; i < poly.Vertices.Length; i++)
{
if (i > 0) sb.Append(',');
sb.AppendFormat(ci, "({0:F6},{1:F6},{2:F6})",
poly.Vertices[i].X, poly.Vertices[i].Y, poly.Vertices[i].Z);
}
sb.Append(']');
Console.WriteLine(sb.ToString());
}
/// <summary>
/// A6.P3 slice 5 placement-insert investigation (2026-05-22). One
/// <c>[place-fail]</c> line per Path 1 (Placement/Ethereal) call in
/// <c>BSPQuery.FindCollisions</c> that returns Collided, plus one per
/// <c>Transition.DoStepDown</c> placement_insert that rejects.
///
/// <para>
/// Investigation target: issue #98 cellar-up stuck. The 2026-05-22
/// handoff diagnosed BSPQuery path-selection (Path 5 vs Path 6) as
/// the divergence, but cross-referencing the retail cdb capture
/// (every BP4 hit shows <c>collide=0</c>) showed retail enters the
/// same Contact branch we do. The actual divergence is downstream:
/// our DoStepUp's step-down probe lifts the sphere onto the cellar
/// ramp, then placement_insert rejects, step_up returns failure,
/// step_up_slide fires, contact-recovery loops forever. This probe
/// identifies which polygon (or solid leaf) causes the placement
/// reject so we know what geometry is blocking the lifted position.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_PLACEMENT_FAIL=1</c>.
/// Low volume — only fires on actual rejection (one line per
/// Collided return from Path 1, plus one per DoStepDown placement
/// failure). Safe to leave on during a full scen4 cellar-up capture.
/// </para>
/// </summary>
public static bool ProbePlacementFailEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLACEMENT_FAIL") == "1";
/// <summary>
/// Phase W Stage 0 (2026-06-02): one <c>[cell-swept]</c> line per
/// <see cref="PhysicsEngine.ResolveWithTransition"/> call — the
/// transition's swept cell (<c>sp.CurCellId</c>/<c>sp.CheckCellId</c>)
/// vs the position-derived cell the legacy static
/// <see cref="PhysicsEngine.ResolveCellId"/> path used. Proves the swept
/// cell is stable where the static one strobes at the doorway boundary.
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_SWEPT=1</c>. Zero cost when off.
/// </para>
/// </summary>
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.
// -----------------------------------------------------------------------
/// <summary>
/// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with
/// the physics-probe family). Emits two line types from
/// <c>Transition.FindObjCollisionsInCell</c>:
///
/// <list type="bullet">
/// <item><c>[reach-q]</c> — one per-cell query summary: the number of
/// shadow entries the cell yielded and the per-disposition tallies.
/// It is emitted <b>even when the cell yields zero entries</b>, 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.</item>
/// <item><c>[reach-obj]</c> — one per candidate, carrying its identity
/// (mover guid, target entity id, GfxObj id, cell) and its
/// <c>disposition</c>: <c>exempt-self</c>, <c>exempt-missile</c>,
/// <c>rejected-reach</c>, <c>exempt-rule</c>,
/// <c>exempt-ethereal-stepdown</c>, <c>no-shape</c>,
/// <c>bsp-only-skip</c>, or <c>tested:&lt;result&gt;</c>. 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 <c>wouldAcceptAtCenter</c>.</item>
/// </list>
///
/// <para>
/// Volume control (this site is hot — it runs per cell per transitional
/// insert, and one resolve performs many inserts). <c>[reach-obj]</c> 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. <c>[reach-q]</c> 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.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_REACH=1</c>. Zero cost when off
/// (one static bool read per query).
/// </para>
/// </summary>
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();
/// <summary>
/// One <c>[reach-obj]</c> line. Self-guards on
/// <see cref="ProbeReachEnabled"/>.
/// </summary>
/// <param name="moverId">The moving entity's guid — never omitted; a
/// per-entity probe without an identity produced a wrong root cause once
/// already (<c>feedback_probe_identity_attribution</c>).</param>
/// <param name="disposition">What happened to this candidate. Use the
/// documented vocabulary on <see cref="ProbeReachEnabled"/>.</param>
/// <param name="distOrigin">What the reach filter measured: the distance
/// from the swept sphere's current centre to the target's part ORIGIN.
/// Negative when not applicable.</param>
/// <param name="distCenter">What it should have measured: the distance to
/// the target's physics-BSP root sphere CENTRE. Negative when not
/// applicable.</param>
/// <param name="budget">The filter's admission threshold, 2 m slack
/// included.</param>
/// <param name="centerBudget">The same threshold WITHOUT the slack — the
/// honest conservative bound once the real centre is used.</param>
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));
}
/// <summary>
/// One <c>[reach-q]</c> 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 <see cref="ProbeReachEnabled"/>.
/// </summary>
/// <param name="inCell">Shadow entries the cell yielded, before any
/// exemption. Zero here at a spot with visible geometry is outcome (b).</param>
/// <param name="reached">Candidates that survived the exemptions and were
/// measured by the reach filter.</param>
/// <param name="rejectedReach">Of those, how many the reach filter
/// rejected — outcome (a).</param>
/// <param name="noShape">Candidates that passed the filter but resolved to
/// no usable shape — outcome (c).</param>
/// <param name="tested">Candidates that actually reached a shape test.</param>
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));
}
// -----------------------------------------------------------------------
// [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
/// cross-thread monotonic timestamp (<see cref="Environment.TickCount64"/>)
/// so the offline reader can order AIM / ENQ / BUILD / APPLY / PLACED across
/// the render thread and the streamer worker. Disambiguates the three
/// candidate roots for "destination not resident fast": apply-THROTTLE
/// (APPLY lands before PLACED), <c>_datLock</c> CONTENTION (BUILD waited=
/// large), and a streaming-command GATE (ENQ never fires for the dest).
/// Initial state from <c>ACDREAM_PROBE_TELEPORT=1</c>. Strip after capture.
/// </summary>
public static bool ProbeTeleportEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_TELEPORT") == "1";
/// <summary>
/// One <c>[tp-probe]</c> line. Self-guards on <see cref="ProbeTeleportEnabled"/>,
/// so callers need not pre-check (the cost when off is a single bool read).
/// </summary>
public static void LogTeleport(string point, uint id, string extra = "")
{
if (!ProbeTeleportEnabled) return;
Console.WriteLine(System.FormattableString.Invariant(
$"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}"));
}
/// <summary>
/// C4 route 3 D-T8 (2026-08-04 — TEMPORARY, strip with the rest of the
/// physics-probe family once the connected gate is scored). One line per
/// local-player portal-arrival attempt from
/// <c>RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal</c>
/// — the single Runtime chokepoint both the graphical and headless hosts
/// share, so this is dual-host parity evidence, not per-host guesswork.
/// Initial state from <c>ACDREAM_PROBE_LOCAL_TELEPORT=1</c>.
/// </summary>
public static bool ProbeLocalTeleportEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LOCAL_TELEPORT") == "1";
/// <summary>
/// Which host process is running — set once at composition startup by
/// each host's own entry point (<c>SessionPlayerComposition</c> for
/// graphical, <c>HeadlessSessionHost</c> for headless). Runtime itself
/// stays presentation-agnostic (Slice K); this is a diagnostics-only
/// label so <see cref="LogLocalTeleportArrival"/> can report which
/// process produced a given line without threading a host parameter
/// through the drive controller's constructor.
/// </summary>
public static string LocalTeleportHostKind { get; set; } = "graphical";
/// <summary>
/// One <c>[local-tp]</c> line: cause, host, placement status, portal
/// generation/sequence, destination cell, resolved cell, and the three
/// D-T8 booleans confirming the reconcile suffix actually ran
/// (<paramref name="hookTailRan"/> = <c>CommitCanonicalTeleportFrame</c>
/// executed, <paramref name="leashArmed"/> = the constraint leash is
/// armed post-commit, <paramref name="autorunCancelled"/> =
/// <c>CancelAutoRun</c> ran). Self-guards on
/// <see cref="ProbeLocalTeleportEnabled"/>. <paramref name="cause"/> is
/// always <c>"portal"</c> today — ACE's recall/admin teleports arrive as
/// the identical TeleportAdvanced Position and are indistinguishable
/// from a doorway portal at this layer; the parameter exists so a future
/// wire-level cause signal has somewhere to land without a probe
/// signature change.
/// </summary>
public static void LogLocalTeleportArrival(
string cause,
string placementStatus,
long portalGeneration,
ushort teleportSequence,
uint destinationCell,
uint resolvedCell,
bool hookTailRan,
bool leashArmed,
bool autorunCancelled)
{
if (!ProbeLocalTeleportEnabled) return;
string hookTailText = hookTailRan ? "ran" : "skipped";
string leashText = leashArmed ? "armed" : "unarmed";
string autorunText = autorunCancelled ? "cancelled" : "unchanged";
Console.WriteLine(System.FormattableString.Invariant(
$"[local-tp] cause={cause} host={LocalTeleportHostKind} status={placementStatus} gen={portalGeneration} seq={teleportSequence} dest=0x{destinationCell:X8} resolved=0x{resolvedCell:X8} hookTail={hookTailText} leash={leashText} autorun={autorunText}"));
}
/// <summary>
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
/// emits one <c>[step-walk]</c> line at selected points in the transition
/// sub-step loop and step-down probe. The line records requested vs
/// adjusted offset, current/check sphere position, cell id, walk interp,
/// contact planes, and walkable flags so a cellar-ramp capture can answer
/// whether forward motion is being projected into rising Z or lost before
/// the placement check.
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_STEP_WALK=1</c>. One-shot
/// diagnostic; no DebugPanel mirror until the root cause is identified.
/// </para>
/// </summary>
public static bool ProbeStepWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_WALK") == "1";
/// <summary>
/// A6.P3 issue #98 cell-dump probe (2026-05-23). When non-empty, dumps
/// the geometry of any cached cell whose EnvCellId matches one of the
/// listed ids to a JSON file under
/// <see cref="ProbeDumpCellsPath"/>. One-shot per cell (a second cache
/// of the same id is a no-op).
///
/// <para>
/// Configured via <c>ACDREAM_DUMP_CELLS</c> as a comma-separated list of
/// hex cell ids (with or without <c>0x</c> prefix). The output path
/// defaults to
/// <c>tests/AcDream.Core.Tests/Fixtures/issue98/&lt;cellid&gt;.json</c>
/// (relative to the worktree root). Override with
/// <c>ACDREAM_DUMP_CELLS_DIR=&lt;dir&gt;</c>.
/// </para>
///
/// <para>
/// The dump fuels the deterministic replay harness for issue #98 — the
/// goal is to load cells 0xA9B40143 + 0xA9B40146 + 0xA9B40147 as JSON
/// fixtures and drive the failing-frame sphere through the walkable
/// query in a unit test, eliminating live-client iteration cost from the
/// investigation loop.
/// </para>
/// </summary>
public static IReadOnlySet<uint> ProbeDumpCellIds { get; set; } =
ParseHexIdList(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CELLS"));
public static string ProbeDumpCellsPath { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_CELLS_DIR")
?? "tests/AcDream.Core.Tests/Fixtures/issue98";
public static bool ProbeDumpCellsEnabled => ProbeDumpCellIds.Count > 0;
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2) — GfxObj-equivalent of
/// <see cref="ProbeDumpCellIds"/>. When non-empty, dumps the polygon
/// table + BSP root metadata of any cached GfxObj whose id matches
/// one of the listed values, as a JSON fixture under
/// <see cref="ProbeDumpGfxObjsPath"/>. One-shot per id (a second
/// cache of the same GfxObj is a no-op).
///
/// <para>
/// Configured via <c>ACDREAM_DUMP_GFXOBJS</c> as a comma-separated
/// list of hex GfxObj ids (with or without <c>0x</c> prefix). Output
/// defaults to <c>tests/AcDream.Core.Tests/Fixtures/issue98</c>
/// (relative to the worktree root) with one file per id named
/// <c>0x{id:X8}.gfxobj.json</c> so it doesn't collide with cell
/// dumps in the same directory. Override directory via
/// <c>ACDREAM_DUMP_GFXOBJS_DIR=&lt;dir&gt;</c>.
/// </para>
///
/// <para>
/// The motivation: the existing <c>[resolve-bldg]</c> probe captures
/// the GfxObj-level metadata (id, BSP root radius, entity origin) but
/// emits <c>hitPoly: n/a (BSP path — side-channel not written)</c>
/// because the BSPQuery wire site that would populate
/// <see cref="LastBspHitPoly"/> never landed. A polygon-level dump
/// at cache time bypasses that gap entirely — one capture run yields
/// the FULL polygon table, suitable for fixture-loading in
/// <c>CellarUpTrajectoryReplayTests</c>'s <c>RegisterCottageGfxObj</c>
/// helper.
/// </para>
/// </summary>
public static IReadOnlySet<uint> ProbeDumpGfxObjIds { get; set; } =
ParseHexIdList(Environment.GetEnvironmentVariable("ACDREAM_DUMP_GFXOBJS"));
public static string ProbeDumpGfxObjsPath { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_GFXOBJS_DIR")
?? "tests/AcDream.Core.Tests/Fixtures/issue98";
public static bool ProbeDumpGfxObjsEnabled => ProbeDumpGfxObjIds.Count > 0;
/// <summary>
/// Test-only reset: set every probe flag to <c>false</c> and clear any
/// side-channel fields. Does NOT re-read environment variables — tests
/// run in environments where the env vars are all absent, so false is
/// the correct default.
///
/// <para>
/// Call from test constructors and <c>IDisposable.Dispose()</c> to
/// prevent one test class from leaving enabled probes that corrupt
/// timing-sensitive tests in another class (the static-leak root cause
/// documented in T0).
/// </para>
///
/// <para>
/// This method is intentionally <c>public</c> so test projects can call
/// it without reflection, but it must NEVER be called from production
/// code paths.
/// </para>
/// </summary>
public static void ResetForTest()
{
ProbeResolveEnabled = false;
ProbeCellEnabled = false;
ProbeParkEnabled = false;
ProbeBuildingEnabled = false;
ProbeCellSetEnabled = false;
ProbeStickyEnabled = false;
ProbeAutoWalkEnabled = false;
ProbeUseabilityFallbackEnabled= false;
DumpSteepRoofEnabled = false;
ProbeIndoorBspEnabled = false;
ProbeCellCacheEnabled = false;
ProbeContactPlaneEnabled = false;
ProbeWalkMissEnabled = false;
ProbePushBackEnabled = false;
ProbePolyDumpEnabled = false;
ProbePlacementFailEnabled = false;
ProbeSweptEnabled = false;
ProbeStepWalkEnabled = false;
ProbeReachEnabled = false;
lock (_reachGate)
{
_reachSeenObj.Clear();
_reachSeenQuery.Clear();
}
ProbeSupportEnabled = false;
_contactPlaneSourceMember = null;
_contactPlaneSourceLine = 0;
lock (_supportGate)
{
_supportSeen.Clear();
_geomSeen.Clear();
}
ProbeTeleportEnabled = false;
ProbeRemoteTeleportEnabled = false;
ProbeRemoteLandingEnabled = false;
ProbeRemoteSlideEnabled = false;
ProbeRemoteSlideGuids = new System.Collections.Generic.HashSet<uint>();
_remoteSlideAttributionGuid = 0;
_remoteSlideTickGate = null;
// Side-channel fields
LastBspHitPoly = null;
LastPlacementFailPolyId = 0;
LastPlacementFailPolyNormal = default;
LastPlacementFailPolyD = 0f;
LastPlacementFailSolidLeaf = false;
// Dump-trigger sets
ProbeDumpCellIds = new System.Collections.Generic.HashSet<uint>();
ProbeDumpGfxObjIds = new System.Collections.Generic.HashSet<uint>();
}
private static IReadOnlySet<uint> ParseHexIdList(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return new System.Collections.Generic.HashSet<uint>();
var ids = new System.Collections.Generic.HashSet<uint>();
foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = token.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
trimmed = trimmed[2..];
if (uint.TryParse(
trimmed,
System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture,
out var id))
{
ids.Add(id);
}
}
return ids;
}
/// <summary>
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
/// at the leaf where it returns true. Either
/// <see cref="LastPlacementFailPolyId"/> identifies the polygon that
/// intersected the sphere, or <see cref="LastPlacementFailSolidLeaf"/>
/// is true to indicate the sphere center landed inside a BSP leaf
/// marked solid (no specific polygon). The caller (Path 1) reads
/// these immediately after the true return to emit the
/// <c>[place-fail]</c> line, then clears them before the next test.
///
/// <para>
/// Writes are gated on <see cref="ProbePlacementFailEnabled"/> so the
/// production path pays only one boolean check per leaf hit when the
/// probe is off.
/// </para>
/// </summary>
public static ushort LastPlacementFailPolyId { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static Vector3 LastPlacementFailPolyNormal { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static float LastPlacementFailPolyD { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static bool LastPlacementFailSolidLeaf { get; set; }
/// <summary>
/// Emit one <c>[place-fail]</c> line for a placement_insert rejection.
/// <paramref name="source"/> tags the call site (e.g.
/// <c>"Path1.sphere0"</c> for the foot sphere in Path 1,
/// <c>"Path1.sphere1"</c> for the head sphere,
/// <c>"DoStepDown"</c> for the wrapper). The polygon (or solid leaf)
/// fields come from the side-channel populated during the recursive
/// BSP descent.
///
/// <para>Caller MUST guard with <c>if (!ProbePlacementFailEnabled) return;</c>.</para>
/// </summary>
public static void LogPlacementFail(
string source,
Vector3 sphereCenter,
float radius,
int sphereIdx,
uint cellId,
Vector3 worldOrigin,
bool ethereal)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string polyDesc = LastPlacementFailSolidLeaf
? "solid_leaf=1"
: LastPlacementFailPolyId != 0
? string.Format(ci, "polyId=0x{0:X4} n=({1:F4},{2:F4},{3:F4}) d={4:F4}",
LastPlacementFailPolyId,
LastPlacementFailPolyNormal.X, LastPlacementFailPolyNormal.Y, LastPlacementFailPolyNormal.Z,
LastPlacementFailPolyD)
: "no_poly_info";
Console.WriteLine(string.Format(ci,
"[place-fail] source={0} cell=0x{1:X8} sphere=({2:F4},{3:F4},{4:F4}) r={5:F4} " +
"sphereIdx={6} worldOrigin=({7:F4},{8:F4},{9:F4}) ethereal={10} {11}",
source, cellId, sphereCenter.X, sphereCenter.Y, sphereCenter.Z, radius,
sphereIdx, worldOrigin.X, worldOrigin.Y, worldOrigin.Z, ethereal, polyDesc));
}
/// <summary>
/// A6.P1 emission helper for the <c>AdjustSphereToPlane</c> site.
/// One line per call: input sphere center, plane geometry, push-back
/// delta, walk-interp before/after, and whether the adjust applied.
/// Direct paired comparison to retail's cdb breakpoint on
/// <c>CPolygon::adjust_sphere_to_plane</c>.
///
/// <para>
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
/// before computing the delta arguments — this method assumes the
/// caller paid that price already.
/// </para>
/// </summary>
public static void LogPushBackAdjust(
Vector3 inputCenter,
Vector3 outputCenter,
Plane plane,
float radius,
float walkInterpBefore,
float walkInterpAfter,
float dpPos,
float dpMove,
float iDist,
bool applied)
{
var delta = outputCenter - inputCenter;
float deltaMag = delta.Length();
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[push-back] site=adjust_sphere " +
"in=({0:F4},{1:F4},{2:F4}) " +
"out=({3:F4},{4:F4},{5:F4}) " +
"delta=({6:F4},{7:F4},{8:F4}) deltaMag={9:F4} " +
"n=({10:F4},{11:F4},{12:F4}) d={13:F4} " +
"r={14:F4} winterp={15:F4}->{16:F4} " +
"dpPos={17:F4} dpMove={18:F4} iDist={19:F4} applied={20}",
inputCenter.X, inputCenter.Y, inputCenter.Z,
outputCenter.X, outputCenter.Y, outputCenter.Z,
delta.X, delta.Y, delta.Z, deltaMag,
plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D,
radius, walkInterpBefore, walkInterpAfter,
dpPos, dpMove, iDist, applied));
}
/// <summary>
/// A6.P1 emission helper for the <c>FindCollisions</c> dispatcher
/// site. One line per call: input sphere center, movement vector,
/// path-selection state flags (collide / insertType / objState),
/// walk-interp at entry, and the return state. Direct paired
/// comparison to retail's cdb breakpoint on
/// <c>BSPTREE::find_collisions</c>.
///
/// <para>
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
/// before calling.
/// </para>
/// </summary>
public static void LogPushBackDispatch(
Vector3 sphereCenter,
Vector3 movement,
bool collide,
int insertType,
int objState,
float walkInterpEntry,
int returnState)
{
// Output format mirrors LogPushBackAdjust: single string.Format
// with CultureInfo.InvariantCulture so the F4 / hex formatting is
// locale-independent and the output is greppable.
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[push-back-disp] site=dispatch " +
"center=({0:F4},{1:F4},{2:F4}) " +
"mvmt=({3:F4},{4:F4},{5:F4}) " +
"collide={6} insertType={7} objState=0x{8:X} " +
"winterp={9:F4} return={10}",
sphereCenter.X, sphereCenter.Y, sphereCenter.Z,
movement.X, movement.Y, movement.Z,
collide, insertType, objState,
walkInterpEntry, returnState));
}
/// <summary>
/// A6.P1 emission helper for the <c>CheckOtherCells</c> multi-cell
/// BSP iteration site. One line per off-cell hit: from-cell, to-cell,
/// BSP result (Ok / Adjusted / Slid / Collided), and the iteration
/// outcome. Direct paired comparison to retail's
/// <c>CTransition::check_other_cells</c> loop at decomp line
/// 272717. Augments the existing A4 multi-cell BSP instrumentation
/// with explicit per-iteration outcome telemetry.
///
/// <para>
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
/// before calling.
/// </para>
/// </summary>
public static void LogPushBackCellTransit(
uint primaryCellId,
uint otherCellId,
int bspResult,
bool halted)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[push-back-cell] site=other_cell " +
"primary=0x{0:X8} other=0x{1:X8} " +
"bspResult={2} halted={3}",
primaryCellId, otherCellId, bspResult, halted));
}
/// <summary>
/// Emit one <c>[step-walk]</c> line for issue #98's cellar-ramp
/// investigation. Caller MUST guard with
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
/// </summary>
public static void LogStepWalk(
string site,
int stepIndex,
int stepCount,
SpherePath sp,
CollisionInfo ci,
ObjectInfo oi,
Vector3 requestedOffset,
Vector3 adjustedOffset,
TransitionState? state = null,
string? detail = null)
{
var culture = System.Globalization.CultureInfo.InvariantCulture;
var checkDelta = sp.CheckPos - sp.CurPos;
string stateText = state.HasValue ? state.Value.ToString() : "n/a";
string stepText = stepIndex >= 0 && stepCount > 0
? string.Format(culture, "{0}/{1}", stepIndex + 1, stepCount)
: "-";
Console.WriteLine(string.Format(culture,
"[step-walk] site={0} step={1} state={2} " +
"cur=({3:F4},{4:F4},{5:F4}) check=({6:F4},{7:F4},{8:F4}) " +
"delta=({9:F4},{10:F4},{11:F4}) cell=0x{12:X8}->0x{13:X8} " +
"req=({14:F4},{15:F4},{16:F4}) adj=({17:F4},{18:F4},{19:F4}) " +
"winterp={20:F4} stepUp={21} stepDown={22} insert={23} " +
"oi=0x{24:X} contact={25} onWalkable={26} " +
"cp={27} lkcp={28} hit={29} slide={30} walkPoly={31} lastWalkPoly={32}{33}",
site, stepText, stateText,
sp.CurPos.X, sp.CurPos.Y, sp.CurPos.Z,
sp.CheckPos.X, sp.CheckPos.Y, sp.CheckPos.Z,
checkDelta.X, checkDelta.Y, checkDelta.Z,
sp.CurCellId, sp.CheckCellId,
requestedOffset.X, requestedOffset.Y, requestedOffset.Z,
adjustedOffset.X, adjustedOffset.Y, adjustedOffset.Z,
sp.WalkInterp,
sp.StepUp, sp.StepDown, sp.InsertType,
(uint)oi.State, oi.Contact, oi.OnWalkable,
FormatPlane(ci.ContactPlaneValid, ci.ContactPlane, ci.ContactPlaneCellId, ci.ContactPlaneIsWater),
FormatPlane(ci.LastKnownContactPlaneValid, ci.LastKnownContactPlane, ci.LastKnownContactPlaneCellId, ci.LastKnownContactPlaneIsWater),
FormatVector(ci.CollisionNormalValid, ci.CollisionNormal),
FormatVector(ci.SlidingNormalValid, ci.SlidingNormal),
sp.HasWalkablePolygon, sp.HasLastWalkablePolygon,
string.IsNullOrEmpty(detail) ? string.Empty : " " + detail));
}
/// <summary>
/// A6.P3 issue #98 (2026-05-23) — focused probe INSIDE
/// <see cref="Transition.AdjustOffset"/> revealing which branch was
/// taken and the per-call Z gain. Pair with <c>[step-walk]
/// site=after-adjust</c> at the call site to triangulate where the
/// projection ends up. Caller MUST guard with
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
/// </summary>
public static void LogStepWalkAdjust(
string branch,
Vector3 input,
Vector3 output,
Plane? contactPlane,
bool slidingValid,
Vector3 slidingNormal,
float collisionAngle,
float walkInterp)
{
var culture = System.Globalization.CultureInfo.InvariantCulture;
string cpDesc = contactPlane is { } cp
? string.Format(culture,
"n=({0:F4},{1:F4},{2:F4}) d={3:F4}",
cp.Normal.X, cp.Normal.Y, cp.Normal.Z, cp.D)
: "n/a";
string slideDesc = slidingValid
? string.Format(culture,
"({0:F4},{1:F4},{2:F4})",
slidingNormal.X, slidingNormal.Y, slidingNormal.Z)
: "n/a";
Console.WriteLine(string.Format(culture,
"[step-walk-adjust] branch={0} input=({1:F4},{2:F4},{3:F4}) " +
"output=({4:F4},{5:F4},{6:F4}) zGain={7:F4} " +
"cp={8} slide={9} colAngle={10:F4} winterp={11:F4}",
branch,
input.X, input.Y, input.Z,
output.X, output.Y, output.Z,
output.Z - input.Z,
cpDesc, slideDesc, collisionAngle, walkInterp));
}
private static string FormatVector(bool valid, Vector3 value)
{
if (!valid)
return "n/a";
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
"({0:F4},{1:F4},{2:F4})",
value.X, value.Y, value.Z);
}
private static string FormatPlane(bool valid, Plane plane, uint cellId, bool isWater)
{
if (!valid)
return "n/a";
float zAtOrigin = MathF.Abs(plane.Normal.Z) > PhysicsGlobals.EPSILON
? -plane.D / plane.Normal.Z
: float.NaN;
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
"cell=0x{0:X8},water={1},n=({2:F4},{3:F4},{4:F4}),d={5:F4},z0={6:F4}",
cellId, isWater,
plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D,
zAtOrigin);
}
public static void LogCpBoolWrite(string field, bool oldValue, bool newValue)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: {oldValue} -> {newValue} caller={caller}"));
}
public static void LogCpPlaneWrite(string field, Plane oldPlane, Plane newPlane)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: n=({oldPlane.Normal.X:F3},{oldPlane.Normal.Y:F3},{oldPlane.Normal.Z:F3}) D={oldPlane.D:F3} -> n=({newPlane.Normal.X:F3},{newPlane.Normal.Y:F3},{newPlane.Normal.Z:F3}) D={newPlane.D:F3} caller={caller}"));
}
public static void LogCpCellIdWrite(string field, uint oldValue, uint newValue)
{
var caller = GetCpCallerName();
Console.WriteLine(System.FormattableString.Invariant(
$"[cp-write] {field}: 0x{oldValue:X8} -> 0x{newValue:X8} caller={caller}"));
}
/// <summary>
/// Walks the stack to identify the first frame outside <c>CollisionInfo</c>
/// and <c>PhysicsDiagnostics</c> — that's the actual caller writing the
/// ContactPlane field. Format: <c>TypeName.MethodName:line</c> when file
/// info is available, else just <c>TypeName.MethodName</c>. Walked with
/// <c>fileNeeded=true</c> only when the probe flag is on, so zero cost
/// when off.
/// </summary>
private static string GetCpCallerName()
{
// Skip 2: this method + the LogCp*Write helper that called it.
var st = new System.Diagnostics.StackTrace(2, fNeedFileInfo: true);
for (int i = 0; i < st.FrameCount; i++)
{
var f = st.GetFrame(i);
var m = f?.GetMethod();
if (m is null) continue;
var typeName = m.DeclaringType?.Name ?? "?";
if (typeName == "CollisionInfo" || typeName == "PhysicsDiagnostics") continue;
int line = f?.GetFileLineNumber() ?? 0;
return line > 0 ? $"{typeName}.{m.Name}:{line}" : $"{typeName}.{m.Name}";
}
return "?";
}
private static int ParsePositiveInt(string? value) =>
int.TryParse(
value,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
&& parsed > 0
? parsed
: 0;
}