acdream/tests/AcDream.Core.Tests/Physics/Issue165RemoteWallPenetrationDiagnosticTests.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

228 lines
9.9 KiB
C#

using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Campaign P Slice P3 item 4 (#165): diagnosis-only fixtures discriminating
/// the three candidate mechanisms the research doc
/// (<c>docs/research/2026-07-29-remote-and-world-specials-pseudocode.md</c>
/// §2.4) lists for "remote entities penetrate walls before stopping":
/// <list type="bullet">
/// <item>(a) the <c>InterpolationManager</c> unclamped stall-fail "tail
/// delta" snap (<c>node_fail_counter &gt; 3</c>) commits a position on
/// the far side of / inside a wall in one tick, and the SAME tick's
/// <c>ResolveWithTransition</c> sweep fails to catch the crossing for a
/// large single-tick delta;</item>
/// <item>(b) a one-frame skip of the sweep entirely
/// (<c>RuntimeRemotePhysicsUpdater.cs</c> gates the whole resolve on
/// <c>rm.CellId != 0 &amp;&amp; LandblockCount &gt; 0</c>) reachable on
/// some tick other than first-spawn;</item>
/// <item>(c) render/interpolation presentation lag on the App side —
/// out of Core/Runtime scope, not addressed here.</item>
/// </list>
///
/// <para>
/// No live client, no dat dependency — every fixture here is synthetic
/// geometry driven directly through <see cref="PhysicsEngine.ResolveWithTransition"/>,
/// matching the existing <see cref="SphereCollisionFamilyTests"/> /
/// <see cref="Issue182CrowdJumpTests"/> pattern.
/// </para>
/// </summary>
public class Issue165RemoteWallPenetrationDiagnosticTests
{
private readonly ITestOutputHelper _out;
public Issue165RemoteWallPenetrationDiagnosticTests(ITestOutputHelper output) => _out = output;
private const uint TestLandblockId = 0xA9D60000u;
private const uint TestCellId = TestLandblockId | 0x0001u;
private const float SphereRadius = 0.48f;
private const float SphereHeight = 1.835f;
private const float StepUpHeight = 0.4f;
private const float StepDownHeight = 0.4f;
/// <summary>
/// Candidate (a) discriminator: mirrors
/// <see cref="SphereCollisionFamilyTests.GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration"/>
/// (30 small 0.08 m steps reaching a creature sphere at Y=11.5, surface
/// contact Y≈10.54) but replaces the 30-tick approach with ONE resolve
/// call spanning the ENTIRE distance in a single tick — exactly what an
/// <c>InterpolationManager</c> unclamped 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> argument.
/// </summary>
[Fact]
public void SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurface()
{
var engine = BuildEngine();
RegisterCreatureSphere(engine, 0xC0F0u, 12f, 11.5f); // due north, same as the proven 30-tick test
var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
// ONE resolve call for the full 2.4 m the 30-tick test covers in
// 0.08 m increments — simulating an unclamped stall-fail snap that
// jumps the remote directly toward (and past) the target node in a
// single tick, rather than a smoothly interpolated approach.
Vector3 farTarget = new(12f, 12.4f, 0f);
var result = engine.ResolveWithTransition(
body.Position, farTarget, TestCellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
isOnGround: true, body: body,
moverFlags: ObjectInfoState.EdgeSlide,
movingEntityId: 0);
_out.WriteLine(
$"single-jump result pos=({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) "
+ $"ok={result.Ok} collisionNormalValid={result.CollisionNormalValid}");
// If the sweep correctly handles a large single-tick delta, the
// result is IDENTICAL in kind to the proven 30-tick test: blocked at
// the sphere surface (Y≈10.54), never past Y=11.02 (the near edge of
// the creature sphere itself, combinedR=0.96 short of full
// interpenetration) — NOT at or past the creature's own center
// (Y=11.5), which would mean the sweep missed the crossing entirely.
Assert.True(
result.Position.Y < 10.7f,
"A single large-tick jump through solid geometry must be blocked "
+ $"at the surface, matching the small-step case; got Y={result.Position.Y:F3}. "
+ "If this fails, candidate (a) (the sweep does not catch large "
+ "single-tick deltas) is CONFIRMED as a contributing #165 mechanism.");
}
/// <summary>
/// Control for the test above: the SAME single large jump, but with NO
/// obstacle registered — confirms the mover actually reaches the far
/// target when nothing blocks it (proving the previous test's block is
/// really the obstacle, not some unrelated large-distance resolve
/// failure/clamp).
/// </summary>
[Fact]
public void SingleLargeTickJumpWithNoObstacle_ReachesFarTarget()
{
var engine = BuildEngine();
var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
Vector3 farTarget = new(12f, 12.4f, 0f);
var result = engine.ResolveWithTransition(
body.Position, farTarget, TestCellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
isOnGround: true, body: body,
moverFlags: ObjectInfoState.EdgeSlide,
movingEntityId: 0);
_out.WriteLine($"unobstructed pos=({result.Position.X:F3},{result.Position.Y:F3})");
Assert.True(
result.Position.Y > 12.2f,
$"An unobstructed large single-tick jump must actually complete; got Y={result.Position.Y:F3}");
}
/// <summary>
/// Candidate (a), sharper variant: the unclamped snap can also land the
/// PRE-INTEGRATE start point already past/inside the obstacle in a
/// pathological case (e.g. two consecutive stalled ticks). Confirms the
/// sweep also rejects a targetPos that starts ALREADY behind the
/// obstacle's near surface relative to the swept segment — i.e. even a
/// start point inside the creature's overlap zone resolves to a valid,
/// non-penetrating position rather than silently accepting the
/// already-tunneled candidate.
/// </summary>
[Fact]
public void SingleLargeTickJumpStartingInsideObstacleOverlap_DoesNotAcceptTunneledCandidate()
{
var engine = BuildEngine();
RegisterCreatureSphere(engine, 0xC0F1u, 12f, 11.5f);
// Start already 0.3 m PAST the surface contact point (Y=10.84,
// inside the combined-radius overlap zone starting at Y≈10.54) —
// simulating a start position an earlier unclamped snap already
// over-shot into.
var body = MakeGroundedBody(new Vector3(12f, 10.84f, 0f));
Vector3 farTarget = new(12f, 13f, 0f);
var result = engine.ResolveWithTransition(
body.Position, farTarget, TestCellId,
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
isOnGround: true, body: body,
moverFlags: ObjectInfoState.EdgeSlide,
movingEntityId: 0);
_out.WriteLine(
$"overlap-start result pos=({result.Position.X:F3},{result.Position.Y:F3}) "
+ $"input Y=10.84 (already inside the {0.48f + 0.48f:F2} m combined-radius overlap)");
// Retail's own validate_transition restores curr_pos on a non-clean
// step when starting deep inside overlapping spheres (per the
// physics digest's #184 DO-NOT-RETRY note) — this is NOT a bug to
// chase if the position stays pinned near the start rather than
// sailing through to the far target.
Assert.True(
result.Position.Y < 11.4f,
"A candidate that starts inside a solid obstacle's overlap zone must not "
+ $"sail through to the far target; got Y={result.Position.Y:F3}");
}
private static PhysicsEngine BuildEngine()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var heights = new byte[81];
var heightTable = new float[256]; // all zero → terrain Z = 0
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
private static void RegisterCreatureSphere(
PhysicsEngine engine, uint entityId, float x, float y)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
new Vector3(x, y, SphereRadius), Quaternion.Identity, SphereRadius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 0f, scale: 1f,
state: 0u,
flags: EntityCollisionFlags.IsCreature,
isStatic: false);
}
private static PhysicsBody MakeGroundedBody(Vector3 position)
{
var floorPlane = new Plane(Vector3.UnitZ, 0f);
var floorVerts = new[]
{
new Vector3(-100f, -100f, 0f),
new Vector3(100f, -100f, 0f),
new Vector3(100f, 100f, 0f),
new Vector3(-100f, 100f, 0f),
};
return new PhysicsBody
{
Position = position,
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = floorPlane,
ContactPlaneCellId = TestCellId,
WalkablePolygonValid = true,
WalkablePlane = floorPlane,
WalkableVertices = floorVerts,
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
}
}