acdream/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs
Erik bc3277a8ec docs(physics): #165 diagnostic pass - rule out (a)/(b), stop at (c)
Campaign P Slice P3 item 4. Per the plan's explicit instruction, this
is diagnose-only: the research's candidate (a)/(b) mechanisms did not
confirm, so no fix lands here.

Built the dat-free/dat-backed fixtures the plan asked for (no live
client) to test the two mechanisms a physics fixture CAN discriminate:

- (b) ruled out by code reading: RuntimeRemotePhysicsUpdater.Tick's
  resolve gate reads RuntimeEntityRecord.FullCellId live. Every
  FullCellId = 0 write site (TryApplyPickup, CommitAcceptedParentCellless,
  CommitWithdrawal in RuntimeEntityObjectLifetime.cs) is a pickup/
  parent-attach/delete path, never reachable for a live, freely moving
  remote mid-session. The "one-frame grace" is genuinely first-spawn-only.

- (a) tested directly and does not reproduce, on two independent
  geometries: InterpolationManager's unclamped stall-fail "tail delta"
  snap (node_fail_counter > 3) can hand ResolveWithTransition an
  arbitrarily large single-tick targetPos. New fixture tests replace a
  proven small-step sweep (many 0.08-0.10 m ticks) with ONE resolve call
  spanning the entire distance, against both a synthetic creature sphere
  and the real Holtburg door BSP slab (Setup 0x020019FF/GfxObj
  0x010044B5, the existing door-apparatus dat fixture) already used by
  DoorCollisionApparatusTests. Both stop at the identical surface
  distance the small-step tests pin, with a valid collision normal --
  the sweep is not distance-limited and does not tunnel on a large
  single-tick delta.

Candidate (c) -- render/interpolation presentation lag on the App side --
is the remaining hypothesis and is out of scope for a physics-fixture
pass (it's a claim about what gets drawn relative to the committed
PhysicsBody.Position, not something a Core fixture observes). #165
stays OPEN with (a)/(b) struck from the candidate list by the evidence
above and (c) named as the next concrete step (an App-layer render-vs-
physics-position diff, or a fresh live ACDREAM_PROBE_RESOLVE capture).

New tests: Issue165RemoteWallPenetrationDiagnosticTests (dat-free,
3 tests) and DoorCollisionApparatusTests.
Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP (dat-backed,
1 test, skips gracefully without the local dat directory).

dotnet build + dotnet test (Core.Tests 4012/2 skip, Runtime.Tests
425/0) green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:01:11 +02:00

475 lines
20 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>
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)) return;
// 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)) return;
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)) return;
// 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)) return;
// 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]
public void Apparatus_DiagnosticDump_FrontApproach()
{
if (!TryBuildScenario(out var ctx)) return;
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;
}
Assert.True(true, "Diagnostic test — always passes; check stdout.");
}
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)) return;
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeBuildingEnabled = true;
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1");
// 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;
Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
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);
}
}