A remote observed in acdream landed on a sloped roof and froze; the server slid
on, the gap passed AP-87's 4 m threshold, and the body snapped — the visible
blip. Live probe capture, two adjacent ticks 63 ms apart:
t=88420671 rsInContact=True rsOnWalkable=False rsIsOnGround=True
bodyCpNz=0.6097 floorZ=0.6642 steep=True gravity=True
vel=(2.146,2.264,-3.549)
t=88420734 contact=True onWalkable=True <- forced against the sweep
gravity=False <- cleared
velBeforeZero=(2.146,2.264,0.000)
moved=0.0000 <- and every tick after
The roof is 52.4 degrees against a 48.4 degree limit, so acdream's classifier
was CORRECT and was then overruled. Four independent links each froze the body
on their own: a per-tick force of Contact|OnWalkable, a per-tick velocity zero,
a Gravity clear at landing, and a landing edge testing IsOnGround
(= inContact || ...) instead of OnWalkable. The tick called
HandleAllCollisions alone — the tail of SetPositionInternal without its prefix.
Retail simulates remotes locally and derives these bits rather than asserting
them: CPhysics::UseTime @0x00509950 iterates the whole object table;
update_object @0x00515D10 gates only on parent/cell/FROZEN with no
is_player fork; SetPositionInternal @0x00515330 sets CONTACT from
contact_plane_valid @0x00515430 and ON_WALKABLE from contact_plane.N.z vs
floor_z @0x00515465-@0x0051548E before handle_all_collisions @0x005154FE;
set_on_walkable @0x00511310 fires HitGround @0x00511364 / LeaveGround
@0x00511346 edge-triggered with no ownership gate; calc_acceleration
@0x00510950 zeroes only when CONTACT && ON_WALKABLE && !Sledding @0x0051096B;
calc_friction @0x0050EE70 returns at its first line when ON_WALKABLE is clear.
acdream had copied retail's airborne no-op WITHOUT retail's local simulation.
The fix is mostly deletion: stop forging the transients, stop discarding the
authoritative velocity, stop clearing Gravity, and route the remote tick
through the same SetPositionInternal commit TickHidden and the local player
already use, with the landing edge derived from the sweep's own OnWalkable.
AP-87's threshold and conditions and InterpolationManager's node_fail_counter
snap-to-tail are deliberately untouched — this removes the CAUSE of the
divergence rather than weakening the backstop.
Cross-checked against ACE: its only creature-side VectorUpdate emitters are the
jump broadcast and spell projectiles, so integrating the wire velocity cannot
double-move a walking remote; and PhysicsGlobals.DefaultState already carries
Gravity, so deleting the manufactured State |= Gravity is safe.
Register: AP-81 narrowed (its GRAVITY half retired outright), AP-87 annotated,
AP-139 filed (the interpolation-queue clear on the landing edge), AP-140 filed
(the two routing gates select snap-vs-interpolate on walkability where retail
uses CONTACT — adjust_offset @0x00555D30 gates on transient_state & 1
@0x00555D52). AP-140's follow-up is deliberately shaped as "point the two gates
at Body.InContact", NOT "re-derive Airborne", which would perturb five writers
and collide with a pinned RemoteTeleportPlacementTests assertion.
Three gaps recorded in #32 rather than papered over: the new LeaveGround
dispatch is untested for chatter; a persistently !Ok transition can latch a
remote airborne; and — the visual-gate watch item — the deleted forge was a
blanket guarantee of Contact|OnWalkable, and contact_allows_move @0x00528dd0
silently refuses action animations without both, which is the literal root
cause of closed #270. Retail-correct on a steep face, a regression anywhere
else.
10 discriminating tests over a real PhysicsEngine landblock whose contact
normal Z is 0.61 against FloorZ 0.6642 — the live roof's exact relationship.
Suite 11,019 passed / 4 skipped / 0 failed. Includes the temporary
ACDREAM_PROBE_REMOTE_LANDING / ACDREAM_PROBE_REMOTE_SLIDE probe family that
produced the capture above; strip with the family.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1643 lines
77 KiB
C#
1643 lines
77 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>
|
||
/// 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>
|
||
/// 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";
|
||
|
||
/// <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>
|
||
/// 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;
|
||
ProbeTeleportEnabled = false;
|
||
ProbeRemoteLandingEnabled = false;
|
||
ProbeRemoteSlideEnabled = false;
|
||
ProbeRemoteSlideGuids = new System.Collections.Generic.HashSet<uint>();
|
||
_remoteSlideAttributionGuid = 0;
|
||
_remoteSlideTickGate = null;
|
||
|
||
// Side-channel fields
|
||
LastBspHitPoly = null;
|
||
LastPlacementFailPolyId = 0;
|
||
LastPlacementFailPolyNormal = default;
|
||
LastPlacementFailPolyD = 0f;
|
||
LastPlacementFailSolidLeaf = false;
|
||
|
||
// Dump-trigger sets
|
||
ProbeDumpCellIds = new System.Collections.Generic.HashSet<uint>();
|
||
ProbeDumpGfxObjIds = new System.Collections.Generic.HashSet<uint>();
|
||
}
|
||
|
||
private static IReadOnlySet<uint> ParseHexIdList(string? raw)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(raw))
|
||
return new System.Collections.Generic.HashSet<uint>();
|
||
|
||
var ids = new System.Collections.Generic.HashSet<uint>();
|
||
foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||
{
|
||
var trimmed = token.Trim();
|
||
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||
trimmed = trimmed[2..];
|
||
|
||
if (uint.TryParse(
|
||
trimmed,
|
||
System.Globalization.NumberStyles.HexNumber,
|
||
System.Globalization.CultureInfo.InvariantCulture,
|
||
out var id))
|
||
{
|
||
ids.Add(id);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
|
||
/// at the leaf where it returns true. Either
|
||
/// <see cref="LastPlacementFailPolyId"/> identifies the polygon that
|
||
/// intersected the sphere, or <see cref="LastPlacementFailSolidLeaf"/>
|
||
/// is true to indicate the sphere center landed inside a BSP leaf
|
||
/// marked solid (no specific polygon). The caller (Path 1) reads
|
||
/// these immediately after the true return to emit the
|
||
/// <c>[place-fail]</c> line, then clears them before the next test.
|
||
///
|
||
/// <para>
|
||
/// Writes are gated on <see cref="ProbePlacementFailEnabled"/> so the
|
||
/// production path pays only one boolean check per leaf hit when the
|
||
/// probe is off.
|
||
/// </para>
|
||
/// </summary>
|
||
public static ushort LastPlacementFailPolyId { get; set; }
|
||
/// <inheritdoc cref="LastPlacementFailPolyId"/>
|
||
public static Vector3 LastPlacementFailPolyNormal { get; set; }
|
||
/// <inheritdoc cref="LastPlacementFailPolyId"/>
|
||
public static float LastPlacementFailPolyD { get; set; }
|
||
/// <inheritdoc cref="LastPlacementFailPolyId"/>
|
||
public static bool LastPlacementFailSolidLeaf { get; set; }
|
||
|
||
/// <summary>
|
||
/// Emit one <c>[place-fail]</c> line for a placement_insert rejection.
|
||
/// <paramref name="source"/> tags the call site (e.g.
|
||
/// <c>"Path1.sphere0"</c> for the foot sphere in Path 1,
|
||
/// <c>"Path1.sphere1"</c> for the head sphere,
|
||
/// <c>"DoStepDown"</c> for the wrapper). The polygon (or solid leaf)
|
||
/// fields come from the side-channel populated during the recursive
|
||
/// BSP descent.
|
||
///
|
||
/// <para>Caller MUST guard with <c>if (!ProbePlacementFailEnabled) return;</c>.</para>
|
||
/// </summary>
|
||
public static void LogPlacementFail(
|
||
string source,
|
||
Vector3 sphereCenter,
|
||
float radius,
|
||
int sphereIdx,
|
||
uint cellId,
|
||
Vector3 worldOrigin,
|
||
bool ethereal)
|
||
{
|
||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||
string polyDesc = LastPlacementFailSolidLeaf
|
||
? "solid_leaf=1"
|
||
: LastPlacementFailPolyId != 0
|
||
? string.Format(ci, "polyId=0x{0:X4} n=({1:F4},{2:F4},{3:F4}) d={4:F4}",
|
||
LastPlacementFailPolyId,
|
||
LastPlacementFailPolyNormal.X, LastPlacementFailPolyNormal.Y, LastPlacementFailPolyNormal.Z,
|
||
LastPlacementFailPolyD)
|
||
: "no_poly_info";
|
||
|
||
Console.WriteLine(string.Format(ci,
|
||
"[place-fail] source={0} cell=0x{1:X8} sphere=({2:F4},{3:F4},{4:F4}) r={5:F4} " +
|
||
"sphereIdx={6} worldOrigin=({7:F4},{8:F4},{9:F4}) ethereal={10} {11}",
|
||
source, cellId, sphereCenter.X, sphereCenter.Y, sphereCenter.Z, radius,
|
||
sphereIdx, worldOrigin.X, worldOrigin.Y, worldOrigin.Z, ethereal, polyDesc));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A6.P1 emission helper for the <c>AdjustSphereToPlane</c> site.
|
||
/// One line per call: input sphere center, plane geometry, push-back
|
||
/// delta, walk-interp before/after, and whether the adjust applied.
|
||
/// Direct paired comparison to retail's cdb breakpoint on
|
||
/// <c>CPolygon::adjust_sphere_to_plane</c>.
|
||
///
|
||
/// <para>
|
||
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
|
||
/// before computing the delta arguments — this method assumes the
|
||
/// caller paid that price already.
|
||
/// </para>
|
||
/// </summary>
|
||
public static void LogPushBackAdjust(
|
||
Vector3 inputCenter,
|
||
Vector3 outputCenter,
|
||
Plane plane,
|
||
float radius,
|
||
float walkInterpBefore,
|
||
float walkInterpAfter,
|
||
float dpPos,
|
||
float dpMove,
|
||
float iDist,
|
||
bool applied)
|
||
{
|
||
var delta = outputCenter - inputCenter;
|
||
float deltaMag = delta.Length();
|
||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||
Console.WriteLine(string.Format(ci,
|
||
"[push-back] site=adjust_sphere " +
|
||
"in=({0:F4},{1:F4},{2:F4}) " +
|
||
"out=({3:F4},{4:F4},{5:F4}) " +
|
||
"delta=({6:F4},{7:F4},{8:F4}) deltaMag={9:F4} " +
|
||
"n=({10:F4},{11:F4},{12:F4}) d={13:F4} " +
|
||
"r={14:F4} winterp={15:F4}->{16:F4} " +
|
||
"dpPos={17:F4} dpMove={18:F4} iDist={19:F4} applied={20}",
|
||
inputCenter.X, inputCenter.Y, inputCenter.Z,
|
||
outputCenter.X, outputCenter.Y, outputCenter.Z,
|
||
delta.X, delta.Y, delta.Z, deltaMag,
|
||
plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D,
|
||
radius, walkInterpBefore, walkInterpAfter,
|
||
dpPos, dpMove, iDist, applied));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A6.P1 emission helper for the <c>FindCollisions</c> dispatcher
|
||
/// site. One line per call: input sphere center, movement vector,
|
||
/// path-selection state flags (collide / insertType / objState),
|
||
/// walk-interp at entry, and the return state. Direct paired
|
||
/// comparison to retail's cdb breakpoint on
|
||
/// <c>BSPTREE::find_collisions</c>.
|
||
///
|
||
/// <para>
|
||
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
|
||
/// before calling.
|
||
/// </para>
|
||
/// </summary>
|
||
public static void LogPushBackDispatch(
|
||
Vector3 sphereCenter,
|
||
Vector3 movement,
|
||
bool collide,
|
||
int insertType,
|
||
int objState,
|
||
float walkInterpEntry,
|
||
int returnState)
|
||
{
|
||
// Output format mirrors LogPushBackAdjust: single string.Format
|
||
// with CultureInfo.InvariantCulture so the F4 / hex formatting is
|
||
// locale-independent and the output is greppable.
|
||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||
Console.WriteLine(string.Format(ci,
|
||
"[push-back-disp] site=dispatch " +
|
||
"center=({0:F4},{1:F4},{2:F4}) " +
|
||
"mvmt=({3:F4},{4:F4},{5:F4}) " +
|
||
"collide={6} insertType={7} objState=0x{8:X} " +
|
||
"winterp={9:F4} return={10}",
|
||
sphereCenter.X, sphereCenter.Y, sphereCenter.Z,
|
||
movement.X, movement.Y, movement.Z,
|
||
collide, insertType, objState,
|
||
walkInterpEntry, returnState));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A6.P1 emission helper for the <c>CheckOtherCells</c> multi-cell
|
||
/// BSP iteration site. One line per off-cell hit: from-cell, to-cell,
|
||
/// BSP result (Ok / Adjusted / Slid / Collided), and the iteration
|
||
/// outcome. Direct paired comparison to retail's
|
||
/// <c>CTransition::check_other_cells</c> loop at decomp line
|
||
/// 272717. Augments the existing A4 multi-cell BSP instrumentation
|
||
/// with explicit per-iteration outcome telemetry.
|
||
///
|
||
/// <para>
|
||
/// Caller MUST guard with <c>if (!ProbePushBackEnabled) return;</c>
|
||
/// before calling.
|
||
/// </para>
|
||
/// </summary>
|
||
public static void LogPushBackCellTransit(
|
||
uint primaryCellId,
|
||
uint otherCellId,
|
||
int bspResult,
|
||
bool halted)
|
||
{
|
||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||
Console.WriteLine(string.Format(ci,
|
||
"[push-back-cell] site=other_cell " +
|
||
"primary=0x{0:X8} other=0x{1:X8} " +
|
||
"bspResult={2} halted={3}",
|
||
primaryCellId, otherCellId, bspResult, halted));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Emit one <c>[step-walk]</c> line for issue #98's cellar-ramp
|
||
/// investigation. Caller MUST guard with
|
||
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
|
||
/// </summary>
|
||
public static void LogStepWalk(
|
||
string site,
|
||
int stepIndex,
|
||
int stepCount,
|
||
SpherePath sp,
|
||
CollisionInfo ci,
|
||
ObjectInfo oi,
|
||
Vector3 requestedOffset,
|
||
Vector3 adjustedOffset,
|
||
TransitionState? state = null,
|
||
string? detail = null)
|
||
{
|
||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||
var checkDelta = sp.CheckPos - sp.CurPos;
|
||
string stateText = state.HasValue ? state.Value.ToString() : "n/a";
|
||
string stepText = stepIndex >= 0 && stepCount > 0
|
||
? string.Format(culture, "{0}/{1}", stepIndex + 1, stepCount)
|
||
: "-";
|
||
|
||
Console.WriteLine(string.Format(culture,
|
||
"[step-walk] site={0} step={1} state={2} " +
|
||
"cur=({3:F4},{4:F4},{5:F4}) check=({6:F4},{7:F4},{8:F4}) " +
|
||
"delta=({9:F4},{10:F4},{11:F4}) cell=0x{12:X8}->0x{13:X8} " +
|
||
"req=({14:F4},{15:F4},{16:F4}) adj=({17:F4},{18:F4},{19:F4}) " +
|
||
"winterp={20:F4} stepUp={21} stepDown={22} insert={23} " +
|
||
"oi=0x{24:X} contact={25} onWalkable={26} " +
|
||
"cp={27} lkcp={28} hit={29} slide={30} walkPoly={31} lastWalkPoly={32}{33}",
|
||
site, stepText, stateText,
|
||
sp.CurPos.X, sp.CurPos.Y, sp.CurPos.Z,
|
||
sp.CheckPos.X, sp.CheckPos.Y, sp.CheckPos.Z,
|
||
checkDelta.X, checkDelta.Y, checkDelta.Z,
|
||
sp.CurCellId, sp.CheckCellId,
|
||
requestedOffset.X, requestedOffset.Y, requestedOffset.Z,
|
||
adjustedOffset.X, adjustedOffset.Y, adjustedOffset.Z,
|
||
sp.WalkInterp,
|
||
sp.StepUp, sp.StepDown, sp.InsertType,
|
||
(uint)oi.State, oi.Contact, oi.OnWalkable,
|
||
FormatPlane(ci.ContactPlaneValid, ci.ContactPlane, ci.ContactPlaneCellId, ci.ContactPlaneIsWater),
|
||
FormatPlane(ci.LastKnownContactPlaneValid, ci.LastKnownContactPlane, ci.LastKnownContactPlaneCellId, ci.LastKnownContactPlaneIsWater),
|
||
FormatVector(ci.CollisionNormalValid, ci.CollisionNormal),
|
||
FormatVector(ci.SlidingNormalValid, ci.SlidingNormal),
|
||
sp.HasWalkablePolygon, sp.HasLastWalkablePolygon,
|
||
string.IsNullOrEmpty(detail) ? string.Empty : " " + detail));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A6.P3 issue #98 (2026-05-23) — focused probe INSIDE
|
||
/// <see cref="Transition.AdjustOffset"/> revealing which branch was
|
||
/// taken and the per-call Z gain. Pair with <c>[step-walk]
|
||
/// site=after-adjust</c> at the call site to triangulate where the
|
||
/// projection ends up. Caller MUST guard with
|
||
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
|
||
/// </summary>
|
||
public static void LogStepWalkAdjust(
|
||
string branch,
|
||
Vector3 input,
|
||
Vector3 output,
|
||
Plane? contactPlane,
|
||
bool slidingValid,
|
||
Vector3 slidingNormal,
|
||
float collisionAngle,
|
||
float walkInterp)
|
||
{
|
||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||
|
||
string cpDesc = contactPlane is { } cp
|
||
? string.Format(culture,
|
||
"n=({0:F4},{1:F4},{2:F4}) d={3:F4}",
|
||
cp.Normal.X, cp.Normal.Y, cp.Normal.Z, cp.D)
|
||
: "n/a";
|
||
|
||
string slideDesc = slidingValid
|
||
? string.Format(culture,
|
||
"({0:F4},{1:F4},{2:F4})",
|
||
slidingNormal.X, slidingNormal.Y, slidingNormal.Z)
|
||
: "n/a";
|
||
|
||
Console.WriteLine(string.Format(culture,
|
||
"[step-walk-adjust] branch={0} input=({1:F4},{2:F4},{3:F4}) " +
|
||
"output=({4:F4},{5:F4},{6:F4}) zGain={7:F4} " +
|
||
"cp={8} slide={9} colAngle={10:F4} winterp={11:F4}",
|
||
branch,
|
||
input.X, input.Y, input.Z,
|
||
output.X, output.Y, output.Z,
|
||
output.Z - input.Z,
|
||
cpDesc, slideDesc, collisionAngle, walkInterp));
|
||
}
|
||
|
||
private static string FormatVector(bool valid, Vector3 value)
|
||
{
|
||
if (!valid)
|
||
return "n/a";
|
||
|
||
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"({0:F4},{1:F4},{2:F4})",
|
||
value.X, value.Y, value.Z);
|
||
}
|
||
|
||
private static string FormatPlane(bool valid, Plane plane, uint cellId, bool isWater)
|
||
{
|
||
if (!valid)
|
||
return "n/a";
|
||
|
||
float zAtOrigin = MathF.Abs(plane.Normal.Z) > PhysicsGlobals.EPSILON
|
||
? -plane.D / plane.Normal.Z
|
||
: float.NaN;
|
||
|
||
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"cell=0x{0:X8},water={1},n=({2:F4},{3:F4},{4:F4}),d={5:F4},z0={6:F4}",
|
||
cellId, isWater,
|
||
plane.Normal.X, plane.Normal.Y, plane.Normal.Z, plane.D,
|
||
zAtOrigin);
|
||
}
|
||
|
||
public static void LogCpBoolWrite(string field, bool oldValue, bool newValue)
|
||
{
|
||
var caller = GetCpCallerName();
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[cp-write] {field}: {oldValue} -> {newValue} caller={caller}"));
|
||
}
|
||
|
||
public static void LogCpPlaneWrite(string field, Plane oldPlane, Plane newPlane)
|
||
{
|
||
var caller = GetCpCallerName();
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[cp-write] {field}: n=({oldPlane.Normal.X:F3},{oldPlane.Normal.Y:F3},{oldPlane.Normal.Z:F3}) D={oldPlane.D:F3} -> n=({newPlane.Normal.X:F3},{newPlane.Normal.Y:F3},{newPlane.Normal.Z:F3}) D={newPlane.D:F3} caller={caller}"));
|
||
}
|
||
|
||
public static void LogCpCellIdWrite(string field, uint oldValue, uint newValue)
|
||
{
|
||
var caller = GetCpCallerName();
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[cp-write] {field}: 0x{oldValue:X8} -> 0x{newValue:X8} caller={caller}"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Walks the stack to identify the first frame outside <c>CollisionInfo</c>
|
||
/// and <c>PhysicsDiagnostics</c> — that's the actual caller writing the
|
||
/// ContactPlane field. Format: <c>TypeName.MethodName:line</c> when file
|
||
/// info is available, else just <c>TypeName.MethodName</c>. Walked with
|
||
/// <c>fileNeeded=true</c> only when the probe flag is on, so zero cost
|
||
/// when off.
|
||
/// </summary>
|
||
private static string GetCpCallerName()
|
||
{
|
||
// Skip 2: this method + the LogCp*Write helper that called it.
|
||
var st = new System.Diagnostics.StackTrace(2, fNeedFileInfo: true);
|
||
for (int i = 0; i < st.FrameCount; i++)
|
||
{
|
||
var f = st.GetFrame(i);
|
||
var m = f?.GetMethod();
|
||
if (m is null) continue;
|
||
var typeName = m.DeclaringType?.Name ?? "?";
|
||
if (typeName == "CollisionInfo" || typeName == "PhysicsDiagnostics") continue;
|
||
int line = f?.GetFileLineNumber() ?? 0;
|
||
return line > 0 ? $"{typeName}.{m.Name}:{line}" : $"{typeName}.{m.Name}";
|
||
}
|
||
return "?";
|
||
}
|
||
|
||
private static int ParsePositiveInt(string? value) =>
|
||
int.TryParse(
|
||
value,
|
||
System.Globalization.NumberStyles.None,
|
||
System.Globalization.CultureInfo.InvariantCulture,
|
||
out int parsed)
|
||
&& parsed > 0
|
||
? parsed
|
||
: 0;
|
||
}
|