acdream/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs
Erik e761761aa3
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
probe(physics): ACDREAM_DUMP_TRANSIT_FAIL — self-selecting transition-phase trace for #345
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>
2026-08-07 10:48:46 +02:00

211 lines
7.8 KiB
C#

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