From bc3277a8ec2a82846aaff52e13e129507bec7849 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 10:01:11 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 48 +++- ...29-remote-and-world-specials-pseudocode.md | 72 ++++++ .../Physics/DoorCollisionApparatusTests.cs | 45 ++++ ...165RemoteWallPenetrationDiagnosticTests.cs | 228 ++++++++++++++++++ 4 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue165RemoteWallPenetrationDiagnosticTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8bc0074f..85b307d5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. diff --git a/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md b/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md index a15e1825..6e282000 100644 --- a/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md +++ b/docs/research/2026-07-29-remote-and-world-specials-pseudocode.md @@ -462,6 +462,78 @@ start point). `ACDREAM_CAPTURE_RESOLVE=` 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.48–10.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 diff --git a/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs b/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs index 1aadbefb..396e1c0f 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs @@ -91,6 +91,51 @@ public class DoorCollisionApparatusTests $"Got Y={finalPos.Y:F3}"); } + /// + /// 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 + /// , but in + /// ONE resolve call spanning the entire 2 m approach instead of 20 small + /// 0.10 m ticks — exactly what an InterpolationManager unclamped + /// stall-fail "tail delta" snap (the FULL remaining distance to the + /// queue's tail node, no speed cap) would hand + /// ResolveWithTransition as its targetPos 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 ). + /// + [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}"); + } + /// /// 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 diff --git a/tests/AcDream.Core.Tests/Physics/Issue165RemoteWallPenetrationDiagnosticTests.cs b/tests/AcDream.Core.Tests/Physics/Issue165RemoteWallPenetrationDiagnosticTests.cs new file mode 100644 index 00000000..65af576a --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue165RemoteWallPenetrationDiagnosticTests.cs @@ -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; + +/// +/// Campaign P Slice P3 item 4 (#165): diagnosis-only fixtures discriminating +/// the three candidate mechanisms the research doc +/// (docs/research/2026-07-29-remote-and-world-specials-pseudocode.md +/// §2.4) lists for "remote entities penetrate walls before stopping": +/// +/// (a) the InterpolationManager unclamped stall-fail "tail +/// delta" snap (node_fail_counter > 3) commits a position on +/// the far side of / inside a wall in one tick, and the SAME tick's +/// ResolveWithTransition sweep fails to catch the crossing for a +/// large single-tick delta; +/// (b) a one-frame skip of the sweep entirely +/// (RuntimeRemotePhysicsUpdater.cs gates the whole resolve on +/// rm.CellId != 0 && LandblockCount > 0) reachable on +/// some tick other than first-spawn; +/// (c) render/interpolation presentation lag on the App side — +/// out of Core/Runtime scope, not addressed here. +/// +/// +/// +/// No live client, no dat dependency — every fixture here is synthetic +/// geometry driven directly through , +/// matching the existing / +/// pattern. +/// +/// +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; + + /// + /// Candidate (a) discriminator: mirrors + /// + /// (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 + /// InterpolationManager unclamped tail-delta snap (the full + /// remaining distance to the queue's tail node, no speed cap) would hand + /// ResolveWithTransition as its targetPos argument. + /// + [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."); + } + + /// + /// 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). + /// + [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}"); + } + + /// + /// 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. + /// + [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(), + portals: Array.Empty(), + 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, + }; + } +}