diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
index 0a5eeb07..8cee4874 100644
--- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -2775,6 +2775,310 @@ public static class PhysicsDiagnostics
_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.
+
+ ///
+ /// Initial state from ACDREAM_DUMP_TRANSIT_FAIL=1.
+ ///
+ public static bool DumpTransitFailEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_DUMP_TRANSIT_FAIL") == "1";
+
+ ///
+ /// 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.
+ ///
+ private const float TransitFailNonzeroRequestXYSq = 0.001f * 0.001f;
+
+ ///
+ /// 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.
+ ///
+ private const float TransitFailZeroXYSq = 0.0001f * 0.0001f;
+
+ [ThreadStatic] private static List? _transitFailBuffer;
+ [ThreadStatic] private static string? _transitFailAdjustLine;
+
+ ///
+ /// Reset the per-tick buffer. Call once per
+ /// call, before
+ /// FindTransitionalPosition runs, so a tick that captures nothing
+ /// does not carry over the previous tick's lines. No-op unless
+ /// .
+ ///
+ public static void BeginTransitFailTrace()
+ {
+ if (!DumpTransitFailEnabled) return;
+ (_transitFailBuffer ??= new List()).Clear();
+ _transitFailAdjustLine = null;
+ }
+
+ ///
+ /// One line per FindPrimaryCellCollisions call — the
+ /// env->building->objects composition inside one
+ /// TransitionalInsert attempt. is
+ /// "environment", "building", or "objects" —
+ /// whichever channel produced (env/building
+ /// short-circuit the other two on a non-OK result, matching production
+ /// control flow, so the skipped channels' states arrive as
+ /// ). On a
+ /// outcome, also names the colliding polygon's plane normal and which
+ /// of the three channels wrote it (
+ /// is non-null only for the objects phase).
+ ///
+ 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()).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));
+ }
+
+ ///
+ /// One line per Transition.DoStepUp entry or exit, mirroring the
+ /// content of the existing ACDREAM_DUMP_STEPUP probe (same input
+ /// normal / walkable verdict / landing-plane fields) so a stuck-tick
+ /// capture carries the step-up chain without a second flag.
+ /// is "enter" or "exit";
+ /// and are
+ /// only meaningful on "exit".
+ ///
+ 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()).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));
+ }
+
+ ///
+ /// One line per ValidateWalkable outcome.
+ /// is "above" (comfortably clear of the surface, no state
+ /// change), "resting" (within EPSILON of the surface),
+ /// "below-push" (below the surface — covers both the ordinary
+ /// push and the step-down interpolation reject; distinguishes them), or "checkwalkable-fail"
+ /// (a walkable probe below the surface, which always fails closed).
+ /// is for branches
+ /// that never reach the SetCollisionNormal site
+ /// ("above"/"checkwalkable-fail"/the interpolation
+ /// reject); otherwise it is the evaluated
+ /// !oi.Contact && !sp.StepDown guard, and is the plane normal that would have been (or was)
+ /// written.
+ ///
+ 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()).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));
+ }
+
+ ///
+ /// Overwritten on every AdjustOffset 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// The stuck-tick predicate, evaluated at
+ /// '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 (> )
+ /// and delivered essentially none (<
+ /// ) — 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
+ /// .
+ ///
+ 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,
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index ba2445ad..d73b5979 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -1922,6 +1922,12 @@ public sealed class PhysicsEngine
// sentinel value for that case. No-op when the probe is off.
PhysicsDiagnostics.BeginContactPlaneAttribution();
+ // #345 probe (2026-08-08): reset the per-tick transition-phase trace
+ // buffer. Scoped to exactly one resolve/tick — see
+ // PhysicsDiagnostics.BeginTransitFailTrace. No-op when the probe is
+ // off.
+ PhysicsDiagnostics.BeginTransitFailTrace();
+
var transition = RentTransition();
try
{
@@ -2470,6 +2476,13 @@ public sealed class PhysicsEngine
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
}
+ // #345 probe (2026-08-08): the self-selecting stuck-tick
+ // predicate — fires only when this tick requested a real XY
+ // move and delivered none, flushing the buffered
+ // transition-phase trace. No-op when the probe is off.
+ PhysicsDiagnostics.EmitTransitFailIfStuck(
+ movingEntityId, currentPos, targetPos, resolveResult.Position);
+
// A6.P3 #98 capture: emit one JSON Lines record per player call,
// with bodyBefore snapshot (taken at method entry, before any
// engine mutation) + bodyAfter snapshot (taken now, after the
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 6e85a7e6..59ebf255 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2460,7 +2460,16 @@ public sealed class Transition
cellId,
FindEnvCollisions(engine, cellId));
if (environment != TransitionState.OK)
+ {
+ // #345 probe (2026-08-08): buffered into PhysicsDiagnostics'
+ // stuck-tick trace; self-guards internally, zero cost when
+ // ACDREAM_DUMP_TRANSIT_FAIL is unset.
+ PhysicsDiagnostics.TraceTransitInsertAttempt(
+ ObjectInfo.SelfEntityId, innerAttempt, "environment",
+ environment, null, null, environment,
+ CollisionInfo.CollisionNormal, CollisionInfo.LastCollidedObjectGuid);
return environment;
+ }
TransitionState building = ObservePrimaryCellPhase(
engine,
@@ -2468,7 +2477,13 @@ public sealed class Transition
cellId,
FindBuildingCollisions(engine, cellId));
if (building != TransitionState.OK)
+ {
+ PhysicsDiagnostics.TraceTransitInsertAttempt(
+ ObjectInfo.SelfEntityId, innerAttempt, "building",
+ environment, building, null, building,
+ CollisionInfo.CollisionNormal, CollisionInfo.LastCollidedObjectGuid);
return building;
+ }
TransitionState objects = ObservePrimaryCellPhase(
engine,
@@ -2476,6 +2491,10 @@ public sealed class Transition
cellId,
FindObjCollisionsInCell(engine, cellId));
DumpPhase2(innerAttempt, environment, objects);
+ PhysicsDiagnostics.TraceTransitInsertAttempt(
+ ObjectInfo.SelfEntityId, innerAttempt, "objects",
+ environment, building, objects, objects,
+ CollisionInfo.CollisionNormal, CollisionInfo.LastCollidedObjectGuid);
return objects;
}
@@ -3685,17 +3704,40 @@ public sealed class Transition
CacheWalkableContext(sp, contactPlane, walkableVertices);
}
- if (!oi.Contact && !sp.StepDown)
+ // #345 probe (2026-08-08): named local so the trace call
+ // below can report exactly what the production guard
+ // evaluated to, without changing the guard itself.
+ bool restingGuardPassed = !oi.Contact && !sp.StepDown;
+ if (restingGuardPassed)
{
ci.SetCollisionNormal(contactPlane.Normal);
ci.CollidedWithEnvironment = true;
}
+
+ PhysicsDiagnostics.TraceTransitValidateWalkable(
+ oi.SelfEntityId, "resting", dist, waterDepth,
+ oi.Contact, sp.StepDown, restingGuardPassed,
+ contactPlane.Normal, TransitionState.OK);
+ }
+ else
+ {
+ PhysicsDiagnostics.TraceTransitValidateWalkable(
+ oi.SelfEntityId, "above", dist, waterDepth,
+ oi.Contact, sp.StepDown, guardPassed: null,
+ contactPlane.Normal, TransitionState.OK);
}
return TransitionState.OK;
}
// ── Below the surface ─────────────────────────────────────────────
- if (sp.CheckWalkable) return TransitionState.Collided; // walkable probe fails
+ if (sp.CheckWalkable)
+ {
+ PhysicsDiagnostics.TraceTransitValidateWalkable(
+ oi.SelfEntityId, "checkwalkable-fail", dist, waterDepth,
+ oi.Contact, sp.StepDown, guardPassed: null,
+ contactPlane.Normal, TransitionState.Collided);
+ return TransitionState.Collided; // walkable probe fails
+ }
// zDist: how far we need to push up along Z to clear the surface.
// contactPlane.Normal.Z is 1 for flat ground, so this is just dist.
@@ -3712,7 +3754,13 @@ public sealed class Transition
// Validate step-down interpolation factor.
float interp = (1f - (-1f / (sp.StepDownAmt * sp.WalkInterp)) * zDist) * sp.WalkInterp;
if (interp >= sp.WalkInterp || interp < -0.1f)
+ {
+ PhysicsDiagnostics.TraceTransitValidateWalkable(
+ oi.SelfEntityId, "below-push", dist, waterDepth,
+ oi.Contact, sp.StepDown, guardPassed: null,
+ contactPlane.Normal, TransitionState.Collided);
return TransitionState.Collided;
+ }
sp.WalkInterp = interp;
}
@@ -3720,12 +3768,20 @@ public sealed class Transition
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -zDist));
}
- if (!oi.Contact && !sp.StepDown)
+ // #345 probe (2026-08-08): named local, same rationale as
+ // restingGuardPassed above.
+ bool belowPushGuardPassed = !oi.Contact && !sp.StepDown;
+ if (belowPushGuardPassed)
{
ci.SetCollisionNormal(contactPlane.Normal);
ci.CollidedWithEnvironment = true;
}
+ PhysicsDiagnostics.TraceTransitValidateWalkable(
+ oi.SelfEntityId, "below-push", dist, waterDepth,
+ oi.Contact, sp.StepDown, belowPushGuardPassed,
+ contactPlane.Normal, TransitionState.Adjusted);
+
return TransitionState.Adjusted;
}
@@ -5563,6 +5619,12 @@ public sealed class Transition
collisionAngle: 0f,
walkInterp: sp.WalkInterp);
+ // #345 probe (2026-08-08): overwrites the buffered "final
+ // AdjustOffset" slot every call; only the last write before the
+ // tick ends survives to the stuck-tick flush.
+ PhysicsDiagnostics.TraceTransitAdjustOffset(
+ ObjectInfo.SelfEntityId, branch, offset, result);
+
return result;
}
@@ -5685,6 +5747,11 @@ public sealed class Transition
collisionAngle: collisionAngle,
walkInterp: sp.WalkInterp);
+ // #345 probe (2026-08-08): same overwrite as the no-contact-plane
+ // early return above.
+ PhysicsDiagnostics.TraceTransitAdjustOffset(
+ ObjectInfo.SelfEntityId, branch, offset, result);
+
return result;
}
@@ -5912,6 +5979,18 @@ public sealed class Transition
$"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})");
}
+ // #345 probe (2026-08-08): mirrors the ACDREAM_DUMP_STEPUP content
+ // above into the buffered stuck-tick trace (self-guards internally,
+ // zero cost when ACDREAM_DUMP_TRANSIT_FAIL is unset) so a stuck-tick
+ // capture carries the step-up chain without a second flag.
+ PhysicsDiagnostics.TraceTransitStepUp(
+ oi.SelfEntityId, "enter", collisionNormal,
+ onWalkable: (oi.State & ObjectInfoState.OnWalkable) != 0,
+ stepUpHeight: oi.StepUpHeight,
+ pos: sp.CurPos,
+ succeeded: null,
+ landedNormal: null);
+
// L.2.3c (2026-04-29): capture the existing contact plane BEFORE
// clearing it. On step-up failure (too-tall wall) we restore it so
// the mover stays grounded — without this, walking into a wall
@@ -5971,6 +6050,18 @@ public sealed class Transition
}
}
+ // #345 probe (2026-08-08): the matching exit line for the "enter"
+ // trace above.
+ PhysicsDiagnostics.TraceTransitStepUp(
+ oi.SelfEntityId, "exit", collisionNormal,
+ onWalkable: (oi.State & ObjectInfoState.OnWalkable) != 0,
+ stepUpHeight: oi.StepUpHeight,
+ pos: sp.CheckPos,
+ succeeded: stepDown,
+ landedNormal: stepDown && ci.ContactPlaneValid
+ ? ci.ContactPlane.Normal
+ : null);
+
if (!stepDown)
{
sp.RestoreCheckPos();
diff --git a/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs b/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs
new file mode 100644
index 00000000..99aaf6fa
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// #345 mechanism-session probe gate
+/// (docs/research/2026-08-08-345-mechanism-contract.md "probe"
+/// section). ACDREAM_DUMP_TRANSIT_FAIL /
+/// must fire on a
+/// tick that requests real XY movement and delivers none (the stuck-tick
+/// fingerprint from the #345 uphill capture: a resolve returning a position
+/// byte-identical to the input against a nonzero request), and must stay
+/// silent on an ordinary moving tick — a healthy session prints nothing at
+/// all.
+///
+///
+/// The synthetic fixture reuses (a
+/// floor at z=0 plus a 5 m wall at x=0.5, "too tall to step over" by design
+/// — the same fixture TransitionAllocationBaselineTests already
+/// drives with an identical player profile) with the sphere already resting
+/// flush against the wall and a purely perpendicular (no lateral component)
+/// movement request, so the whole requested displacement is expected to be
+/// absorbed by the wall's contact-plane projection with nothing left to
+/// slide along.
+///
+///
+public sealed class TransitFailProbeTests
+{
+ private const uint CellId = 0xA9B40001u;
+ private const uint GfxObjId = 0x0100F100u;
+
+ [Fact]
+ public void Probe_FiresOnSyntheticStuckTick_WallAbsorbsWholeRequest()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+ var engine = BuildEngine(root, resolved);
+ var body = new PhysicsBody();
+ ResetBody(body);
+
+ PhysicsDiagnostics.DumpTransitFailEnabled = true;
+ var saved = Console.Out;
+ var sw = new StringWriter();
+ Console.SetOut(sw);
+ ResolveResult result;
+ try
+ {
+ // Sphere resting flush against the wall (wall at x=0.5, radius
+ // 0.2 -> resting x=0.3), requesting a further 0.3 m straight
+ // into it with zero lateral (Y) component — the wall's contact
+ // normal is pure -X, so there is no crease direction for a
+ // slide to preserve.
+ result = engine.ResolveWithTransition(
+ currentPos: new Vector3(0.30f, 0f, 0.20f),
+ targetPos: new Vector3(0.60f, 0f, 0.20f),
+ cellId: CellId,
+ sphereRadius: BSPStepUpFixtures.SphereRadius,
+ sphereHeight: 1.20f,
+ stepUpHeight: 0.60f,
+ stepDownHeight: 1.50f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0x5000000Au);
+ }
+ finally
+ {
+ Console.SetOut(saved);
+ PhysicsDiagnostics.DumpTransitFailEnabled = false;
+ }
+
+ string log = sw.ToString();
+
+ // The stuck-tick predicate must have fired: requested ~0.30 m of
+ // XY, delivered essentially none.
+ Assert.Contains("[transit-fail]", log);
+ Assert.Contains("STUCK-TICK", log);
+ Assert.Contains("mover=0x5000000A", log);
+
+ // At least one buffered TransitionalInsert-attempt line must have
+ // flushed with it — proves the buffer-then-flush plumbing actually
+ // carried per-tick detail through to the stuck-tick report, not
+ // just the summary line.
+ Assert.Contains("[transit-fail-insert]", log);
+
+ float actualDx = result.Position.X - 0.30f;
+ float actualDy = result.Position.Y - 0f;
+ float actualXYLen = MathF.Sqrt(actualDx * actualDx + actualDy * actualDy);
+ Assert.True(
+ actualXYLen < 0.01f,
+ $"expected the wall to absorb ~all requested XY movement, " +
+ $"actual XY delta length={actualXYLen:F5} (position=" +
+ $"{result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4})");
+ }
+
+ [Fact]
+ public void Probe_StaysSilentOnOrdinaryMovingTick()
+ {
+ var (root, resolved) = BSPStepUpFixtures.TallWall();
+ var engine = BuildEngine(root, resolved);
+ var body = new PhysicsBody();
+ ResetBody(body);
+
+ PhysicsDiagnostics.DumpTransitFailEnabled = true;
+ var saved = Console.Out;
+ var sw = new StringWriter();
+ Console.SetOut(sw);
+ ResolveResult result;
+ try
+ {
+ // Same floor, same player profile, but walking parallel to the
+ // wall (along -Y) far from x=0.5 — nothing should block this
+ // move at all.
+ result = engine.ResolveWithTransition(
+ currentPos: new Vector3(-1.50f, 0.00f, 0.20f),
+ targetPos: new Vector3(-1.50f, -0.30f, 0.20f),
+ cellId: CellId,
+ sphereRadius: BSPStepUpFixtures.SphereRadius,
+ sphereHeight: 1.20f,
+ stepUpHeight: 0.60f,
+ stepDownHeight: 1.50f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0x5000000Au);
+ }
+ finally
+ {
+ Console.SetOut(saved);
+ PhysicsDiagnostics.DumpTransitFailEnabled = false;
+ }
+
+ string log = sw.ToString();
+
+ // The probe's own families must be completely silent on a healthy
+ // moving tick. (Console.Out may still carry unrelated one-shot
+ // process diagnostics — e.g. the #338 AnnounceStepHeightProbeOnce
+ // self-report, which fires unconditionally on the first IsPlayer
+ // resolve in the process regardless of any flag — so this checks
+ // the probe's own tag rather than asserting total silence.)
+ Assert.DoesNotContain("[transit-fail", log);
+
+ float actualDy = result.Position.Y - 0.00f;
+ Assert.True(
+ MathF.Abs(actualDy) > 0.20f,
+ $"expected the open-floor move to actually advance in Y, " +
+ $"actual Y={result.Position.Y:F4}");
+ }
+
+ private static void ResetBody(PhysicsBody body)
+ {
+ body.State = PhysicsStateFlags.Gravity;
+ body.TransientState = TransientStateFlags.Active;
+ body.ContactPlaneValid = false;
+ body.WalkablePolygonValid = false;
+ body.WalkableVertices = null;
+ body.SlidingNormal = Vector3.Zero;
+ body.FramesStationaryFall = 0;
+ }
+
+ private static PhysicsEngine BuildEngine(
+ PhysicsBSPNode root,
+ Dictionary resolved)
+ {
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ Array.Fill(heightTable, -50f);
+
+ var engine = new PhysicsEngine();
+ engine.AddLandblock(
+ 0xA9B4FFFFu,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ 0f,
+ 0f);
+
+ var cache = new PhysicsDataCache();
+ cache.RegisterGfxObjForTest(GfxObjId, new GfxObjPhysics
+ {
+ BSP = new PhysicsBSPTree { Root = root },
+ PhysicsPolygons = new Dictionary(),
+ Vertices = new VertexArray(),
+ Resolved = resolved,
+ BoundingSphere = new Sphere
+ {
+ Origin = new Vector3(0f, 0f, 2.5f),
+ Radius = 10f,
+ },
+ });
+ engine.DataCache = cache;
+ engine.ShadowObjects.Register(
+ entityId: GfxObjId,
+ gfxObjId: GfxObjId,
+ worldPos: Vector3.Zero,
+ rotation: Quaternion.Identity,
+ radius: 10f,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: 0xA9B4FFFFu,
+ collisionType: ShadowCollisionType.BSP,
+ scale: 1f);
+
+ return engine;
+ }
+}