acdream/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
Erik e761761aa3
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
probe(physics): ACDREAM_DUMP_TRANSIT_FAIL — self-selecting transition-phase trace for #345
Fires only on the stuck-tick predicate (>=1mm XY requested, <=0.1mm
achieved), buffering per-tick phase outcomes cheaply and flushing only
on a stuck tick: per-insert-attempt phase/state/normal/source, step-up
enter/exit verdicts, every ValidateWalkable branch with dist/waterDepth
and both SetCollisionNormal guards evaluated, and the tick's final
AdjustOffset pair. Zero cost when off (flag before any allocation — the
I1 zero-alloc gate stays green), mover id on every line, [ThreadStatic]
buffer per the referee-safety rule. Two tests: fires on a synthetic
wall-stuck tick, silent on ordinary movement.

Diagnostics only; no behavioral change. Suite 11,271 / 6 / 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:48:46 +02:00

3091 lines
145 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).
// CONFIRMED and CLOSED 2026-08-06: this was the cause of #337, and
// the filter is now DELETED — retail has none. `rejectedReach` is
// retained as a structurally-zero column so a post-fix capture is
// directly comparable with the pre-fix one that recorded 7,225
// rejections on one owner, every single one with
// wouldAcceptAtCenter=True;
// (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>exempt-rule</c>
/// (<c>rejected-reach</c> is retired — #333 deleted the filter that
/// produced it),
/// <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">What the deleted filter's admission threshold WOULD
/// have been, 2 m slack included. Since #333 no live predicate reads it; it
/// is kept so the acceptance capture shows which candidates the old filter
/// would have thrown away.</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 went
/// on to a shape dispatch. Before #333 this was "and were measured by the
/// reach filter"; the filter is gone, so the two are now the same set.</param>
/// <param name="rejectedReach">Of those, how many the reach filter
/// rejected — outcome (a). <b>Structurally 0 since #333 deleted the
/// filter</b>, and retained precisely so that a post-fix capture reading 0
/// is comparable against the pre-fix capture that read 7,225.</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>();
// S6 PerfectClip TOI-tail containment guard (AP-83/AP-91).
ResetPerfectClipTailGuardForTest();
}
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 "?";
}
// -----------------------------------------------------------------------
// #338 — step-height provenance
// -----------------------------------------------------------------------
/// <summary>
/// #338 (2026-08-06, TEMPORARY). Traces the LOCAL PLAYER's step-up /
/// step-down heights along the chain that is supposed to carry them from
/// the authored Setup to the resolver, so we can see WHERE the observed
/// <c>0.400 / 0.400</c> wins.
///
/// <para>
/// What is already known and does NOT need measuring: retail reads the
/// authored field (<c>CTransition::step_up</c> @0x0050b610 substitutes
/// <c>object_info.step_up_height</c> when <c>state &amp; 2</c>, i.e.
/// OnWalkable, and otherwise uses <c>0.0399999991f</c>), and acdream
/// already ports that gate faithfully in <c>Transition.DoStepUp</c>. The
/// value is the defect, not the gate.
/// </para>
///
/// <para>
/// What is NOT known, and is exactly what this probe decides: the
/// controller's fields initialise to <c>0.4f</c>, while
/// <c>RuntimeSetPositionMoverPreparation</c> genuinely computes the
/// Setup-derived value and a writer genuinely assigns it. So either the
/// prepare/publish path never runs for the local player, or it runs and
/// something later overwrites it. A live reading of <c>0.400</c> alone
/// cannot tell those apart, and a fix chosen without knowing which is a
/// coin flip.
/// </para>
///
/// <para><b>Decision table.</b> Read the <c>[step-h]</c> lines in order:
/// <list type="bullet">
/// <item>No <c>site=prepare</c> line at all → the Setup-derived path
/// never runs for the local player. Fix is to wire it (and the missing
/// <c>PlayerModeController.ApplyStepHeights</c> the doc comment names
/// was probably it).</item>
/// <item><c>site=prepare</c> shows 0.600/1.500 but no
/// <c>site=publish</c> → the command is built and never consumed on this
/// path.</item>
/// <item><c>prepare</c> and <c>publish</c> both show 0.600/1.500 but
/// <c>site=resolve</c> shows 0.400 → a later writer clobbers it; find
/// that writer, do not re-set the field.</item>
/// <item><c>site=prepare</c> itself shows 0.400 → the Setup lookup is
/// returning the wrong Setup, or scale is wrong.</item>
/// <item><c>site=prepare</c> shows 0.000 → <c>preparation.Setup.Collision</c>
/// was null and the retail dummy path was taken.</item>
/// </list>
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_STEP_HEIGHTS=1</c>. Zero cost when
/// off (one static bool read per site).
/// </para>
/// </summary>
public static bool ProbeStepHeightsEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") == "1";
private static readonly object _stepHeightGate = new();
private static readonly Dictionary<string, (float Up, float Down)> _stepHeightSeen = new();
private static int _stepHeightAnnounced;
/// <summary>
/// #338 (TEMPORARY). Prints ONCE per process, regardless of the flag, from
/// the resolve site. Two placements of this probe produced no output at
/// all, and silence cannot distinguish "the site is never reached" from
/// "the flag is false" — so the instrument reports its own state rather
/// than leaving that to inference. Reaching this call proves the site
/// executes; the printed values say whether the flag and the player filter
/// would have let anything through.
/// </summary>
public static void AnnounceStepHeightProbeOnce(bool isPlayerMover)
{
if (System.Threading.Interlocked.Exchange(ref _stepHeightAnnounced, 1) != 0)
return;
Console.WriteLine(
$"[step-h] SELF-REPORT: resolve site reached. "
+ $"ProbeStepHeightsEnabled={ProbeStepHeightsEnabled} "
+ $"(ACDREAM_PROBE_STEP_HEIGHTS="
+ $"{Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") ?? "<unset>"}) "
+ $"firstMoverIsPlayer={isPlayerMover}");
// Which BINARY is this? The 2026-08-07 wrong-checkout incident: two
// checkouts, a relative launch path, and every "test" of a fix ran a
// binary that did not contain it. Assembly identity belongs IN the
// capture, not inferred from bin timestamps afterwards.
Console.WriteLine(
$"[step-h] SELF-REPORT: assembly="
+ $"{typeof(PhysicsDiagnostics).Assembly.Location}");
}
/// <summary>
/// One <c>[step-h]</c> line. Self-guards on
/// <see cref="ProbeStepHeightsEnabled"/>, and is edge-triggered per site:
/// a site that keeps reporting the same pair prints once, so the
/// per-tick resolve site cannot drown the two one-shot sites it has to be
/// compared against.
/// </summary>
/// <param name="site">Where on the chain this reading was taken —
/// <c>prepare</c>, <c>publish</c>, or <c>resolve</c>.</param>
/// <param name="stepUp">The step-up height at that point, in metres.</param>
/// <param name="stepDown">The step-down height at that point.</param>
/// <param name="detail">Free-form provenance: the Setup id, the scale, or
/// which branch produced the value. This is what makes a surprising
/// reading actionable instead of merely surprising.</param>
public static void LogStepHeights(
string site, float stepUp, float stepDown, string detail)
{
if (!ProbeStepHeightsEnabled) return;
lock (_stepHeightGate)
{
if (_stepHeightSeen.TryGetValue(site, out var prev)
&& prev.Up == stepUp && prev.Down == stepDown)
{
return;
}
_stepHeightSeen[site] = (stepUp, stepDown);
}
Console.WriteLine(
$"[step-h] site={site} stepUp={stepUp:F3} stepDown={stepDown:F3} {detail}");
}
// ------------------------------------------------------------------
// S6 (Campaign S, 2026-08-07) — AP-83/AP-91 PerfectClip TOI-tail
// containment guard.
// ------------------------------------------------------------------
/// <summary>
/// S6 reachability proof
/// (<c>docs/research/2026-08-07-s6-perfectclip-containment-contract.md</c>):
/// the ONLY production site that sets
/// <see cref="ObjectInfoState.PerfectClip"/> on a mover is
/// <c>PhysicsCameraCollisionProbe.SweepEye</c>
/// (<c>src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:69</c>),
/// which ALSO sets <see cref="ObjectInfoState.IsViewer"/>. The camera's
/// resolve DOES reach both the Cyl and Sphere PerfectClip time-of-impact
/// tails (<c>TransitionTypes.CylCollideWithPoint</c> / AP-83,
/// <c>SphereCollideWithPoint</c> / AP-91) — neither candidate cut from the
/// S6 scoping doc holds: <c>CollisionExemption.ShouldSkip</c> only
/// exempts a viewer mover against a CREATURE target
/// (<c>CollisionExemption.cs:91-94</c>), and
/// <c>TransitionTypes.FindObjCollisionsInCell</c> walks every cell's
/// shadow list unconditionally, with no viewer/mover-flag gate before the
/// per-target loop (called from <c>FindPrimaryCellCollisions</c>,
/// <c>TransitionTypes.cs:2477</c>). A non-creature Cyl/Sphere-shaped
/// shadow entry is a real production population — static landblock
/// scenery registered by <c>LandblockPhysicsPublisher.PublishStaticEntity</c>
/// with <c>EntityCollisionFlags.None</c> (tracked live via
/// <c>publication.CylinderOwnerCount</c>) — so the camera's foot sphere
/// reaches the tail whenever it overlaps one: the camera is always
/// PathClipped and never grounded (its resolve passes <c>body: null</c>,
/// <c>isOnGround: false</c>), which is exactly Branch 4's PathClipped
/// route in both <c>CylinderCollision</c> and <c>SphereCollision</c>.
/// </summary>
/// <remarks>
/// Call this at the head of the PerfectClip branch inside each TOI tail,
/// after confirming <c>ObjectInfo.State</c> carries
/// <see cref="ObjectInfoState.PerfectClip"/>. A mover that ALSO carries
/// <see cref="ObjectInfoState.IsViewer"/> is the verified-reachable
/// population from the proof above — this is expected production
/// behavior, not a bug, so it is recorded with a plain counter (never an
/// assertion/throw). A mover WITHOUT <c>IsViewer</c> reaching this code
/// has no verified reachability chain — the only prior candidate (a
/// PathClipped missile) was ported per ACE but never armed PerfectClip in
/// M1.5 (<c>PhysicsEngine.cs:1984</c>: "PerfectClip is deliberately not
/// inferred"). That population is logged LOUDLY (one-shot per tail) so a
/// future flag change cannot silently start executing ACE-derived math
/// nobody re-verified.
/// </remarks>
public static void RecordSpherePerfectClipTailReach(bool moverIsViewer) =>
RecordPerfectClipTailReachCore(
"Sphere", moverIsViewer,
ref _sphereToiCameraLiveCount, ref _sphereToiUnverifiedCount,
ref _sphereToiUnverifiedAnnounced);
/// <inheritdoc cref="RecordSpherePerfectClipTailReach"/>
public static void RecordCylPerfectClipTailReach(bool moverIsViewer) =>
RecordPerfectClipTailReachCore(
"Cyl", moverIsViewer,
ref _cylToiCameraLiveCount, ref _cylToiUnverifiedCount,
ref _cylToiUnverifiedAnnounced);
private static void RecordPerfectClipTailReachCore(
string tail, bool moverIsViewer,
ref int cameraLiveCount, ref int unverifiedCount, ref int unverifiedAnnounced)
{
if (moverIsViewer)
{
System.Threading.Interlocked.Increment(ref cameraLiveCount);
return;
}
System.Threading.Interlocked.Increment(ref unverifiedCount);
if (System.Threading.Interlocked.Exchange(ref unverifiedAnnounced, 1) != 0)
return;
Console.WriteLine(
$"[perfectclip-tail] UNVERIFIED mover reached the {tail} PerfectClip "
+ "time-of-impact tail (AP-83/AP-91 — ACE-derived math with no retail "
+ "decompile; verified-reachable population is the camera / IsViewer "
+ "only). A non-viewer mover just executed this path; its reachability "
+ "was never re-verified — see "
+ "docs/research/2026-08-07-s6-perfectclip-containment-contract.md "
+ "before trusting the result.");
}
private static int _sphereToiCameraLiveCount;
private static int _sphereToiUnverifiedCount;
private static int _sphereToiUnverifiedAnnounced;
private static int _cylToiCameraLiveCount;
private static int _cylToiUnverifiedCount;
private static int _cylToiUnverifiedAnnounced;
/// <summary>Diagnostic counter — see <see cref="RecordSpherePerfectClipTailReach"/>.</summary>
public static int SphereToiCameraLiveCount => _sphereToiCameraLiveCount;
/// <summary>Diagnostic counter — see <see cref="RecordSpherePerfectClipTailReach"/>.</summary>
public static int SphereToiUnverifiedCount => _sphereToiUnverifiedCount;
/// <summary>Diagnostic counter — see <see cref="RecordCylPerfectClipTailReach"/>.</summary>
public static int CylToiCameraLiveCount => _cylToiCameraLiveCount;
/// <summary>Diagnostic counter — see <see cref="RecordCylPerfectClipTailReach"/>.</summary>
public static int CylToiUnverifiedCount => _cylToiUnverifiedCount;
/// <summary>
/// Test-only reset for the S6 PerfectClip-tail guard counters. Does NOT
/// reset the one-shot "announced" latches independently of the counts —
/// a full reset (counts AND latches) so a sabotage test that intends to
/// re-trigger the loud unverified log can observe it fire again.
/// </summary>
public static void ResetPerfectClipTailGuardForTest()
{
_sphereToiCameraLiveCount = 0;
_sphereToiUnverifiedCount = 0;
_sphereToiUnverifiedAnnounced = 0;
_cylToiCameraLiveCount = 0;
_cylToiUnverifiedCount = 0;
_cylToiUnverifiedAnnounced = 0;
}
// ── [transit-fail-*] — #345 stuck-tick transition-phase trace ─────────
//
// docs/research/2026-08-08-345-mechanism-contract.md "probe" section:
// walking angled into a too-steep slope eats 100% of the requested XY
// displacement while the resolve reports collN=(0,0,1) — straight up —
// with the output position byte-identical to the input. Naming WHICH
// mechanism writes that UP normal on a completely stalled tick needs a
// per-tick TRANSITION PHASE TRACE, but printing one on every one of the
// ~30 resolves/sec a normal walk takes would drown the signal. So the
// trace is captured into a cheap per-thread buffer during the tick and
// only flushed to the console when ResolveWithTransition's own tail
// confirms the tick requested a nonzero XY move and delivered zero (the
// self-selecting stuck-tick predicate — a healthy session prints
// nothing at all).
//
// Four capture families, one flush:
// [transit-fail-insert] — one line per FindPrimaryCellCollisions call
// (TransitionalInsert's per-attempt env->building->objects
// channel), naming which phase halted the attempt and, on Collided,
// the colliding polygon's normal and which of the three channels
// wrote it.
// [transit-fail-stepup] — DoStepUp entry/exit, mirroring the existing
// ACDREAM_DUMP_STEPUP probe's own input-normal-and-verdict content
// so a stuck-tick capture carries the step chain without running a
// second flag.
// [transit-fail-walk] — every ValidateWalkable outcome: the signed
// distance, waterDepth, which of the four code branches fired, and
// the two flag guards (oi.Contact, sp.StepDown) gating its
// SetCollisionNormal call — the collN=(0,0,1) fingerprint most
// plausibly comes from exactly this site.
// [transit-fail-adjust] — overwritten on every AdjustOffset call, so
// only the LAST one before the tick ends survives to the flush:
// the final per-tick input/output offset pair.
//
// A stuck tick's flush order is: the [transit-fail] summary line
// itself, then every buffered [transit-fail-insert]/
// [transit-fail-stepup]/[transit-fail-walk] line in call order, then
// the one [transit-fail-adjust] line.
//
// All four families are diagnostics-only: nothing here reads back into
// any production decision. [ThreadStatic] because a headless host ticks
// several sessions in parallel and physics is synchronous within each.
// Every capture method tests DumpTransitFailEnabled BEFORE touching any
// buffer, building any string, or reading collision state beyond the
// value-type arguments the caller already had in hand — zero allocation
// when off (Slice I1's TransitionAllocationBaselineTests gate).
// TEMPORARY — strip with the rest of the #345 investigation once the
// mechanism is identified and the fix contract is written.
/// <summary>
/// Initial state from <c>ACDREAM_DUMP_TRANSIT_FAIL=1</c>.
/// </summary>
public static bool DumpTransitFailEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_TRANSIT_FAIL") == "1";
/// <summary>
/// The stuck-tick predicate's "the request was real" threshold, metres
/// squared. 1 mm: comfortably under any legitimate per-tick movement
/// request (the #345 capture's stuck ticks requested ~0.23 m), well over
/// float roundoff.
/// </summary>
private const float TransitFailNonzeroRequestXYSq = 0.001f * 0.001f;
/// <summary>
/// The stuck-tick predicate's "the result was zero" threshold, metres
/// squared. 0.1 mm: the #345 capture's stuck ticks returned a position
/// byte-identical to the input, so this only needs to be tight enough to
/// reject genuine (if small) movement.
/// </summary>
private const float TransitFailZeroXYSq = 0.0001f * 0.0001f;
[ThreadStatic] private static List<string>? _transitFailBuffer;
[ThreadStatic] private static string? _transitFailAdjustLine;
/// <summary>
/// Reset the per-tick buffer. Call once per
/// <see cref="PhysicsEngine.ResolveWithTransition"/> call, before
/// <c>FindTransitionalPosition</c> runs, so a tick that captures nothing
/// does not carry over the previous tick's lines. No-op unless
/// <see cref="DumpTransitFailEnabled"/>.
/// </summary>
public static void BeginTransitFailTrace()
{
if (!DumpTransitFailEnabled) return;
(_transitFailBuffer ??= new List<string>()).Clear();
_transitFailAdjustLine = null;
}
/// <summary>
/// One line per <c>FindPrimaryCellCollisions</c> call — the
/// env-&gt;building-&gt;objects composition inside one
/// <c>TransitionalInsert</c> attempt. <paramref name="phase"/> is
/// <c>"environment"</c>, <c>"building"</c>, or <c>"objects"</c> —
/// whichever channel produced <paramref name="outcome"/> (env/building
/// short-circuit the other two on a non-OK result, matching production
/// control flow, so the skipped channels' states arrive as
/// <see langword="null"/>). On a <see cref="TransitionState.Collided"/>
/// outcome, also names the colliding polygon's plane normal and which
/// of the three channels wrote it (<paramref name="collidedObjectGuid"/>
/// is non-null only for the <c>objects</c> phase).
/// </summary>
public static void TraceTransitInsertAttempt(
uint moverId,
int attempt,
string phase,
TransitionState envState,
TransitionState? buildingState,
TransitionState? objectsState,
TransitionState outcome,
Vector3 collisionNormal,
uint? collidedObjectGuid)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
string buildingText = buildingState is { } b ? b.ToString() : "n/a";
string objectsText = objectsState is { } o ? o.ToString() : "n/a";
string collidedText = outcome == TransitionState.Collided
? string.Format(ci,
" collN=({0:F3},{1:F3},{2:F3}) src={3}",
collisionNormal.X, collisionNormal.Y, collisionNormal.Z,
phase == "objects"
? (collidedObjectGuid is { } guid
? string.Format(ci, "object:0x{0:X8}", guid)
: "object:none")
: phase)
: "";
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
"[transit-fail-insert] mover=0x{0:X8} attempt={1} phase={2} " +
"env={3} building={4} objects={5} outcome={6}{7}",
moverId, attempt, phase, envState, buildingText, objectsText,
outcome, collidedText));
}
/// <summary>
/// One line per <c>Transition.DoStepUp</c> entry or exit, mirroring the
/// content of the existing <c>ACDREAM_DUMP_STEPUP</c> probe (same input
/// normal / walkable verdict / landing-plane fields) so a stuck-tick
/// capture carries the step-up chain without a second flag.
/// <paramref name="edge"/> is <c>"enter"</c> or <c>"exit"</c>;
/// <paramref name="succeeded"/> and <paramref name="landedNormal"/> are
/// only meaningful on <c>"exit"</c>.
/// </summary>
public static void TraceTransitStepUp(
uint moverId,
string edge,
Vector3 inputNormal,
bool onWalkable,
float stepUpHeight,
Vector3 pos,
bool? succeeded,
Vector3? landedNormal)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
float floor = PhysicsGlobals.FloorZ;
string verdict = inputNormal.Z >= floor ? "WALKABLE" : "STEEP";
string outcomeText;
if (succeeded is null)
{
outcomeText = "";
}
else if (succeeded.Value && landedNormal is { } landed)
{
string landedVerdict = landed.Z >= floor ? "WALKABLE" : "STEEP";
outcomeText = string.Format(ci,
" outcome=SUCCESS landedN=({0:F3},{1:F3},{2:F3})->{3}",
landed.X, landed.Y, landed.Z, landedVerdict);
}
else
{
outcomeText = " outcome=FAILED";
}
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
"[transit-fail-stepup] mover=0x{0:X8} edge={1} " +
"n=({2:F3},{3:F3},{4:F3})->{5} onWalkable={6} stepUpHeight={7:F3} " +
"pos=({8:F2},{9:F2},{10:F2}){11}",
moverId, edge,
inputNormal.X, inputNormal.Y, inputNormal.Z, verdict,
onWalkable, stepUpHeight,
pos.X, pos.Y, pos.Z, outcomeText));
}
/// <summary>
/// One line per <c>ValidateWalkable</c> outcome. <paramref name="branch"/>
/// is <c>"above"</c> (comfortably clear of the surface, no state
/// change), <c>"resting"</c> (within EPSILON of the surface),
/// <c>"below-push"</c> (below the surface — covers both the ordinary
/// push and the step-down interpolation reject; <paramref
/// name="outcome"/> distinguishes them), or <c>"checkwalkable-fail"</c>
/// (a walkable probe below the surface, which always fails closed).
/// <paramref name="guardPassed"/> is <see langword="null"/> for branches
/// that never reach the <c>SetCollisionNormal</c> site
/// (<c>"above"</c>/<c>"checkwalkable-fail"</c>/the interpolation
/// reject); otherwise it is the evaluated
/// <c>!oi.Contact &amp;&amp; !sp.StepDown</c> guard, and <paramref
/// name="normal"/> is the plane normal that would have been (or was)
/// written.
/// </summary>
public static void TraceTransitValidateWalkable(
uint moverId,
string branch,
float dist,
float waterDepth,
bool oiContact,
bool spStepDown,
bool? guardPassed,
Vector3 normal,
TransitionState outcome)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
string guardText = guardPassed is { } g ? g.ToString() : "n/a";
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
"[transit-fail-walk] mover=0x{0:X8} branch={1} dist={2:F5} " +
"waterDepth={3:F4} oiContact={4} spStepDown={5} guardPassed={6} " +
"normal=({7:F3},{8:F3},{9:F3}) outcome={10}",
moverId, branch, dist, waterDepth, oiContact, spStepDown,
guardText, normal.X, normal.Y, normal.Z, outcome));
}
/// <summary>
/// Overwritten on every <c>AdjustOffset</c> call within the tick, so
/// only the LAST call before the tick ends survives to the flush — the
/// final per-tick input/output offset pair the mechanism contract asks
/// for.
/// </summary>
public static void TraceTransitAdjustOffset(
uint moverId, string branch, Vector3 offsetIn, Vector3 offsetOut)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
_transitFailAdjustLine = string.Format(ci,
"[transit-fail-adjust] mover=0x{0:X8} branch={1} " +
"in=({2:F4},{3:F4},{4:F4}) out=({5:F4},{6:F4},{7:F4})",
moverId, branch,
offsetIn.X, offsetIn.Y, offsetIn.Z,
offsetOut.X, offsetOut.Y, offsetOut.Z);
}
/// <summary>
/// The stuck-tick predicate, evaluated at
/// <see cref="PhysicsEngine.ResolveWithTransition"/>'s tail where both
/// the requested and the actual XY displacement are known. Fires
/// (flushes the buffered trace to the console) only when the tick
/// requested a real XY move (&gt; <see cref="TransitFailNonzeroRequestXYSq"/>)
/// and delivered essentially none (&lt;
/// <see cref="TransitFailZeroXYSq"/>) — the #345 fingerprint. Always
/// clears the buffer afterward, whether it fired or not, so a healthy
/// tick never leaks into the next one. No-op unless
/// <see cref="DumpTransitFailEnabled"/>.
/// </summary>
public static void EmitTransitFailIfStuck(
uint moverId, Vector3 currentPos, Vector3 targetPos, Vector3 resultPos)
{
if (!DumpTransitFailEnabled) return;
float reqX = targetPos.X - currentPos.X;
float reqY = targetPos.Y - currentPos.Y;
float reqXYSq = reqX * reqX + reqY * reqY;
float actX = resultPos.X - currentPos.X;
float actY = resultPos.Y - currentPos.Y;
float actXYSq = actX * actX + actY * actY;
bool stuck = reqXYSq >= TransitFailNonzeroRequestXYSq
&& actXYSq <= TransitFailZeroXYSq;
if (stuck)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
int lineCount = (_transitFailBuffer?.Count ?? 0)
+ (_transitFailAdjustLine is null ? 0 : 1);
Console.WriteLine(string.Format(ci,
"[transit-fail] mover=0x{0:X8} STUCK-TICK " +
"reqXY=({1:F4},{2:F4}) reqLen={3:F4} " +
"actXY=({4:F4},{5:F4}) actLen={6:F4} " +
"in=({7:F3},{8:F3},{9:F3}) tgt=({10:F3},{11:F3},{12:F3}) " +
"out=({13:F3},{14:F3},{15:F3}) lines={16}",
moverId, reqX, reqY, MathF.Sqrt(reqXYSq),
actX, actY, MathF.Sqrt(actXYSq),
currentPos.X, currentPos.Y, currentPos.Z,
targetPos.X, targetPos.Y, targetPos.Z,
resultPos.X, resultPos.Y, resultPos.Z,
lineCount));
if (_transitFailBuffer is { Count: > 0 } buffer)
{
foreach (string line in buffer)
Console.WriteLine(line);
}
if (_transitFailAdjustLine is not null)
Console.WriteLine(_transitFailAdjustLine);
}
_transitFailBuffer?.Clear();
_transitFailAdjustLine = null;
}
private static int ParsePositiveInt(string? value) =>
int.TryParse(
value,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
&& parsed > 0
? parsed
: 0;
}