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:
parent
bb7b899bfe
commit
bc3277a8ec
4 changed files with 392 additions and 1 deletions
|
|
@ -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
|
wall shows whether the resolver reports Collided-but-position-inside or
|
||||||
never sees the wall.
|
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
|
**Where:** GameWindow remote DR tick (`TickAnimations` player-remote
|
||||||
pipeline + queue chase), `PhysicsEngine.ResolveWithTransition` 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
|
**Acceptance:** a retail mover pressed against a wall shows flush at the
|
||||||
wall from acdream, matching the retail-observer view side-by-side.
|
wall from acdream, matching the retail-observer view side-by-side.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
offline against the trajectory-replay harness. Both are existing tools;
|
||||||
no new instrumentation is needed to start.
|
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
|
## 3. P3.3 — TS-23: PK/PKLite/Impenetrable mover bits
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,51 @@ public class DoorCollisionApparatusTests
|
||||||
$"Got Y={finalPos.Y:F3}");
|
$"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>
|
/// <summary>
|
||||||
/// 50 cm off-center: the small Sphere shape (r=0.10) can't catch
|
/// 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
|
/// this, but the BSP slab (1.9 m wide) MUST. This is the live
|
||||||
|
|
|
||||||
|
|
@ -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 > 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 && LandblockCount > 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue