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

@ -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;

View file

@ -69,6 +69,16 @@ public sealed class ObjectInfo
/// </summary>
public uint SelfEntityId;
/// <summary>
/// The moving object has the retail GRAVITY state bit (PhysicsStateFlags.Gravity 0x400).
/// Seeded from the body at <c>get_object_info</c> time. Retail's frames_stationary_fall
/// ladder is gated on the mover having gravity (<c>object_info.object-&gt;state &amp; 0x400</c>,
/// validate_transition pc:272625; ACE <c>ObjectInfo.Object.State.HasFlag(Gravity)</c>
/// 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.
/// </summary>
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;
}
}