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;
|
||||
}
|
||||
|
||||
// ── [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) =>
|
||||
int.TryParse(
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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