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; + } +}