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