202 lines
8.6 KiB
C#
202 lines
8.6 KiB
C#
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.");
|
|
}
|
|
}
|