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:
parent
6e8117741b
commit
6c3cd96b7a
3 changed files with 264 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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->state & 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
149
tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs
Normal file
149
tests/AcDream.Core.Tests/Physics/FramesStationaryFallTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue