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
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>
3091 lines
145 KiB
C#
3091 lines
145 KiB
C#
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 > 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 > 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 > 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:<result></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/<cellid>.json</c>
|
||
/// (relative to the worktree root). Override with
|
||
/// <c>ACDREAM_DUMP_CELLS_DIR=<dir></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=<dir></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 & 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->building->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 && !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 (> <see cref="TransitFailNonzeroRequestXYSq"/>)
|
||
/// and delivered essentially none (<
|
||
/// <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;
|
||
}
|