feat(#182): frames_stationary_fall round-trip in the kept transition internals (Slice 1b/1c)

Completes the TS-3 stub (the deferred 'full physics port'):
- ValidateTransition: the fsf increment/reset ladder + the fsf>=3 upward-contact-plane
  manufacture (retail validate_transition 0x0050aa70 pc:272625-656; ACE Transition.cs:
  1029-1061). Runs after acdream's fused contact block (structural adaptation preserving
  the L.2.3c/L.2.4/A6.P3 contact-retention divergences), deriving retail's _redo as
  cleanAdvance || OnWalkable — a grounded wall-slide is not a stuck-fall.
- The sweep-loop fsf early-out (retail find_valid_position pc:273745 / ACE :587): stop
  as soon as fsf != 0, so a later advancing sub-step can't reset it in the same frame
  (load-bearing — without it the counter never escalates across frames).
- ObjectInfo.MoverHasGravity gate (pc:272625).
- PhysicsEngine: seed ci.fsf from the body's Stationary* bits AFTER InitPath
  (retail transition() pc:280940-947); writeback publishes body.FramesStationaryFall +
  encodes the Stationary* bits (co-located with the fsf compute so the round-trip is
  self-contained in Core; retail encodes in handle_all_collisions — register note).

Tests: FramesStationaryFallTests — an airborne jump wedged under an overhead creature
escalates fsf 0->1->2->3 and at fsf 3 manufactures the UP plane (grounded 'glide onto
the crowd top'); a grounded wall-slide never accumulates fsf. Core 2609/0.
Behaviour dormant in ordinary locomotion (fsf stays 0 unless a gravity mover is blocked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 14:11:35 +02:00
parent 6e8117741b
commit 6c3cd96b7a
3 changed files with 264 additions and 2 deletions

View file

@ -0,0 +1,149 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for the retail frames_stationary_fall (fsf) round-trip
/// (validate_transition 0x0050aa70 pc:272625-656; transition seed pc:280940-947;
/// ACE Transition.cs:1029-1061). Retires the TS-3 stub.
///
/// <para>
/// The ladder detects a gravity mover that CANNOT advance for successive frames — the
/// #182 airborne "stuck in the falling animation" wedge (a jump into a monster crowd
/// where the near-horizontal creature normal blocks upward motion). fsf escalates
/// 0→1→2→3 while blocked and resets to 0 the moment the mover advances; at fsf≥3 an
/// upward contact plane is manufactured so the mover stands on the obstacle. The counter
/// round-trips across frames through the Stationary* transient bits (seed→ladder→writeback).
/// The velocity "bleed on block" (fsf>1 → v=0) lives in handle_all_collisions (see
/// <see cref="HandleAllCollisionsTests"/>); this file proves the counter itself.
/// </para>
/// </summary>
public class FramesStationaryFallTests
{
private readonly ITestOutputHelper _out;
public FramesStationaryFallTests(ITestOutputHelper output) => _out = output;
private const uint Lb = 0xA9B40000u;
private const uint Cell = Lb | 0x0001u;
private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.04f;
private static PhysicsEngine BuildEngine()
{
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(
landblockId: Lb,
terrain: new TerrainSurface(new byte[81], new float[256]), // flat terrain at Z=0
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
return engine;
}
// A creature body sphere at an ARBITRARY height (elevated well above the terrain so the
// airborne player never finds a floor and stays airborne — the crowd-jump geometry).
private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 center, float radius = R)
{
e.ShadowObjects.Register(
id, gfxObjId: 0u, center, Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: Lb,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 0f, scale: 1f, state: 0u,
flags: EntityCollisionFlags.IsCreature, isStatic: false);
}
private static PhysicsBody AirborneBody(Vector3 pos) => new PhysicsBody
{
Position = pos,
Orientation = Quaternion.Identity,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.None, // airborne: no Contact / no OnWalkable
};
private ResolveResult Push(PhysicsEngine engine, PhysicsBody body, Vector3 delta, uint cell)
=> engine.ResolveWithTransition(
body.Position, body.Position + delta, cell,
R, H, StepUp, StepDown, isOnGround: false, body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
[Fact]
public void AirborneJumpBlockedOverhead_FsfClimbsTo3_ThenResetsWhenAdvancing()
{
// The #182 airborne-stuck geometry, distilled: an airborne mover with a persistent
// UPWARD intent (a jump) into a creature directly overhead. The collision normal is
// vertical, so — unlike a purely-horizontal push, whose sliding normal absorbs the
// whole offset and aborts the sweep before the ladder — the up-intent survives every
// frame, the sweep runs, and fsf escalates 0→1→2→3 via the Stationary* bit round-trip.
var engine = BuildEngine();
RegisterCreatureAt(engine, 0xC0B0u, new Vector3(12f, 10f, 4.5f)); // directly overhead
// Start BELOW the creature so the first frames rise freely (fsf stays 0), then wedge.
var body = AirborneBody(new Vector3(12f, 10f, 1f));
uint cell = Cell;
var up = new Vector3(0f, 0f, 0.6f); // persistent jump intent
int firstFrameFsf = -1, maxFsf = 0;
for (int i = 0; i < 14; i++)
{
var r = Push(engine, body, up, cell);
body.Position = r.Position; cell = r.CellId;
if (i == 0) firstFrameFsf = body.FramesStationaryFall;
maxFsf = Math.Max(maxFsf, body.FramesStationaryFall);
_out.WriteLine($"frame{i,2}: z={body.Position.Z:F3} fsf={body.FramesStationaryFall} " +
$"ts=0x{(uint)body.TransientState:X} onGround={r.IsOnGround}");
}
// The first frame rose freely (well below the creature) — advancing keeps fsf at 0.
Assert.Equal(0, firstFrameFsf);
// Once wedged under the creature, fsf escalated to 3.
Assert.True(maxFsf == 3, $"fsf must escalate to 3 while the jump is blocked overhead; got {maxFsf}");
// At fsf 3 the ladder manufactured an upward contact plane → grounded on the obstacle
// (the retail "glide onto the crowd top").
Assert.True(body.ContactPlaneValid, "fsf≥3 should manufacture a contact plane");
Assert.True(body.ContactPlane.Normal.Z > 0.99f, "manufactured contact plane points up");
}
[Fact]
public void GroundedWallSlide_DoesNotAccumulateFsf()
{
// A GROUNDED mover pushed into an obstacle is not a "stuck fall" — retail keeps fsf=0
// (ACE _redo=1 via the OnWalkable path), so a grounded crowd-jam slides rather than
// getting its velocity zeroed. Guards against the fsf-zero breaking grounded wall-slide.
var engine = BuildEngine();
RegisterCreatureAt(engine, 0xC0C0u, new Vector3(12f, 11.5f, R)); // foot-height creature
var floor = new Plane(Vector3.UnitZ, 0f);
var verts = new[]
{
new Vector3(-100f, -100f, 0f), new Vector3(100f, -100f, 0f),
new Vector3(100f, 100f, 0f), new Vector3(-100f, 100f, 0f),
};
var body = new PhysicsBody
{
Position = new Vector3(12f, 10f, 0f),
Orientation = Quaternion.Identity,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
ContactPlaneValid = true, ContactPlane = floor, ContactPlaneCellId = Cell,
WalkablePolygonValid = true, WalkablePlane = floor, WalkableVertices = verts,
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
uint cell = Cell;
int maxFsf = 0;
for (int i = 0; i < 40; i++)
{
var r = engine.ResolveWithTransition(
body.Position, body.Position + new Vector3(0f, 0.08f, 0f), cell,
R, H, StepUp, StepDown, isOnGround: true, body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
body.Position = r.Position; cell = r.CellId;
maxFsf = Math.Max(maxFsf, body.FramesStationaryFall);
}
_out.WriteLine($"grounded push maxFsf={maxFsf}");
Assert.Equal(0, maxFsf); // a grounded mover never accumulates a stuck-fall
}
}