probe(physics): ACDREAM_DUMP_TRANSIT_FAIL — self-selecting transition-phase trace for #345
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Fires only on the stuck-tick predicate (>=1mm XY requested, <=0.1mm
achieved), buffering per-tick phase outcomes cheaply and flushing only
on a stuck tick: per-insert-attempt phase/state/normal/source, step-up
enter/exit verdicts, every ValidateWalkable branch with dist/waterDepth
and both SetCollisionNormal guards evaluated, and the tick's final
AdjustOffset pair. Zero cost when off (flag before any allocation — the
I1 zero-alloc gate stays green), mover id on every line, [ThreadStatic]
buffer per the referee-safety rule. Two tests: fires on a synthetic
wall-stuck tick, silent on ordinary movement.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 10:48:46 +02:00
parent 6c6664a685
commit e761761aa3
4 changed files with 622 additions and 3 deletions

View file

@ -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.
/// <summary>
/// Initial state from <c>ACDREAM_DUMP_TRANSIT_FAIL=1</c>.
/// </summary>
public static bool DumpTransitFailEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_TRANSIT_FAIL") == "1";
/// <summary>
/// The stuck-tick predicate's "the request was real" threshold, metres
/// squared. 1 mm: comfortably under any legitimate per-tick movement
/// request (the #345 capture's stuck ticks requested ~0.23 m), well over
/// float roundoff.
/// </summary>
private const float TransitFailNonzeroRequestXYSq = 0.001f * 0.001f;
/// <summary>
/// The stuck-tick predicate's "the result was zero" threshold, metres
/// squared. 0.1 mm: the #345 capture's stuck ticks returned a position
/// byte-identical to the input, so this only needs to be tight enough to
/// reject genuine (if small) movement.
/// </summary>
private const float TransitFailZeroXYSq = 0.0001f * 0.0001f;
[ThreadStatic] private static List<string>? _transitFailBuffer;
[ThreadStatic] private static string? _transitFailAdjustLine;
/// <summary>
/// Reset the per-tick buffer. Call once per
/// <see cref="PhysicsEngine.ResolveWithTransition"/> call, before
/// <c>FindTransitionalPosition</c> runs, so a tick that captures nothing
/// does not carry over the previous tick's lines. No-op unless
/// <see cref="DumpTransitFailEnabled"/>.
/// </summary>
public static void BeginTransitFailTrace()
{
if (!DumpTransitFailEnabled) return;
(_transitFailBuffer ??= new List<string>()).Clear();
_transitFailAdjustLine = null;
}
/// <summary>
/// One line per <c>FindPrimaryCellCollisions</c> call — the
/// env-&gt;building-&gt;objects composition inside one
/// <c>TransitionalInsert</c> attempt. <paramref name="phase"/> is
/// <c>"environment"</c>, <c>"building"</c>, or <c>"objects"</c> —
/// whichever channel produced <paramref name="outcome"/> (env/building
/// short-circuit the other two on a non-OK result, matching production
/// control flow, so the skipped channels' states arrive as
/// <see langword="null"/>). On a <see cref="TransitionState.Collided"/>
/// outcome, also names the colliding polygon's plane normal and which
/// of the three channels wrote it (<paramref name="collidedObjectGuid"/>
/// is non-null only for the <c>objects</c> phase).
/// </summary>
public static void TraceTransitInsertAttempt(
uint moverId,
int attempt,
string phase,
TransitionState envState,
TransitionState? buildingState,
TransitionState? objectsState,
TransitionState outcome,
Vector3 collisionNormal,
uint? collidedObjectGuid)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
string buildingText = buildingState is { } b ? b.ToString() : "n/a";
string objectsText = objectsState is { } o ? o.ToString() : "n/a";
string collidedText = outcome == TransitionState.Collided
? string.Format(ci,
" collN=({0:F3},{1:F3},{2:F3}) src={3}",
collisionNormal.X, collisionNormal.Y, collisionNormal.Z,
phase == "objects"
? (collidedObjectGuid is { } guid
? string.Format(ci, "object:0x{0:X8}", guid)
: "object:none")
: phase)
: "";
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
"[transit-fail-insert] mover=0x{0:X8} attempt={1} phase={2} " +
"env={3} building={4} objects={5} outcome={6}{7}",
moverId, attempt, phase, envState, buildingText, objectsText,
outcome, collidedText));
}
/// <summary>
/// One line per <c>Transition.DoStepUp</c> entry or exit, mirroring the
/// content of the existing <c>ACDREAM_DUMP_STEPUP</c> probe (same input
/// normal / walkable verdict / landing-plane fields) so a stuck-tick
/// capture carries the step-up chain without a second flag.
/// <paramref name="edge"/> is <c>"enter"</c> or <c>"exit"</c>;
/// <paramref name="succeeded"/> and <paramref name="landedNormal"/> are
/// only meaningful on <c>"exit"</c>.
/// </summary>
public static void TraceTransitStepUp(
uint moverId,
string edge,
Vector3 inputNormal,
bool onWalkable,
float stepUpHeight,
Vector3 pos,
bool? succeeded,
Vector3? landedNormal)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
float floor = PhysicsGlobals.FloorZ;
string verdict = inputNormal.Z >= floor ? "WALKABLE" : "STEEP";
string outcomeText;
if (succeeded is null)
{
outcomeText = "";
}
else if (succeeded.Value && landedNormal is { } landed)
{
string landedVerdict = landed.Z >= floor ? "WALKABLE" : "STEEP";
outcomeText = string.Format(ci,
" outcome=SUCCESS landedN=({0:F3},{1:F3},{2:F3})->{3}",
landed.X, landed.Y, landed.Z, landedVerdict);
}
else
{
outcomeText = " outcome=FAILED";
}
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
"[transit-fail-stepup] mover=0x{0:X8} edge={1} " +
"n=({2:F3},{3:F3},{4:F3})->{5} onWalkable={6} stepUpHeight={7:F3} " +
"pos=({8:F2},{9:F2},{10:F2}){11}",
moverId, edge,
inputNormal.X, inputNormal.Y, inputNormal.Z, verdict,
onWalkable, stepUpHeight,
pos.X, pos.Y, pos.Z, outcomeText));
}
/// <summary>
/// One line per <c>ValidateWalkable</c> outcome. <paramref name="branch"/>
/// is <c>"above"</c> (comfortably clear of the surface, no state
/// change), <c>"resting"</c> (within EPSILON of the surface),
/// <c>"below-push"</c> (below the surface — covers both the ordinary
/// push and the step-down interpolation reject; <paramref
/// name="outcome"/> distinguishes them), or <c>"checkwalkable-fail"</c>
/// (a walkable probe below the surface, which always fails closed).
/// <paramref name="guardPassed"/> is <see langword="null"/> for branches
/// that never reach the <c>SetCollisionNormal</c> site
/// (<c>"above"</c>/<c>"checkwalkable-fail"</c>/the interpolation
/// reject); otherwise it is the evaluated
/// <c>!oi.Contact &amp;&amp; !sp.StepDown</c> guard, and <paramref
/// name="normal"/> is the plane normal that would have been (or was)
/// written.
/// </summary>
public static void TraceTransitValidateWalkable(
uint moverId,
string branch,
float dist,
float waterDepth,
bool oiContact,
bool spStepDown,
bool? guardPassed,
Vector3 normal,
TransitionState outcome)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
string guardText = guardPassed is { } g ? g.ToString() : "n/a";
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
"[transit-fail-walk] mover=0x{0:X8} branch={1} dist={2:F5} " +
"waterDepth={3:F4} oiContact={4} spStepDown={5} guardPassed={6} " +
"normal=({7:F3},{8:F3},{9:F3}) outcome={10}",
moverId, branch, dist, waterDepth, oiContact, spStepDown,
guardText, normal.X, normal.Y, normal.Z, outcome));
}
/// <summary>
/// Overwritten on every <c>AdjustOffset</c> call within the tick, so
/// only the LAST call before the tick ends survives to the flush — the
/// final per-tick input/output offset pair the mechanism contract asks
/// for.
/// </summary>
public static void TraceTransitAdjustOffset(
uint moverId, string branch, Vector3 offsetIn, Vector3 offsetOut)
{
if (!DumpTransitFailEnabled) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
_transitFailAdjustLine = string.Format(ci,
"[transit-fail-adjust] mover=0x{0:X8} branch={1} " +
"in=({2:F4},{3:F4},{4:F4}) out=({5:F4},{6:F4},{7:F4})",
moverId, branch,
offsetIn.X, offsetIn.Y, offsetIn.Z,
offsetOut.X, offsetOut.Y, offsetOut.Z);
}
/// <summary>
/// The stuck-tick predicate, evaluated at
/// <see cref="PhysicsEngine.ResolveWithTransition"/>'s tail where both
/// the requested and the actual XY displacement are known. Fires
/// (flushes the buffered trace to the console) only when the tick
/// requested a real XY move (&gt; <see cref="TransitFailNonzeroRequestXYSq"/>)
/// and delivered essentially none (&lt;
/// <see cref="TransitFailZeroXYSq"/>) — the #345 fingerprint. Always
/// clears the buffer afterward, whether it fired or not, so a healthy
/// tick never leaks into the next one. No-op unless
/// <see cref="DumpTransitFailEnabled"/>.
/// </summary>
public static void EmitTransitFailIfStuck(
uint moverId, Vector3 currentPos, Vector3 targetPos, Vector3 resultPos)
{
if (!DumpTransitFailEnabled) return;
float reqX = targetPos.X - currentPos.X;
float reqY = targetPos.Y - currentPos.Y;
float reqXYSq = reqX * reqX + reqY * reqY;
float actX = resultPos.X - currentPos.X;
float actY = resultPos.Y - currentPos.Y;
float actXYSq = actX * actX + actY * actY;
bool stuck = reqXYSq >= TransitFailNonzeroRequestXYSq
&& actXYSq <= TransitFailZeroXYSq;
if (stuck)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
int lineCount = (_transitFailBuffer?.Count ?? 0)
+ (_transitFailAdjustLine is null ? 0 : 1);
Console.WriteLine(string.Format(ci,
"[transit-fail] mover=0x{0:X8} STUCK-TICK " +
"reqXY=({1:F4},{2:F4}) reqLen={3:F4} " +
"actXY=({4:F4},{5:F4}) actLen={6:F4} " +
"in=({7:F3},{8:F3},{9:F3}) tgt=({10:F3},{11:F3},{12:F3}) " +
"out=({13:F3},{14:F3},{15:F3}) lines={16}",
moverId, reqX, reqY, MathF.Sqrt(reqXYSq),
actX, actY, MathF.Sqrt(actXYSq),
currentPos.X, currentPos.Y, currentPos.Z,
targetPos.X, targetPos.Y, targetPos.Z,
resultPos.X, resultPos.Y, resultPos.Z,
lineCount));
if (_transitFailBuffer is { Count: > 0 } buffer)
{
foreach (string line in buffer)
Console.WriteLine(line);
}
if (_transitFailAdjustLine is not null)
Console.WriteLine(_transitFailAdjustLine);
}
_transitFailBuffer?.Clear();
_transitFailAdjustLine = null;
}
private static int ParsePositiveInt(string? value) =>
int.TryParse(
value,

View file

@ -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

View file

@ -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();

View file

@ -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;
/// <summary>
/// #345 mechanism-session probe gate
/// (<c>docs/research/2026-08-08-345-mechanism-contract.md</c> "probe"
/// section). <c>ACDREAM_DUMP_TRANSIT_FAIL</c> /
/// <see cref="PhysicsDiagnostics.DumpTransitFailEnabled"/> 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.
///
/// <para>
/// The synthetic fixture reuses <see cref="BSPStepUpFixtures.TallWall"/> (a
/// floor at z=0 plus a 5 m wall at x=0.5, "too tall to step over" by design
/// — the same fixture <c>TransitionAllocationBaselineTests</c> 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.
/// </para>
/// </summary>
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<ushort, ResolvedPolygon> 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<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f);
var cache = new PhysicsDataCache();
cache.RegisterGfxObjForTest(GfxObjId, new GfxObjPhysics
{
BSP = new PhysicsBSPTree { Root = root },
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
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;
}
}