using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using Xunit; using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics; /// /// 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 (docs/research/2026-07-30-response-layer-edge-family-pseudocode.md /// §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 /// docs/research/2026-04-30-* and the L.4 commit `b1af56e`), so this /// test builds a dat-free multi-frame replay from the existing /// geometry (a 63.4° /// slope, normal.Z ≈ 0.447 — below PhysicsGlobals.FloorZ ≈ 0.6642 but /// above PhysicsGlobals.LandingZ ≈ 0.0871, i.e. exactly the band the /// L.4 commit's own steep-poly shortcut targets) using the same /// PhysicsEngine.ResolveWithTransition multi-frame replay idiom as /// Issue185OutdoorStairsSeamReplayTests. /// /// /// A body falls from directly above the slope's mid-face, integrating /// gravity between resolves exactly as PhysicsBody.UpdatePhysicsInternal /// 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 ≈ /// ) 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 /// LandingZ threshold (matching CTransition::check_walkable, /// pc:273202, 0.0871556997f) and then downhill-drifting off it via /// the already-ported TS-1 CliffSlide chain. /// /// /// /// 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 SetSlidingNormal 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. /// /// 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(), System.Array.Empty(), 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(), 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; } /// /// 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. /// [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(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."); } }