acdream/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs
Erik c1e6e3da44 fix #435 (part 2, closes it): attribute the unowned probes — delete 7, reclassify 8, restore 1
Part 1 deleted probes whose owning issues were closed. These 14 named no
issue at all, so each was traced to its introducing commit
(git log -S) instead of guessed at. Attribution split them three ways:

DELETED (7, investigations closed): ACDREAM_A8_DUMP_PV and
ACDREAM_DUMP_LIVE_SPAWNS (Phase A8), ACDREAM_DUMP_CLOTHING (#37),
ACDREAM_DUMP_EDGE_SLIDE (#32), ACDREAM_DUMP_STEPUP (L.2.3d-f),
ACDREAM_DUMP_VENDOR (the vendor campaign, 25 call sites across 8 files),
ACDREAM_DUMP_VITALS (#5, four independent read sites). VendorDiagnostics.cs
went entirely.

RECLASSIFIED (8, tools misfiled as probes): the DUMP_CELLS/DUMP_GFXOBJS
fixture-extraction family (replay-harness tooling with a roundtrip test),
PROBE_CELL (standing cell-transit tracer, pair of the permanent
PROBE_RESOLVE), DUMP_SKY and HIDE_PART (generic isolation tools), and
DUMP_STEEP_ROOF — which looked like an L.4 relic but observes LIVE
divergence-register row AD-56; deleting it would have removed the only
runtime lens on an active divergence. All moved to Permanent diagnostics
with their attribution recorded.

RESTORED (1): ACDREAM_DUMP_MOVE_TRUTH was deleted and un-deleted the same
day. It is not a probe — the canonical nine-stop soak
(run-connected-r6-soak.ps1) hard-fails every destination without its
'move-truth OUT' records, with a message that would misdirect the next
operator. Under the no-workarounds rule the gate's mechanism is restored,
not left broken with an IOU (#437, closed). Process lesson recorded on
both issues: a closed owning issue is NOT sufficient to delete a probe —
grep tools/ and the contract tests for consumers first.

Also lands the owner-requested default-off invariant: every diagnostic in
the codebase is inert until its env var is explicitly set. Exactly four
flags default ON and none is a diagnostic — RETAIL_CHASE, CAMERA_COLLIDE,
CAMERA_ALIGN_SLOPE, RETAIL_CLOSE_DEGRADES are retail behaviors wearing an
A/B off-switch. That set is now FROZEN by
LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn;
docs/launch-options.md's Conventions and CLAUDE.md state the rule, and
CLAUDE.md now binds future probes to a documented row in the same commit.

The client reads 137 environment variables (161 at audit start); 40
temporary probes remain, every one attributed. Full hermetic suite 15,322
passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:32:42 +02:00

474 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// A6.P4 door fix (2026-05-24) — apparatus test. Loads door Setup
/// <c>0x020019FF</c> + GfxObj <c>0x010044B5</c> (the collision-bearing
/// frame/slab part — see
/// <see cref="DoorSetupGfxObjInspectionTests"/>) from the real dat,
/// registers a synthetic door entity at a known world position via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>, and sweeps a
/// player sphere into the door from multiple angles.
///
/// <para>
/// Purpose: with the dat inspection having confirmed that
/// <c>0x010044B5</c> has a complete 1.9 × 0.26 × 2.5 m door-slab
/// PhysicsBSP, this test isolates whether our
/// <see cref="BSPQuery.FindCollisions"/> + the
/// <see cref="RegisterMultiPart"/> registration path actually fires
/// collisions against that BSP. If it does, the live Holtburg door
/// bug is somewhere DOWNSTREAM (live registration race, entity
/// rotation, cell scoping). If it doesn't, the bug is here in
/// the integration we can fix with confidence.
/// </para>
///
/// <para>
/// The test skips gracefully when the dat directory isn't present so
/// CI stays green. Local developer machines always have it.
/// </para>
/// </summary>
[Trait("Lane", "InstalledDat")]
public class DoorCollisionApparatusTests
{
private readonly ITestOutputHelper _out;
public DoorCollisionApparatusTests(ITestOutputHelper output) => _out = output;
private const uint DoorSetupId = 0x020019FFu;
private const uint DoorFrameGfxObjId = 0x010044B5u;
private const uint DoorEntityId = 0xF424Fu;
private const uint TestLandblockId = 0xA9B40000u;
// Cell (0, 0) in landblock 0xA9B40000 — outdoor 24-m cell, low byte
// = (0 * 8) + 0 + 1 = 1.
private const uint TestCellId = TestLandblockId | 0x0001u;
private const float SphereRadius = 0.48f; // ~retail player capsule radius
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.60f;
private const float StepDownHeight = 0.04f;
/// <summary>
/// Walk the sphere toward the closed door, dead-center, from
/// the door's -Y front face approach. Expect a CollisionNormalValid
/// = true AND the sphere stops before passing through the door.
/// </summary>
[Fact]
public void Apparatus_DeadCenter_FrontApproach_BlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
// Door at world (12, 12, 0). The frame's BSP slab extends
// entity-local Y∈[-0.009, 0.252] (poly Y range plus frame[0] offset).
// Approach from -Y at sphere Y=11 (1 m before the front face),
// walk +Y at 0.10 m/tick.
var start = new Vector3(12f, 11f, 0.5f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
ctx.engine, start, perTick, maxTicks: 30);
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
Assert.True(blocked,
$"Expected collision before passing through the door. Sphere reached " +
$"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " +
$"without firing CollisionNormalValid.");
Assert.True(finalPos.Y < 12.0f,
$"Sphere should stop before the door's front face (Y ≈ 11.99). " +
$"Got Y={finalPos.Y:F3}");
}
/// <summary>
/// Campaign P Slice P3 item 4 (#165) candidate (a) discriminator, against
/// REAL BSP wall geometry (the door slab) rather than a synthetic sphere:
/// the SAME dead-center front approach as
/// <see cref="Apparatus_DeadCenter_FrontApproach_BlocksOnBSP"/>, but in
/// ONE resolve call spanning the entire 2 m approach instead of 20 small
/// 0.10 m ticks — exactly what an <c>InterpolationManager</c> unclamped
/// stall-fail "tail delta" snap (the FULL remaining distance to the
/// queue's tail node, no speed cap) would hand
/// <c>ResolveWithTransition</c> as its <c>targetPos</c> in a single tick.
/// If this still blocks at the door, candidate (a) does not reproduce
/// against real wall BSP geometry either (matching the synthetic-sphere
/// finding in <see cref="Issue165RemoteWallPenetrationDiagnosticTests"/>).
/// </summary>
[Fact]
public void Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
var start = new Vector3(12f, 11f, 0.5f);
// The full 20-tick × 0.10 m approach in ONE resolve call.
var target = new Vector3(12f, 13f, 0.5f);
var result = ctx.engine.ResolveWithTransition(
start, target, TestCellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
isOnGround: false, body: null,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
_out.WriteLine(
$"single-jump pos=({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) "
+ $"hit={result.CollisionNormalValid} "
+ $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
Assert.True(result.CollisionNormalValid,
"A single large-tick jump through the door's BSP slab must still "
+ "report a collision, matching the 30-tick sweep's finding. If "
+ "this fails, candidate (a) (the sweep misses large single-tick "
+ "deltas) is CONFIRMED against real wall geometry.");
Assert.True(result.Position.Y < 12.0f,
$"Sphere should stop before the door's front face (Y ≈ 11.99) even "
+ $"on a single large-tick jump; got Y={result.Position.Y:F3}");
}
/// <summary>
/// 50 cm off-center: the small Sphere shape (r=0.10) can't catch
/// this, but the BSP slab (1.9 m wide) MUST. This is the live
/// regression — pre-fix the user can walk around the cylinder.
/// </summary>
[Fact]
public void Apparatus_50cmOffCenter_FrontApproach_BlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
// Same setup, but start 0.5 m off-center in X. Slab X range
// is approximately [11.05, 12.97] (frame[0].X offset = -0.006).
var start = new Vector3(12.5f, 11f, 0.5f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
ctx.engine, start, perTick, maxTicks: 30);
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
Assert.True(blocked,
$"Expected BSP collision off-center. Sphere reached " +
$"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " +
$"without firing CollisionNormalValid.");
Assert.True(finalPos.Y < 12.0f,
$"Sphere should stop before door front face. Got Y={finalPos.Y:F3}");
}
/// <summary>
/// Reverse approach: walk from the door's +Y back side toward -Y.
/// SidesType=Landblock polys are two-sided, so this must also block.
/// This pins the live "walking out from inside passes through" bug
/// in its simplest geometric form.
/// </summary>
[Fact]
public void Apparatus_DeadCenter_BackApproach_BlocksOnBSP()
{
if (!TryBuildScenario(out var ctx)) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
// Start past the door at Y=13, walk back toward -Y.
var start = new Vector3(12f, 13f, 0.5f);
var perTick = new Vector3(0f, -0.10f, 0f);
var (blocked, finalPos, normal, ticks) = SweepUntilBlocked(
ctx.engine, start, perTick, maxTicks: 30);
_out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " +
$"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})");
Assert.True(blocked,
$"Expected BSP collision from back side (two-sided Landblock poly). " +
$"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) " +
$"after {ticks} ticks.");
Assert.True(finalPos.Y > 12.30f,
$"Sphere should stop after the door's back face (Y ≈ 12.25). " +
$"Got Y={finalPos.Y:F3}");
}
/// <summary>
/// Diagnostic dump: drive 5 ticks with probes on, just to see
/// what the engine reports per-tick. Useful when the assertion
/// tests above fail — the diagnostic test shows the FULL log
/// without yielding a green/red verdict.
/// </summary>
[Fact]
[Trait("Purpose", "Diagnostic")]
public void Apparatus_DiagnosticDump_FrontApproach()
{
if (!TryBuildScenario(out var ctx)) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeBuildingEnabled = true;
try
{
var pos = new Vector3(12f, 11f, 0.5f);
var perTick = new Vector3(0f, 0.10f, 0f);
uint cellId = TestCellId;
bool isOnGround = false;
for (int tick = 0; tick < 8; tick++)
{
Vector3 target = pos + perTick;
var result = ctx.engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: null,
moverFlags: ObjectInfoState.IsPlayer,
movingEntityId: 0);
_out.WriteLine($"tick={tick} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) → " +
$"({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) " +
$"hit={result.CollisionNormalValid} " +
$"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
}
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeBuildingEnabled = false;
}
}
/// <summary>
/// Reproduces the LIVE bug: a grounded player (isOnGround=true with
/// seeded ContactPlane) walking off-center toward a closed door
/// passes through. The path-difference test: this hits Path 5
/// (Contact branch + StepSphereUp), while the other apparatus
/// tests above hit Path 6 (Default). If Path 5 incorrectly
/// declares step-up success the BSP collision returns OK and the
/// sphere walks through — exactly what the user reports in the
/// live Holtburg session 2026-05-24.
/// </summary>
/// <remarks>
/// BR-7 / A6.P4 (2026-06-11) — FLIPPED from documents-the-bug to
/// asserts-the-fix: with the per-cell shadow architecture the door is
/// found from every overlapped cell and this approach now blocks.
/// </remarks>
[Fact]
public void Apparatus_Grounded_50cmOffCenter_FrontApproach_Blocks()
{
if (!TryBuildScenario(out var ctx)) Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeBuildingEnabled = true;
// Synthetic floor plane at Z = 0 so the grounded sphere has a
// walkable plane to rest on. Sphere foot center starts at Z=radius
// = 0.48, head at 0.48 + 1.20 = 1.68.
var floorPlane = new Plane(0f, 0f, 1f, 0f); // z = 0 plane
var floorVerts = new[]
{
new Vector3( 0f, 0f, 0f),
new Vector3(24f, 0f, 0f),
new Vector3(24f, 24f, 0f),
new Vector3( 0f, 24f, 0f),
};
var body = new PhysicsBody
{
Position = new Vector3(12.5f, 11f, 0.48f),
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = floorPlane,
ContactPlaneCellId = TestCellId,
WalkablePolygonValid = true,
WalkablePlane = floorPlane,
WalkableVertices = floorVerts,
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
var perTick = new Vector3(0f, 0.10f, 0f);
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool isOnGround = true;
bool blocked = false;
Vector3 lastNormal = Vector3.Zero;
int ticks = 0;
for (int tick = 0; tick < 30; tick++)
{
Vector3 target = pos + perTick;
var result = ctx.engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
ticks = tick;
body.Position = result.Position;
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
lastNormal = result.CollisionNormal;
if (result.CollisionNormalValid)
{
blocked = true;
_out.WriteLine($"Tick {tick}: BLOCKED at pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) normal=({lastNormal.X:F3},{lastNormal.Y:F3},{lastNormal.Z:F3})");
break;
}
}
_out.WriteLine($"Final pos = ({pos.X:F3}, {pos.Y:F3}, {pos.Z:F3}) after {ticks + 1} ticks; blocked={blocked}");
_out.WriteLine($"Grounded={isOnGround}");
// FLIPPED (BR-7 / A6.P4, 2026-06-11): this test documented the #99
// walk-through; the per-cell shadow architecture closes it. The
// grounded Path-5 approach must now BLOCK before the slab
// (Y < 12.0) — the same shape as the Path-6 apparatus tests above.
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeBuildingEnabled = false;
Assert.True(blocked,
$"Door must block the grounded off-center approach (#99 closed). " +
$"Current pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) blocked={blocked}");
Assert.True(pos.Y < 12.0f,
$"Sphere must stop before the slab; pos.Y={pos.Y:F3}");
}
// ───────────────────────────────────────────────────────────────
// Apparatus setup
// ───────────────────────────────────────────────────────────────
private sealed record Context(
PhysicsEngine engine,
PhysicsDataCache cache,
Setup setup,
IReadOnlyList<ShadowShape> registeredShapes);
/// <summary>
/// Build the engine + cache + landblock + registered door entity.
/// Returns false if the dat directory is missing (test skips).
/// </summary>
private bool TryBuildScenario(out Context ctx)
{
ctx = default!;
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return false;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Load + cache the door's collision-bearing part.
var doorFrameGfx = dats.Get<GfxObj>(DoorFrameGfxObjId);
Assert.NotNull(doorFrameGfx);
var cache = new PhysicsDataCache();
cache.CacheGfxObj(DoorFrameGfxObjId, doorFrameGfx!);
Assert.NotNull(cache.GetGfxObj(DoorFrameGfxObjId));
var engine = new PhysicsEngine { DataCache = cache };
// Stub landblock at world (0, 0) so TryGetLandblockContext succeeds
// for player XY in the [0, 192) range. Flat far-below terrain so it
// doesn't interact with the door collision.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// Load door setup, build shapes via the production builder.
var setup = dats.Get<Setup>(DoorSetupId);
Assert.NotNull(setup);
var shapes = ShadowShapeBuilder.FromSetup(
setup!,
entScale: 1.0f,
hasPhysicsBsp: id => cache.GetGfxObj(id)?.BSP?.Root is not null);
_out.WriteLine($"Shapes from FromSetup: {shapes.Count} entries");
foreach (var s in shapes)
{
_out.WriteLine($" type={s.CollisionType} gfxObj=0x{s.GfxObjId:X8} " +
$"localPos=({s.LocalPosition.X:F3},{s.LocalPosition.Y:F3},{s.LocalPosition.Z:F3}) " +
$"radius={s.Radius:F3} cylH={s.CylHeight:F3}");
}
// Register the door at world (12, 12, 0). That's cell (0,0) of
// landblock 0xA9B40000.
Vector3 doorWorldPos = new(12f, 12f, 0f);
Quaternion doorWorldRot = Quaternion.Identity;
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | ReportCollisions
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,
entityWorldPos: doorWorldPos,
entityWorldRot: doorWorldRot,
shapes: shapes,
state: doorState,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: TestLandblockId);
ctx = new Context(engine, cache, setup!, shapes);
return true;
}
/// <summary>
/// Drive a sphere from <paramref name="start"/> by
/// <paramref name="perTick"/> each tick until either
/// <c>CollisionNormalValid</c> fires or <paramref name="maxTicks"/>
/// elapse. Returns the outcome + final position.
/// </summary>
private (bool blocked, Vector3 finalPos, Vector3 normal, int ticks)
SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks)
{
Vector3 pos = start;
uint cellId = TestCellId;
bool isOnGround = false;
Vector3 lastNormal = Vector3.Zero;
for (int tick = 0; tick < maxTicks; tick++)
{
Vector3 target = pos + perTick;
var result = engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: null,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
if (result.CollisionNormalValid)
return (true, result.Position, result.CollisionNormal, tick);
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
lastNormal = result.CollisionNormal;
}
return (false, pos, lastNormal, maxTicks);
}
}