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>
This commit is contained in:
Erik 2026-07-30 10:01:11 +02:00
parent bb7b899bfe
commit bc3277a8ec
4 changed files with 392 additions and 1 deletions

View file

@ -4244,9 +4244,55 @@ NOTE: capture first — ACDREAM_PROBE_RESOLVE on the remote's guid at a
wall shows whether the resolver reports Collided-but-position-inside or
never sees the wall.
**Campaign P Slice P3 diagnostic pass (2026-07-30, no live client —
dat-free + dat-backed fixtures only, per
`docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §2.4b):**
research P3.2 re-framed the three candidates as (a) the
`InterpolationManager` unclamped stall-fail "tail delta" snap
(`node_fail_counter > 3`) committing a position on the far side of / inside
a wall in one tick and the same-tick sweep failing to catch a large
delta, (b) the remote resolve gate (`rm.CellId != 0 &&
_physics.Engine.LandblockCount > 0`) skipping the sweep entirely on some
tick other than first-spawn, and (c) render/interpolation presentation lag
on the App side. Both (a) and (b) are now RULED OUT with direct evidence:
- **(b):** code-read every `FullCellId = 0` write site
(`RuntimeEntityObjectLifetime.cs`: `TryApplyPickup`,
`CommitAcceptedParentCellless`, `CommitWithdrawal`) — all three are
pickup/parent-attach/delete paths, never reachable for a live, freely
moving remote mid-session. The gate's "one-frame grace" is genuinely
first-spawn-only.
- **(a):** three new fixture tests drive a SINGLE resolve call spanning an
entire large-tick jump (simulating the unclamped snap) instead of many
small ticks, against both synthetic sphere geometry AND the real
Holtburg door BSP slab (`Setup 0x020019FF`/`GfxObj 0x010044B5`) already
used by the door apparatus tests — both stop at the identical surface
distance the proven small-step tests already pin, with a valid collision
normal. The sweep is not distance-limited and does not tunnel on a large
single-tick delta. See
`Issue165RemoteWallPenetrationDiagnosticTests.SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurface`
and
`DoorCollisionApparatusTests.Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP`.
**Candidate (c) is therefore the remaining hypothesis** and is explicitly
OUT OF SCOPE for a physics-fixture-only pass — it is a claim about the
App-layer render/presentation frame relative to the committed
`PhysicsBody.Position`, not something a dat-free/dat-backed Core fixture
can observe. Per the campaign's own instruction ("diagnose only unless a
candidate confirms cheaply; otherwise stop"), this issue stays OPEN. The
next concrete step for whoever picks this up: an App-layer render-position
vs. physics-position diff across frames for a remote near a wall, or a
fresh `ACDREAM_PROBE_RESOLVE`/`ACDREAM_CAPTURE_RESOLVE` live capture (the
existing diagnostic recommendation in the research doc, still valid) if a
live repro becomes available.
**Where:** GameWindow remote DR tick (`TickAnimations` player-remote
pipeline + queue chase), `PhysicsEngine.ResolveWithTransition` remote
callers.
callers; `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs`
(resolve gate, candidate (b), ruled out); `src/AcDream.Core/Physics/
InterpolationManager.cs` (unclamped stall-fail snap, candidate (a), ruled
out); the render/presentation path (candidate (c), OPEN, App-layer, not
yet located).
**Acceptance:** a retail mover pressed against a wall shows flush at the
wall from acdream, matching the retail-observer view side-by-side.

View file

@ -462,6 +462,78 @@ start point). `ACDREAM_CAPTURE_RESOLVE=<path>` would let this be replayed
offline against the trajectory-replay harness. Both are existing tools;
no new instrumentation is needed to start.
### 2.4b — P3 diagnostic pass (2026-07-30): (a) and (b) ruled out by dat-free/dat-backed fixtures; (c) is the remaining candidate
Campaign P Slice P3 item 4 built the dat-free/dat-backed fixtures the
plan asked for (no live client) to discriminate the three candidates
directly, without waiting on a fresh live capture. Findings:
**(b) ruled out by code reading.** `RuntimeRemotePhysicsUpdater.Tick`'s
resolve gate is `rm.CellId != 0 && _physics.Engine.LandblockCount > 0`.
`RemoteMotion.CellId` reads `RuntimeEntityRecord.FullCellId` live (bound via
`RuntimePhysicsState`'s `readCell = () => record.FullCellId`). Every write
site for `FullCellId = 0` was traced (`RuntimeEntityObjectLifetime.cs`:
`TryApplyPickup` line ~505, `CommitAcceptedParentCellless` line ~591,
`CommitWithdrawal` line ~833) — all three are pickup/parent-attach/delete
paths, never reachable for a live, freely-moving remote creature or player
mid-session. For such a mover, `FullCellId` is set once by the first
`UpdatePosition` and never returns to 0 until the entity despawns. The
"one-frame grace" this gate names is therefore genuinely first-spawn-only,
as the code's own comment already claimed — not a hidden mid-session
tunneling window.
**(a) tested directly and does NOT reproduce, on two independent
geometries.** The unclamped stall-fail "tail delta" snap
(`InterpolationManager.ComputeStep`, `_failCount > StallFailCountThreshold`)
hands `ResolveWithTransition` a `targetPos` that can be an arbitrarily large
single-tick delta (the full remaining distance to the interpolation queue's
tail node, no speed cap). The concern was that this large delta might not
sweep correctly and could tunnel through solid geometry in one tick.
Tested directly, synthetic-sphere and real-BSP-wall geometry both agree:
- `Issue165RemoteWallPenetrationDiagnosticTests.SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurface`
(dat-free, synthetic creature sphere): a single resolve call spanning the
ENTIRE 2.4 m approach (the same total distance the pre-existing 30-tick
`SphereCollisionFamilyTests.GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration`
covers in 0.08 m increments) stops at the identical surface distance
(Y≈10.4810.54) — the sweep is not distance-limited and catches the large
jump exactly as it catches the small-step approach.
- `DoorCollisionApparatusTests.Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP`
(dat-backed, the REAL Holtburg door Setup `0x020019FF` + GfxObj
`0x010044B5` BSP slab used by the existing door apparatus tests): the
SAME dead-center front approach the 20-tick
`Apparatus_DeadCenter_FrontApproach_BlocksOnBSP` test covers, driven in
ONE resolve call for the full 2 m instead of 20 × 0.10 m ticks, reports
`CollisionNormalValid=true` and stops at Y=11.4 — before the door's front
face (Y≈11.99), matching the small-step result.
- A third fixture (`SingleLargeTickJumpStartingInsideObstacleOverlap_DoesNotAcceptTunneledCandidate`)
confirms that even a candidate whose START point is already inside a
solid obstacle's overlap zone does not sail through to a far target —
retail's own `validate_transition` restores `curr_pos` on a non-clean
step from inside an overlap (the #184 DO-NOT-RETRY precedent), and
acdream matches.
Both candidates the fixtures could test without a live capture are
therefore RULED OUT as the #165 mechanism: the sweep itself handles large
single-tick position deltas correctly against both object-collision
(ShadowObjectRegistry sphere) and BSP-wall geometry, and the resolve gate
cannot skip mid-session for a live mover.
**Conclusion: candidate (c) — render/interpolation presentation lag on the
App side — is the remaining candidate**, and it is NOT testable with a
physics-fixture-only pass: it is a claim about what gets DRAWN on a given
frame relative to the collision-corrected `PhysicsBody.Position`, which
requires an App-layer render-frame read (comparing the entity's presented
transform against its committed physics position across frames) — outside
Core/Runtime and outside what a dat-free/dat-backed fixture can observe.
Per the campaign plan ("otherwise write the diagnosis... and STOP"), this
item stops here: #165 stays OPEN with (a) and (b) struck from the
candidate list by the evidence above, and (c) named as the next concrete
step for whoever picks this up (an App-layer render-position vs.
physics-position diff across frames, or a live `ACDREAM_PROBE_RESOLVE`
capture if a fresh repro is available — see the diagnostic recommendation
above, still valid for confirming (c) live).
---
## 3. P3.3 — TS-23: PK/PKLite/Impenetrable mover bits

View file

@ -91,6 +91,51 @@ public class DoorCollisionApparatusTests
$"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

View file

@ -0,0 +1,228 @@
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,
};
}
}