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
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:
parent
6c6664a685
commit
e761761aa3
4 changed files with 622 additions and 3 deletions
|
|
@ -2775,6 +2775,310 @@ public static class PhysicsDiagnostics
|
||||||
_cylToiUnverifiedAnnounced = 0;
|
_cylToiUnverifiedAnnounced = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── [transit-fail-*] — #345 stuck-tick transition-phase trace ─────────
|
||||||
|
//
|
||||||
|
// docs/research/2026-08-08-345-mechanism-contract.md "probe" section:
|
||||||
|
// walking angled into a too-steep slope eats 100% of the requested XY
|
||||||
|
// displacement while the resolve reports collN=(0,0,1) — straight up —
|
||||||
|
// with the output position byte-identical to the input. Naming WHICH
|
||||||
|
// mechanism writes that UP normal on a completely stalled tick needs a
|
||||||
|
// per-tick TRANSITION PHASE TRACE, but printing one on every one of the
|
||||||
|
// ~30 resolves/sec a normal walk takes would drown the signal. So the
|
||||||
|
// trace is captured into a cheap per-thread buffer during the tick and
|
||||||
|
// only flushed to the console when ResolveWithTransition's own tail
|
||||||
|
// confirms the tick requested a nonzero XY move and delivered zero (the
|
||||||
|
// self-selecting stuck-tick predicate — a healthy session prints
|
||||||
|
// nothing at all).
|
||||||
|
//
|
||||||
|
// Four capture families, one flush:
|
||||||
|
// [transit-fail-insert] — one line per FindPrimaryCellCollisions call
|
||||||
|
// (TransitionalInsert's per-attempt env->building->objects
|
||||||
|
// channel), naming which phase halted the attempt and, on Collided,
|
||||||
|
// the colliding polygon's normal and which of the three channels
|
||||||
|
// wrote it.
|
||||||
|
// [transit-fail-stepup] — DoStepUp entry/exit, mirroring the existing
|
||||||
|
// ACDREAM_DUMP_STEPUP probe's own input-normal-and-verdict content
|
||||||
|
// so a stuck-tick capture carries the step chain without running a
|
||||||
|
// second flag.
|
||||||
|
// [transit-fail-walk] — every ValidateWalkable outcome: the signed
|
||||||
|
// distance, waterDepth, which of the four code branches fired, and
|
||||||
|
// the two flag guards (oi.Contact, sp.StepDown) gating its
|
||||||
|
// SetCollisionNormal call — the collN=(0,0,1) fingerprint most
|
||||||
|
// plausibly comes from exactly this site.
|
||||||
|
// [transit-fail-adjust] — overwritten on every AdjustOffset call, so
|
||||||
|
// only the LAST one before the tick ends survives to the flush:
|
||||||
|
// the final per-tick input/output offset pair.
|
||||||
|
//
|
||||||
|
// A stuck tick's flush order is: the [transit-fail] summary line
|
||||||
|
// itself, then every buffered [transit-fail-insert]/
|
||||||
|
// [transit-fail-stepup]/[transit-fail-walk] line in call order, then
|
||||||
|
// the one [transit-fail-adjust] line.
|
||||||
|
//
|
||||||
|
// All four families are diagnostics-only: nothing here reads back into
|
||||||
|
// any production decision. [ThreadStatic] because a headless host ticks
|
||||||
|
// several sessions in parallel and physics is synchronous within each.
|
||||||
|
// Every capture method tests DumpTransitFailEnabled BEFORE touching any
|
||||||
|
// buffer, building any string, or reading collision state beyond the
|
||||||
|
// value-type arguments the caller already had in hand — zero allocation
|
||||||
|
// when off (Slice I1's TransitionAllocationBaselineTests gate).
|
||||||
|
// TEMPORARY — strip with the rest of the #345 investigation once the
|
||||||
|
// mechanism is identified and the fix contract is written.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initial state from <c>ACDREAM_DUMP_TRANSIT_FAIL=1</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool DumpTransitFailEnabled { get; set; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_DUMP_TRANSIT_FAIL") == "1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stuck-tick predicate's "the request was real" threshold, metres
|
||||||
|
/// squared. 1 mm: comfortably under any legitimate per-tick movement
|
||||||
|
/// request (the #345 capture's stuck ticks requested ~0.23 m), well over
|
||||||
|
/// float roundoff.
|
||||||
|
/// </summary>
|
||||||
|
private const float TransitFailNonzeroRequestXYSq = 0.001f * 0.001f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stuck-tick predicate's "the result was zero" threshold, metres
|
||||||
|
/// squared. 0.1 mm: the #345 capture's stuck ticks returned a position
|
||||||
|
/// byte-identical to the input, so this only needs to be tight enough to
|
||||||
|
/// reject genuine (if small) movement.
|
||||||
|
/// </summary>
|
||||||
|
private const float TransitFailZeroXYSq = 0.0001f * 0.0001f;
|
||||||
|
|
||||||
|
[ThreadStatic] private static List<string>? _transitFailBuffer;
|
||||||
|
[ThreadStatic] private static string? _transitFailAdjustLine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the per-tick buffer. Call once per
|
||||||
|
/// <see cref="PhysicsEngine.ResolveWithTransition"/> call, before
|
||||||
|
/// <c>FindTransitionalPosition</c> runs, so a tick that captures nothing
|
||||||
|
/// does not carry over the previous tick's lines. No-op unless
|
||||||
|
/// <see cref="DumpTransitFailEnabled"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static void BeginTransitFailTrace()
|
||||||
|
{
|
||||||
|
if (!DumpTransitFailEnabled) return;
|
||||||
|
(_transitFailBuffer ??= new List<string>()).Clear();
|
||||||
|
_transitFailAdjustLine = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line per <c>FindPrimaryCellCollisions</c> call — the
|
||||||
|
/// env->building->objects composition inside one
|
||||||
|
/// <c>TransitionalInsert</c> attempt. <paramref name="phase"/> is
|
||||||
|
/// <c>"environment"</c>, <c>"building"</c>, or <c>"objects"</c> —
|
||||||
|
/// whichever channel produced <paramref name="outcome"/> (env/building
|
||||||
|
/// short-circuit the other two on a non-OK result, matching production
|
||||||
|
/// control flow, so the skipped channels' states arrive as
|
||||||
|
/// <see langword="null"/>). On a <see cref="TransitionState.Collided"/>
|
||||||
|
/// outcome, also names the colliding polygon's plane normal and which
|
||||||
|
/// of the three channels wrote it (<paramref name="collidedObjectGuid"/>
|
||||||
|
/// is non-null only for the <c>objects</c> phase).
|
||||||
|
/// </summary>
|
||||||
|
public static void TraceTransitInsertAttempt(
|
||||||
|
uint moverId,
|
||||||
|
int attempt,
|
||||||
|
string phase,
|
||||||
|
TransitionState envState,
|
||||||
|
TransitionState? buildingState,
|
||||||
|
TransitionState? objectsState,
|
||||||
|
TransitionState outcome,
|
||||||
|
Vector3 collisionNormal,
|
||||||
|
uint? collidedObjectGuid)
|
||||||
|
{
|
||||||
|
if (!DumpTransitFailEnabled) return;
|
||||||
|
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
string buildingText = buildingState is { } b ? b.ToString() : "n/a";
|
||||||
|
string objectsText = objectsState is { } o ? o.ToString() : "n/a";
|
||||||
|
string collidedText = outcome == TransitionState.Collided
|
||||||
|
? string.Format(ci,
|
||||||
|
" collN=({0:F3},{1:F3},{2:F3}) src={3}",
|
||||||
|
collisionNormal.X, collisionNormal.Y, collisionNormal.Z,
|
||||||
|
phase == "objects"
|
||||||
|
? (collidedObjectGuid is { } guid
|
||||||
|
? string.Format(ci, "object:0x{0:X8}", guid)
|
||||||
|
: "object:none")
|
||||||
|
: phase)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
|
||||||
|
"[transit-fail-insert] mover=0x{0:X8} attempt={1} phase={2} " +
|
||||||
|
"env={3} building={4} objects={5} outcome={6}{7}",
|
||||||
|
moverId, attempt, phase, envState, buildingText, objectsText,
|
||||||
|
outcome, collidedText));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line per <c>Transition.DoStepUp</c> entry or exit, mirroring the
|
||||||
|
/// content of the existing <c>ACDREAM_DUMP_STEPUP</c> probe (same input
|
||||||
|
/// normal / walkable verdict / landing-plane fields) so a stuck-tick
|
||||||
|
/// capture carries the step-up chain without a second flag.
|
||||||
|
/// <paramref name="edge"/> is <c>"enter"</c> or <c>"exit"</c>;
|
||||||
|
/// <paramref name="succeeded"/> and <paramref name="landedNormal"/> are
|
||||||
|
/// only meaningful on <c>"exit"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static void TraceTransitStepUp(
|
||||||
|
uint moverId,
|
||||||
|
string edge,
|
||||||
|
Vector3 inputNormal,
|
||||||
|
bool onWalkable,
|
||||||
|
float stepUpHeight,
|
||||||
|
Vector3 pos,
|
||||||
|
bool? succeeded,
|
||||||
|
Vector3? landedNormal)
|
||||||
|
{
|
||||||
|
if (!DumpTransitFailEnabled) return;
|
||||||
|
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
float floor = PhysicsGlobals.FloorZ;
|
||||||
|
string verdict = inputNormal.Z >= floor ? "WALKABLE" : "STEEP";
|
||||||
|
string outcomeText;
|
||||||
|
if (succeeded is null)
|
||||||
|
{
|
||||||
|
outcomeText = "";
|
||||||
|
}
|
||||||
|
else if (succeeded.Value && landedNormal is { } landed)
|
||||||
|
{
|
||||||
|
string landedVerdict = landed.Z >= floor ? "WALKABLE" : "STEEP";
|
||||||
|
outcomeText = string.Format(ci,
|
||||||
|
" outcome=SUCCESS landedN=({0:F3},{1:F3},{2:F3})->{3}",
|
||||||
|
landed.X, landed.Y, landed.Z, landedVerdict);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
outcomeText = " outcome=FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
|
||||||
|
"[transit-fail-stepup] mover=0x{0:X8} edge={1} " +
|
||||||
|
"n=({2:F3},{3:F3},{4:F3})->{5} onWalkable={6} stepUpHeight={7:F3} " +
|
||||||
|
"pos=({8:F2},{9:F2},{10:F2}){11}",
|
||||||
|
moverId, edge,
|
||||||
|
inputNormal.X, inputNormal.Y, inputNormal.Z, verdict,
|
||||||
|
onWalkable, stepUpHeight,
|
||||||
|
pos.X, pos.Y, pos.Z, outcomeText));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line per <c>ValidateWalkable</c> outcome. <paramref name="branch"/>
|
||||||
|
/// is <c>"above"</c> (comfortably clear of the surface, no state
|
||||||
|
/// change), <c>"resting"</c> (within EPSILON of the surface),
|
||||||
|
/// <c>"below-push"</c> (below the surface — covers both the ordinary
|
||||||
|
/// push and the step-down interpolation reject; <paramref
|
||||||
|
/// name="outcome"/> distinguishes them), or <c>"checkwalkable-fail"</c>
|
||||||
|
/// (a walkable probe below the surface, which always fails closed).
|
||||||
|
/// <paramref name="guardPassed"/> is <see langword="null"/> for branches
|
||||||
|
/// that never reach the <c>SetCollisionNormal</c> site
|
||||||
|
/// (<c>"above"</c>/<c>"checkwalkable-fail"</c>/the interpolation
|
||||||
|
/// reject); otherwise it is the evaluated
|
||||||
|
/// <c>!oi.Contact && !sp.StepDown</c> guard, and <paramref
|
||||||
|
/// name="normal"/> is the plane normal that would have been (or was)
|
||||||
|
/// written.
|
||||||
|
/// </summary>
|
||||||
|
public static void TraceTransitValidateWalkable(
|
||||||
|
uint moverId,
|
||||||
|
string branch,
|
||||||
|
float dist,
|
||||||
|
float waterDepth,
|
||||||
|
bool oiContact,
|
||||||
|
bool spStepDown,
|
||||||
|
bool? guardPassed,
|
||||||
|
Vector3 normal,
|
||||||
|
TransitionState outcome)
|
||||||
|
{
|
||||||
|
if (!DumpTransitFailEnabled) return;
|
||||||
|
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
string guardText = guardPassed is { } g ? g.ToString() : "n/a";
|
||||||
|
|
||||||
|
(_transitFailBuffer ??= new List<string>()).Add(string.Format(ci,
|
||||||
|
"[transit-fail-walk] mover=0x{0:X8} branch={1} dist={2:F5} " +
|
||||||
|
"waterDepth={3:F4} oiContact={4} spStepDown={5} guardPassed={6} " +
|
||||||
|
"normal=({7:F3},{8:F3},{9:F3}) outcome={10}",
|
||||||
|
moverId, branch, dist, waterDepth, oiContact, spStepDown,
|
||||||
|
guardText, normal.X, normal.Y, normal.Z, outcome));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overwritten on every <c>AdjustOffset</c> call within the tick, so
|
||||||
|
/// only the LAST call before the tick ends survives to the flush — the
|
||||||
|
/// final per-tick input/output offset pair the mechanism contract asks
|
||||||
|
/// for.
|
||||||
|
/// </summary>
|
||||||
|
public static void TraceTransitAdjustOffset(
|
||||||
|
uint moverId, string branch, Vector3 offsetIn, Vector3 offsetOut)
|
||||||
|
{
|
||||||
|
if (!DumpTransitFailEnabled) return;
|
||||||
|
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
_transitFailAdjustLine = string.Format(ci,
|
||||||
|
"[transit-fail-adjust] mover=0x{0:X8} branch={1} " +
|
||||||
|
"in=({2:F4},{3:F4},{4:F4}) out=({5:F4},{6:F4},{7:F4})",
|
||||||
|
moverId, branch,
|
||||||
|
offsetIn.X, offsetIn.Y, offsetIn.Z,
|
||||||
|
offsetOut.X, offsetOut.Y, offsetOut.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stuck-tick predicate, evaluated at
|
||||||
|
/// <see cref="PhysicsEngine.ResolveWithTransition"/>'s tail where both
|
||||||
|
/// the requested and the actual XY displacement are known. Fires
|
||||||
|
/// (flushes the buffered trace to the console) only when the tick
|
||||||
|
/// requested a real XY move (> <see cref="TransitFailNonzeroRequestXYSq"/>)
|
||||||
|
/// and delivered essentially none (<
|
||||||
|
/// <see cref="TransitFailZeroXYSq"/>) — the #345 fingerprint. Always
|
||||||
|
/// clears the buffer afterward, whether it fired or not, so a healthy
|
||||||
|
/// tick never leaks into the next one. No-op unless
|
||||||
|
/// <see cref="DumpTransitFailEnabled"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static void EmitTransitFailIfStuck(
|
||||||
|
uint moverId, Vector3 currentPos, Vector3 targetPos, Vector3 resultPos)
|
||||||
|
{
|
||||||
|
if (!DumpTransitFailEnabled) return;
|
||||||
|
|
||||||
|
float reqX = targetPos.X - currentPos.X;
|
||||||
|
float reqY = targetPos.Y - currentPos.Y;
|
||||||
|
float reqXYSq = reqX * reqX + reqY * reqY;
|
||||||
|
float actX = resultPos.X - currentPos.X;
|
||||||
|
float actY = resultPos.Y - currentPos.Y;
|
||||||
|
float actXYSq = actX * actX + actY * actY;
|
||||||
|
|
||||||
|
bool stuck = reqXYSq >= TransitFailNonzeroRequestXYSq
|
||||||
|
&& actXYSq <= TransitFailZeroXYSq;
|
||||||
|
|
||||||
|
if (stuck)
|
||||||
|
{
|
||||||
|
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
int lineCount = (_transitFailBuffer?.Count ?? 0)
|
||||||
|
+ (_transitFailAdjustLine is null ? 0 : 1);
|
||||||
|
Console.WriteLine(string.Format(ci,
|
||||||
|
"[transit-fail] mover=0x{0:X8} STUCK-TICK " +
|
||||||
|
"reqXY=({1:F4},{2:F4}) reqLen={3:F4} " +
|
||||||
|
"actXY=({4:F4},{5:F4}) actLen={6:F4} " +
|
||||||
|
"in=({7:F3},{8:F3},{9:F3}) tgt=({10:F3},{11:F3},{12:F3}) " +
|
||||||
|
"out=({13:F3},{14:F3},{15:F3}) lines={16}",
|
||||||
|
moverId, reqX, reqY, MathF.Sqrt(reqXYSq),
|
||||||
|
actX, actY, MathF.Sqrt(actXYSq),
|
||||||
|
currentPos.X, currentPos.Y, currentPos.Z,
|
||||||
|
targetPos.X, targetPos.Y, targetPos.Z,
|
||||||
|
resultPos.X, resultPos.Y, resultPos.Z,
|
||||||
|
lineCount));
|
||||||
|
|
||||||
|
if (_transitFailBuffer is { Count: > 0 } buffer)
|
||||||
|
{
|
||||||
|
foreach (string line in buffer)
|
||||||
|
Console.WriteLine(line);
|
||||||
|
}
|
||||||
|
if (_transitFailAdjustLine is not null)
|
||||||
|
Console.WriteLine(_transitFailAdjustLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
_transitFailBuffer?.Clear();
|
||||||
|
_transitFailAdjustLine = null;
|
||||||
|
}
|
||||||
|
|
||||||
private static int ParsePositiveInt(string? value) =>
|
private static int ParsePositiveInt(string? value) =>
|
||||||
int.TryParse(
|
int.TryParse(
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -1922,6 +1922,12 @@ public sealed class PhysicsEngine
|
||||||
// sentinel value for that case. No-op when the probe is off.
|
// sentinel value for that case. No-op when the probe is off.
|
||||||
PhysicsDiagnostics.BeginContactPlaneAttribution();
|
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();
|
var transition = RentTransition();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -2470,6 +2476,13 @@ public sealed class PhysicsEngine
|
||||||
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
|
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,
|
// A6.P3 #98 capture: emit one JSON Lines record per player call,
|
||||||
// with bodyBefore snapshot (taken at method entry, before any
|
// with bodyBefore snapshot (taken at method entry, before any
|
||||||
// engine mutation) + bodyAfter snapshot (taken now, after the
|
// engine mutation) + bodyAfter snapshot (taken now, after the
|
||||||
|
|
|
||||||
|
|
@ -2460,7 +2460,16 @@ public sealed class Transition
|
||||||
cellId,
|
cellId,
|
||||||
FindEnvCollisions(engine, cellId));
|
FindEnvCollisions(engine, cellId));
|
||||||
if (environment != TransitionState.OK)
|
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;
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
TransitionState building = ObservePrimaryCellPhase(
|
TransitionState building = ObservePrimaryCellPhase(
|
||||||
engine,
|
engine,
|
||||||
|
|
@ -2468,7 +2477,13 @@ public sealed class Transition
|
||||||
cellId,
|
cellId,
|
||||||
FindBuildingCollisions(engine, cellId));
|
FindBuildingCollisions(engine, cellId));
|
||||||
if (building != TransitionState.OK)
|
if (building != TransitionState.OK)
|
||||||
|
{
|
||||||
|
PhysicsDiagnostics.TraceTransitInsertAttempt(
|
||||||
|
ObjectInfo.SelfEntityId, innerAttempt, "building",
|
||||||
|
environment, building, null, building,
|
||||||
|
CollisionInfo.CollisionNormal, CollisionInfo.LastCollidedObjectGuid);
|
||||||
return building;
|
return building;
|
||||||
|
}
|
||||||
|
|
||||||
TransitionState objects = ObservePrimaryCellPhase(
|
TransitionState objects = ObservePrimaryCellPhase(
|
||||||
engine,
|
engine,
|
||||||
|
|
@ -2476,6 +2491,10 @@ public sealed class Transition
|
||||||
cellId,
|
cellId,
|
||||||
FindObjCollisionsInCell(engine, cellId));
|
FindObjCollisionsInCell(engine, cellId));
|
||||||
DumpPhase2(innerAttempt, environment, objects);
|
DumpPhase2(innerAttempt, environment, objects);
|
||||||
|
PhysicsDiagnostics.TraceTransitInsertAttempt(
|
||||||
|
ObjectInfo.SelfEntityId, innerAttempt, "objects",
|
||||||
|
environment, building, objects, objects,
|
||||||
|
CollisionInfo.CollisionNormal, CollisionInfo.LastCollidedObjectGuid);
|
||||||
return objects;
|
return objects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3685,17 +3704,40 @@ public sealed class Transition
|
||||||
CacheWalkableContext(sp, contactPlane, walkableVertices);
|
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.SetCollisionNormal(contactPlane.Normal);
|
||||||
ci.CollidedWithEnvironment = true;
|
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;
|
return TransitionState.OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Below the surface ─────────────────────────────────────────────
|
// ── 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.
|
// 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.
|
// 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.
|
// Validate step-down interpolation factor.
|
||||||
float interp = (1f - (-1f / (sp.StepDownAmt * sp.WalkInterp)) * zDist) * sp.WalkInterp;
|
float interp = (1f - (-1f / (sp.StepDownAmt * sp.WalkInterp)) * zDist) * sp.WalkInterp;
|
||||||
if (interp >= sp.WalkInterp || interp < -0.1f)
|
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;
|
return TransitionState.Collided;
|
||||||
|
}
|
||||||
sp.WalkInterp = interp;
|
sp.WalkInterp = interp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3720,12 +3768,20 @@ public sealed class Transition
|
||||||
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -zDist));
|
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.SetCollisionNormal(contactPlane.Normal);
|
||||||
ci.CollidedWithEnvironment = true;
|
ci.CollidedWithEnvironment = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhysicsDiagnostics.TraceTransitValidateWalkable(
|
||||||
|
oi.SelfEntityId, "below-push", dist, waterDepth,
|
||||||
|
oi.Contact, sp.StepDown, belowPushGuardPassed,
|
||||||
|
contactPlane.Normal, TransitionState.Adjusted);
|
||||||
|
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5563,6 +5619,12 @@ public sealed class Transition
|
||||||
collisionAngle: 0f,
|
collisionAngle: 0f,
|
||||||
walkInterp: sp.WalkInterp);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5685,6 +5747,11 @@ public sealed class Transition
|
||||||
collisionAngle: collisionAngle,
|
collisionAngle: collisionAngle,
|
||||||
walkInterp: sp.WalkInterp);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5912,6 +5979,18 @@ public sealed class Transition
|
||||||
$"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})");
|
$"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
|
// 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
|
// clearing it. On step-up failure (too-tall wall) we restore it so
|
||||||
// the mover stays grounded — without this, walking into a wall
|
// 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)
|
if (!stepDown)
|
||||||
{
|
{
|
||||||
sp.RestoreCheckPos();
|
sp.RestoreCheckPos();
|
||||||
|
|
|
||||||
211
tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs
Normal file
211
tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue