merge: Campaign P Slice P2 response-layer (TS-1 resolved, AP-7 ported, TS-4 stopped at escape valve)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
26e0334af3
9 changed files with 746 additions and 63 deletions
|
|
@ -0,0 +1,151 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P2, TS-1 gap #1 (2026-07-30 research pass,
|
||||
/// <c>docs/research/2026-07-30-response-layer-edge-family-pseudocode.md</c>
|
||||
/// §2). Pins the specific state <c>Transition.EdgeSlideAfterStepDownFailed</c>'s
|
||||
/// back-probe fallback (<c>TransitionTypes.cs:2015-2031</c>) hands to
|
||||
/// <see cref="SpherePath.PrecipiceSlide"/>: a walkable polygon rediscovered
|
||||
/// near <c>GlobalCurrCenter</c> (the last-known-good grounded position),
|
||||
/// tested against <c>GlobalSphere[0]</c> restored to the ORIGINAL failed
|
||||
/// move target (off the polygon's edge) after <c>RestoreCheckPos()</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>SPHEREPATH::edge_slide</c> back-probe branch
|
||||
/// (<c>acclient_2013_pseudo_c.txt:274316-274326</c>, <c>0050b4e0-0050b507</c>)
|
||||
/// re-caches <c>walkable_check_pos</c>/<c>localspace_sphere</c> from
|
||||
/// <c>SPHEREPATH::get_walkable_pos</c> (<c>0050a8f0</c>) via
|
||||
/// <c>SPHEREPATH::cache_localspace_sphere</c> (<c>0050c9d0</c>) and
|
||||
/// <c>SPHEREPATH::set_walkable_check_pos</c> (<c>00509ce0</c>) before calling
|
||||
/// <c>precipice_slide</c> a second time. That machinery exists to solve a
|
||||
/// coordinate-FRAME problem: retail's <c>walkable</c> polygon and
|
||||
/// <c>check_pos</c> are each expressed relative to a PER-CELL local frame
|
||||
/// (<c>cache_localspace_sphere</c> re-projects one into the other's frame via
|
||||
/// <c>Position::localtolocal</c>, and <c>precipice_slide</c> itself applies a
|
||||
/// <c>LandDefs::get_block_offset</c> landblock correction, pc:274341).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// acdream's <see cref="SpherePath.WalkableVertices"/>/<see cref="SpherePath.WalkablePlane"/>
|
||||
/// are populated in UNIFIED WORLD SPACE at assignment time (see
|
||||
/// <c>SpherePath.SetWalkable</c>/<c>SetWalkableTransformed</c>,
|
||||
/// <c>TransitionTypes.cs:667-739</c>, which bake <c>worldOrigin</c> and
|
||||
/// <c>scale</c> in immediately), and <see cref="SpherePath.GlobalSphere"/> is
|
||||
/// likewise always world-space (<c>SpherePath.SetCheckPos</c>/
|
||||
/// <c>RestoreCheckPos</c>, <c>TransitionTypes.cs:621-650</c>). Both operands
|
||||
/// <see cref="BSPQuery.FindCrossedEdge"/> compares are therefore ALREADY
|
||||
/// commensurable without any recache step — retail's local-frame
|
||||
/// re-projection is a no-op correction in acdream's flat-world-space design.
|
||||
/// <see cref="BSPQuery.FindCrossedEdge"/> also never reads a sphere radius
|
||||
/// (only <c>sphereCenter</c>), so retail's radius/<c>walkable_scale</c>
|
||||
/// correction has no acdream counterpart to begin with.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This test does not add a recache step (there is nothing for it to
|
||||
/// correct in this architecture); it instead PINS the claim: given exactly
|
||||
/// the field values the back-probe fallback produces (a world-space walkable
|
||||
/// polygon near <c>GlobalCurrCenter</c>, a restored <c>GlobalSphere[0]</c> at
|
||||
/// the original off-edge target), <c>PrecipiceSlide</c> must find the
|
||||
/// crossed edge and slide — not wedge into <c>Collided</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EdgeSlideBackProbePrecipiceSlideTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Back-probe re-discovers a flat platform's edge polygon near the last
|
||||
/// known good center; the restored (failed) target sphere sits just past
|
||||
/// the +X edge. PrecipiceSlide must cross that edge and slide, matching
|
||||
/// retail's post-recache precipice_slide result (ADJUSTED_TS/SLID_TS,
|
||||
/// never a stuck COLLIDED_TS).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PrecipiceSlide_BackProbeState_CrossesEdge_DoesNotWedge()
|
||||
{
|
||||
var transition = new Transition();
|
||||
var sp = transition.SpherePath;
|
||||
|
||||
// Last known good grounded center — where the back-probe offset
|
||||
// points back toward (retail: global_curr_center).
|
||||
sp.GlobalCurrCenter[0].Origin = new Vector3(0f, 0f, 1f);
|
||||
sp.GlobalCurrCenter[0].Radius = 0.5f;
|
||||
|
||||
// The walkable polygon the back-probe's DoStepDown rediscovered near
|
||||
// that center: a flat 2x2 platform, top face at Z=1.
|
||||
var plane = new Plane(Vector3.UnitZ, -1f);
|
||||
sp.SetWalkable(
|
||||
plane,
|
||||
new[]
|
||||
{
|
||||
new Vector3(-1f, -1f, 1f),
|
||||
new Vector3(1f, -1f, 1f),
|
||||
new Vector3(1f, 1f, 1f),
|
||||
new Vector3(-1f, 1f, 1f),
|
||||
},
|
||||
Vector3.UnitZ);
|
||||
|
||||
// GlobalSphere[0] after RestoreCheckPos(): the ORIGINAL failed move
|
||||
// target, just past the platform's +X edge (off the polygon, over
|
||||
// open air) — exactly what the back-probe fallback hands to
|
||||
// PrecipiceSlide once retail's edge_slide restores check_pos.
|
||||
sp.CheckPos = new Vector3(1.2f, 0f, 1f);
|
||||
sp.GlobalSphere[0].Origin = sp.CheckPos;
|
||||
sp.GlobalSphere[0].Radius = 0.5f;
|
||||
|
||||
var result = sp.PrecipiceSlide(transition);
|
||||
|
||||
Assert.NotEqual(TransitionState.Collided, result);
|
||||
Assert.True(
|
||||
result is TransitionState.Slid or TransitionState.Adjusted or TransitionState.OK,
|
||||
$"Back-probe PrecipiceSlide must slide/adjust across the found edge, not wedge; got {result}.");
|
||||
|
||||
// The walkable context is consumed (retail: this->walkable = nullptr
|
||||
// inside precipice_slide before slide_sphere runs).
|
||||
Assert.False(sp.WalkableValid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanity inverse: when the restored target sphere is still WELL INSIDE
|
||||
/// the rediscovered polygon (no edge crossed — e.g. the back-probe found
|
||||
/// the same ground the mover is standing on), retail's raw
|
||||
/// <c>precipice_slide</c> returns COLLIDED_TS
|
||||
/// (<c>acclient_2013_pseudo_c.txt:274322-274326</c>: <c>eax==0 → walkable
|
||||
/// = nullptr; return 2</c>). Confirms the "no wedge" claim above is about
|
||||
/// the edge-crossing case specifically, not a blanket "never Collided."
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PrecipiceSlide_NoEdgeCrossed_ReturnsCollided_MatchingRetail()
|
||||
{
|
||||
var transition = new Transition();
|
||||
var sp = transition.SpherePath;
|
||||
|
||||
sp.GlobalCurrCenter[0].Origin = new Vector3(0f, 0f, 1f);
|
||||
sp.GlobalCurrCenter[0].Radius = 0.5f;
|
||||
|
||||
var plane = new Plane(Vector3.UnitZ, -1f);
|
||||
sp.SetWalkable(
|
||||
plane,
|
||||
new[]
|
||||
{
|
||||
new Vector3(-1f, -1f, 1f),
|
||||
new Vector3(1f, -1f, 1f),
|
||||
new Vector3(1f, 1f, 1f),
|
||||
new Vector3(-1f, 1f, 1f),
|
||||
},
|
||||
Vector3.UnitZ);
|
||||
|
||||
// Restored target well inside the polygon — no edge crossed.
|
||||
sp.CheckPos = new Vector3(0.1f, 0f, 1f);
|
||||
sp.GlobalSphere[0].Origin = sp.CheckPos;
|
||||
sp.GlobalSphere[0].Radius = 0.5f;
|
||||
|
||||
var result = sp.PrecipiceSlide(transition);
|
||||
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ namespace AcDream.Core.Tests.Physics;
|
|||
/// <summary>
|
||||
/// Unit tests for PhysicsBody — the C# port of CPhysicsObj's core simulation
|
||||
/// from acclient.exe (FUN_005111d0, FUN_00511420, FUN_00511ec0, FUN_00511fa0,
|
||||
/// FUN_00511de0, FUN_0050f940, FUN_00515020).
|
||||
/// FUN_00511de0, FUN_00515020, and the named
|
||||
/// <c>CPhysicsObj::calc_friction</c> at 0050ee70).
|
||||
/// </summary>
|
||||
public sealed class PhysicsBodyTests
|
||||
{
|
||||
|
|
@ -454,6 +455,106 @@ public sealed class PhysicsBodyTests
|
|||
Assert.Equal(1f, body.Velocity.X, precision: 4);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// AP-7 (Campaign P Slice P2, 2026-07-30): calc_friction's 0.25f threshold
|
||||
// docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §1
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void calc_friction_dot_between_zero_and_quarter_now_engages_friction()
|
||||
{
|
||||
// dot(velocity, groundNormal) = 0.1 — ABOVE the old 0.0 threshold
|
||||
// (no friction pre-fix) but BELOW the new retail 0.25f threshold
|
||||
// (friction now engages). This is exactly the window the 0.0 -> 0.25f
|
||||
// port changes; pinning it here documents the intentional behavior
|
||||
// change the AP-7 register row used to warn about.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.Friction = 0.95f;
|
||||
body.Velocity = new Vector3(5f, 0f, 0.1f);
|
||||
float mag2 = body.Velocity.LengthSquared();
|
||||
|
||||
body.calc_friction(1f / 60f, mag2);
|
||||
|
||||
Assert.True(body.Velocity.Length() < 5f,
|
||||
"Retail's 0.25f threshold means dot=0.1 (below 0.25) engages friction, " +
|
||||
"unlike the old 0.0 threshold which would have returned early here.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void calc_friction_dot_at_quarter_threshold_returns_early_no_change()
|
||||
{
|
||||
// dot(velocity, groundNormal) = 0.25 exactly -> angle >= 0.25f is true
|
||||
// -> early return, matching ACE's `if (angle >= 0.25f) return;`.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.Velocity = new Vector3(5f, 0f, 0.25f);
|
||||
var before = body.Velocity;
|
||||
float mag2 = body.Velocity.LengthSquared();
|
||||
|
||||
body.calc_friction(1f / 60f, mag2);
|
||||
|
||||
Assert.Equal(before, body.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests()
|
||||
{
|
||||
// Campaign P Slice P2 research finding: the reverted 2026-04-30 L.3c
|
||||
// regression (forward locomotion 3 -> 0.16 m/s) cannot reproduce on
|
||||
// the production graphical local-player path post-R6, because
|
||||
// PlayerMovementController zeroes Velocity.X/Y to exactly zero every
|
||||
// tick BEFORE UpdatePhysicsInternal/calc_friction runs whenever
|
||||
// animation root motion drives the walk (walking displacement comes
|
||||
// from the animation Frame delta applied directly to Position, not
|
||||
// from integrating Velocity). This test pins that specific state at
|
||||
// the PhysicsBody level (the only file this slice may change):
|
||||
// Velocity.XY == 0 on flat ground is IDENTICAL after calc_friction
|
||||
// whether the threshold is the old 0.0 or the new retail 0.25 --
|
||||
// friction has nothing to hammer because there is no horizontal
|
||||
// velocity for it to act on. Only the residual vertical (gravity)
|
||||
// component may be affected by the normal-removal step, exactly as
|
||||
// retail's own contact handling expects.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.Friction = 0.95f;
|
||||
// Root-motion path's exact per-tick shape: horizontal zeroed, only
|
||||
// the world Z survives (a small residual downward settle velocity).
|
||||
body.Velocity = new Vector3(0f, 0f, -0.05f);
|
||||
|
||||
body.calc_friction(1f / 60f, body.Velocity.LengthSquared());
|
||||
|
||||
Assert.Equal(0f, body.Velocity.X, precision: 5);
|
||||
Assert.Equal(0f, body.Velocity.Y, precision: 5);
|
||||
Assert.True(MathF.Abs(body.Velocity.Z) < 0.05f,
|
||||
$"Root-motion horizontal speed must stay exactly at full (zero) " +
|
||||
$"speed under the new threshold; got Velocity={body.Velocity}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void calc_friction_sledding_state_gate_reachable_with_new_threshold()
|
||||
{
|
||||
// The Sledding-gated overrides (1.5625/6.25/near-flat) were already
|
||||
// present but structurally unreachable in production (nothing sets
|
||||
// PhysicsStateFlags.Sledding, see #166 research §3) -- this test only
|
||||
// confirms the branch still behaves once the outer 0.25f gate is
|
||||
// passed, so a future data-authored Sledding toggle lands on tested
|
||||
// code.
|
||||
var body = MakeGrounded();
|
||||
body.GroundNormal = Vector3.UnitZ;
|
||||
body.State |= PhysicsStateFlags.Sledding;
|
||||
body.Velocity = new Vector3(3f, 0f, -0.5f); // velocityMag2 = 9.25, >= 6.25
|
||||
float mag2 = body.Velocity.LengthSquared();
|
||||
|
||||
body.calc_friction(1f / 60f, mag2);
|
||||
|
||||
// friction should be 0.2f (near-flat, fast sled) rather than the
|
||||
// default 0.95f -- less decay, so speed should stay closer to 3.
|
||||
Assert.True(body.Velocity.Length() > 2.9f,
|
||||
$"Fast near-flat sledding should use the light 0.2f friction override; " +
|
||||
$"got speed {body.Velocity.Length()}");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// update_object — per-frame driver
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P2, TS-4 (Section 6 Step 3): the 2026-04-30 "L.4" fixture
|
||||
/// capture required before the Path-6 steep-poly slide-tangent shortcut may be
|
||||
/// removed (<c>docs/research/2026-07-30-response-layer-edge-family-pseudocode.md</c>
|
||||
/// §4, §6 Step 3). The original repro was a live-client jump onto a steep
|
||||
/// roof that got the body "stuck in falling animation" for many frames; no
|
||||
/// captured fixture from that live session survives in the repo (checked
|
||||
/// <c>docs/research/2026-04-30-*</c> and the L.4 commit `b1af56e`), so this
|
||||
/// test builds a dat-free multi-frame replay from the existing
|
||||
/// <see cref="BSPStepUpFixtures.SlopedUnwalkable"/> geometry (a 63.4°
|
||||
/// slope, normal.Z ≈ 0.447 — below <c>PhysicsGlobals.FloorZ</c> ≈ 0.6642 but
|
||||
/// above <c>PhysicsGlobals.LandingZ</c> ≈ 0.0871, i.e. exactly the band the
|
||||
/// L.4 commit's own steep-poly shortcut targets) using the same
|
||||
/// <c>PhysicsEngine.ResolveWithTransition</c> multi-frame replay idiom as
|
||||
/// <c>Issue185OutdoorStairsSeamReplayTests</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// A body falls from directly above the slope's mid-face, integrating
|
||||
/// gravity between resolves exactly as <c>PhysicsBody.UpdatePhysicsInternal</c>
|
||||
/// would, for up to 3 simulated seconds (90 ticks at 30 Hz — retail's physics
|
||||
/// tick rate, #32 L.5). "Wedged" is defined precisely, matching the original
|
||||
/// bug report ("stuck in falling animation on the roof" for many consecutive
|
||||
/// frames): the body's position stops changing (within 1 mm) for more than
|
||||
/// 15 consecutive ticks (0.5 s) while never reaching the flat reference
|
||||
/// floor at x<0, z=0. A healthy resolution reaches the flat floor (Z ≈
|
||||
/// <see cref="BSPStepUpFixtures.SphereRadius"/>) well before the 90-tick
|
||||
/// budget expires, whether it does so by retail's own COLLIDED-then-fall
|
||||
/// bounce (this file's own git history documents that as retail's actual
|
||||
/// behavior for a clean Path-6 steep hit with no pre-existing contact plane)
|
||||
/// or by committing to the steep "walkable" surface via the permissive
|
||||
/// <c>LandingZ</c> threshold (matching <c>CTransition::check_walkable</c>,
|
||||
/// pc:273202, <c>0.0871556997f</c>) and then downhill-drifting off it via
|
||||
/// the already-ported TS-1 CliffSlide chain.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Run TWICE across this slice's git history: once with the Path-6 steep
|
||||
/// shortcut ACTIVE (pins today's baseline — always green, since the
|
||||
/// shortcut's own in-frame slide-tangent cannot wedge by construction), and
|
||||
/// once with it REMOVED (the retail-strict candidate). If both pass, TS-4's
|
||||
/// removal is evidenced safe and lands in the same commit that deletes the
|
||||
/// shortcut and its <c>SetSlidingNormal</c> writes. If the removed-shortcut
|
||||
/// run wedges, the shortcut stays and this file's result against ToT is the
|
||||
/// recorded evidence — see the commit message / research doc open questions
|
||||
/// for the outcome actually reached.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Ts4SteepRoofWedgeCaptureTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Ts4SteepRoofWedgeCaptureTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint CellId = 0xA9B40001u;
|
||||
private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate
|
||||
private const int MaxTicks = 3 * TicksPerSecond;
|
||||
private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged
|
||||
private const float WedgeEpsilon = 0.001f; // 1 mm
|
||||
|
||||
private static PhysicsEngine MakeSlopeEngine()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
|
||||
|
||||
const uint LandblockId = 0xA9B4FFFFu;
|
||||
const uint SyntheticGfxId = 0xDEADBEEFu;
|
||||
|
||||
var heights = new byte[81];
|
||||
var heightTab = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTab[i] = -1000f; // terrain never interferes
|
||||
|
||||
var engine = new PhysicsEngine();
|
||||
engine.AddLandblock(
|
||||
LandblockId,
|
||||
new TerrainSurface(heights, heightTab),
|
||||
System.Array.Empty<CellSurface>(),
|
||||
System.Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
var cache = new PhysicsDataCache();
|
||||
var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root };
|
||||
var physics = new GfxObjPhysics
|
||||
{
|
||||
BSP = bspTree,
|
||||
PhysicsPolygons = new System.Collections.Generic.Dictionary<ushort, DatReaderWriter.Types.Polygon>(),
|
||||
Vertices = new DatReaderWriter.Types.VertexArray(),
|
||||
Resolved = resolved,
|
||||
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f },
|
||||
};
|
||||
cache.RegisterGfxObjForTest(SyntheticGfxId, physics);
|
||||
engine.DataCache = cache;
|
||||
|
||||
engine.ShadowObjects.Register(
|
||||
entityId: SyntheticGfxId,
|
||||
gfxObjId: SyntheticGfxId,
|
||||
worldPos: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
radius: 15f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: LandblockId,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1.0f);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falls a player-flagged mover from directly above the 63.4° slope's
|
||||
/// mid-face and asserts it reaches the flat floor (or at minimum keeps
|
||||
/// making downward/downhill progress) without a >0.5s frozen stretch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FallOntoSteepSlope_NeverFreezesForOverHalfASecond_AndReachesFloor()
|
||||
{
|
||||
var engine = MakeSlopeEngine();
|
||||
float r = BSPStepUpFixtures.SphereRadius;
|
||||
const float dt = 1f / TicksPerSecond;
|
||||
const float gravity = -9.8f;
|
||||
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
|
||||
// Start well above the slope's mid-face (slope spans x in [0,1], z in
|
||||
// [0,2] at that x-range), falling straight down.
|
||||
Vector3 pos = new(0.5f, 0f, 3.0f);
|
||||
float fallVelocityZ = 0f;
|
||||
uint cell = CellId;
|
||||
|
||||
var positions = new List<Vector3>(MaxTicks) { pos };
|
||||
int frozenStreak = 0;
|
||||
bool reachedFloor = false;
|
||||
|
||||
for (int tick = 0; tick < MaxTicks; tick++)
|
||||
{
|
||||
fallVelocityZ += gravity * dt;
|
||||
Vector3 target = pos + new Vector3(0f, 0f, fallVelocityZ * dt);
|
||||
|
||||
var result = engine.ResolveWithTransition(
|
||||
currentPos: pos,
|
||||
targetPos: target,
|
||||
cellId: cell,
|
||||
sphereRadius: r,
|
||||
sphereHeight: r * 2f,
|
||||
stepUpHeight: 0.30f,
|
||||
stepDownHeight: 0.04f,
|
||||
isOnGround: false,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0x01000000u);
|
||||
|
||||
var newPos = result.Position;
|
||||
float moved = Vector3.Distance(newPos, pos);
|
||||
|
||||
if (moved < WedgeEpsilon)
|
||||
frozenStreak++;
|
||||
else
|
||||
frozenStreak = 0;
|
||||
|
||||
_out.WriteLine(
|
||||
$"t{tick,3}: pos=({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) " +
|
||||
$"moved={moved:F4} onGround={result.IsOnGround} onWalkable={result.OnWalkable} " +
|
||||
$"contact={result.InContact} vz={fallVelocityZ:F2} frozen={frozenStreak}");
|
||||
|
||||
Assert.True(frozenStreak <= WedgeTickThreshold,
|
||||
$"Body frozen for {frozenStreak} consecutive ticks (>{WedgeTickThreshold} == " +
|
||||
$">0.5s) at tick {tick}, position ({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) — " +
|
||||
"this is the 'stuck in falling animation on the roof' wedge shape.");
|
||||
|
||||
pos = newPos;
|
||||
cell = result.CellId;
|
||||
body.Position = pos;
|
||||
|
||||
if (result.IsOnGround)
|
||||
fallVelocityZ = 0f;
|
||||
|
||||
positions.Add(pos);
|
||||
|
||||
// Reached the flat reference floor (x<0, z ~ r) — resolved cleanly.
|
||||
if (pos.X < 0f && pos.Z <= r + 0.05f)
|
||||
{
|
||||
reachedFloor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(reachedFloor,
|
||||
$"Body never reached the flat reference floor within {MaxTicks} ticks " +
|
||||
$"({MaxTicks / (float)TicksPerSecond:F1}s); final position " +
|
||||
$"({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) — this is the wedge the L.4 shortcut guards " +
|
||||
"against (never resolving off the steep surface at all), distinct from a bounded " +
|
||||
"per-tick freeze.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue