diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index da52716b..7b072fc5 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -971,6 +971,11 @@ public sealed class PhysicsEngine // (matches non-player movement, all targets collide). transition.ObjectInfo.State |= moverFlags; + // frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit + // (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder + // in ValidateTransition runs for gravity movers (the player) and not floating props. + transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false; + if (isOnGround) transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; @@ -1043,6 +1048,19 @@ public sealed class PhysicsEngine body.WalkableUp); } + // Seed collision_info.frames_stationary_fall from the body's carried Stationary* + // transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state + // 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position + // (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset + // doesn't wipe the seed. + if (body is not null) + { + transition.CollisionInfo.FramesStationaryFall = + body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 : + body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 : + body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0; + } + bool ok = transition.FindTransitionalPosition(this); var sp = transition.SpherePath; @@ -1072,6 +1090,23 @@ public sealed class PhysicsEngine body.ContactPlaneValid = false; } + // Publish frames_stationary_fall + carry it to the next frame via the Stationary* + // transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758); + // acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION, + // register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core. + // handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall. + body.FramesStationaryFall = ci.FramesStationaryFall; + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= ci.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null) { body.WalkablePolygonValid = true; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c52c1552..e7b67b11 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -69,6 +69,16 @@ public sealed class ObjectInfo /// public uint SelfEntityId; + /// + /// The moving object has the retail GRAVITY state bit (PhysicsStateFlags.Gravity 0x400). + /// Seeded from the body at get_object_info time. Retail's frames_stationary_fall + /// ladder is gated on the mover having gravity (object_info.object->state & 0x400, + /// validate_transition pc:272625; ACE ObjectInfo.Object.State.HasFlag(Gravity) + /// Transition.cs:1031) — a floating/gravity-less prop never accumulates a stuck-fall. + /// Default false so table-less/one-shot callers don't run the ladder. + /// + public bool MoverHasGravity; + // Convenience flag checks public bool Contact => State.HasFlag(ObjectInfoState.Contact); public bool OnWalkable => State.HasFlag(ObjectInfoState.OnWalkable); @@ -956,8 +966,13 @@ public sealed class Transition transitionState); } - // PathClipped objects stop at the first collision. - if (CollisionInfo.CollisionNormalValid && ObjectInfo.PathClipped) + // retail find_valid_position (pc:273745): stop the sweep as soon as the mover is + // flagged stuck (frames_stationary_fall != 0) OR a PathClipped object took its + // first collision. The fsf break is load-bearing — without it a later advancing + // sub-step in the SAME frame resets fsf, so the counter can never escalate across + // frames (ACE Transition.cs:587 `if (CollisionInfo.FramesStationaryFall > 0) break`). + if (CollisionInfo.FramesStationaryFall != 0 + || (CollisionInfo.CollisionNormalValid && ObjectInfo.PathClipped)) break; } @@ -4578,6 +4593,11 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; + // retail validate_transition arg3 / ACE _redo: was this a CLEAN advance (an OK step + // that actually moved)? Captured BEFORE the collision branch below reverts CurPos and + // rewrites transitionState to OK. Feeds the frames_stationary_fall ladder at the tail. + bool cleanAdvance = transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos; + if (transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos) { // Movement succeeded: accept the new position. @@ -4718,6 +4738,64 @@ public sealed class Transition oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable); } + // ── frames_stationary_fall ladder (retail validate_transition pc:272625-656; + // ACE Transition.cs:1029-1061). Retires the TS-3 stub. Detects a gravity mover + // that CANNOT advance (blocked) for successive frames and (a) escalates the counter + // and (b) at fsf≥3 manufactures an UPWARD contact plane so the mover "stands on" the + // obstacle (glide onto a crowd top). handle_all_collisions later zeros the velocity + // when fsf>1 (the airborne-stuck bleed). + // + // STRUCTURAL ADAPTATION (register): ACE interleaves this between the LKCP-restore + // and the contact-marking, using its `_redo`. acdream fused those into the contact + // block above (the L.2.3c/L.2.4/A6.P3 contact-retention divergences), so the ladder + // runs HERE, after the marking, and derives the same signal as `cleanAdvance || + // oi.OnWalkable` (a grounded wall-slide is not a stuck-fall) — semantically equal to + // ACE's _redo. The manufacture case re-marks grounding itself (ACE's post-fsf D block). + // Gate: not the camera viewer AND the mover has gravity (pc:272625). + if (!oi.IsViewer && oi.MoverHasGravity) + { + bool redo = cleanAdvance || oi.OnWalkable; + if (!redo) + { + int fsf = ci.FramesStationaryFall; + if (fsf > 0) + { + if (fsf > 1) + { + ci.FramesStationaryFall = 3; + + // UP plane through the sphere bottom: d = radius − center.Z + // (up=(0,0,1) so dot(center,up)=center.Z). pc:272639-643 / ACE 1040-1044. + var up = Vector3.UnitZ; + float d = sp.GlobalSphere[0].Radius - sp.GlobalSphere[0].Origin.Z; + ci.SetContactPlane(new Plane(up, d), sp.CheckCellId, isWater: false); + + // Only record the environment hit if not ALREADY in contact + // (pc:272647 / ACE 1046) — read Contact before we set it below. + if (!oi.Contact) + { + ci.SetCollisionNormal(up); + ci.CollidedWithEnvironment = true; + } + + // acdream runs the ladder after the fused contact-marking, so apply the + // manufactured grounding + LKCP save that ACE's post-fsf D block would. + oi.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + ci.LastKnownContactPlaneValid = true; + ci.LastKnownContactPlane = ci.ContactPlane; + ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId; + ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater; + } + else + ci.FramesStationaryFall = 2; + } + else + ci.FramesStationaryFall = 1; + } + else + ci.FramesStationaryFall = 0; + } + return transitionState; } } diff --git a/tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs b/tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs new file mode 100644 index 00000000..b5059a76 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// 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. +/// +/// +/// 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 +/// ); this file proves the counter itself. +/// +/// +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(), + portals: Array.Empty(), + 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 + } +}