fix(physics): port retail slope landing stop

This commit is contained in:
Erik 2026-07-31 09:10:53 +02:00
parent 1d8371dbe5
commit 5a0f9868a6
13 changed files with 870 additions and 103 deletions

View file

@ -5530,6 +5530,30 @@ public sealed class Transition
else if (transitionState != TransitionState.Invalid)
{
// Collision/slide/adjusted: revert to current position.
// Retail CTransition::validate_transition 0x0050AA70 consumes a
// remembered contact plane only on this non-OK recovery path. It
// first calls OBJECTINFO::kill_velocity, then restores the plane
// when the current sphere remains within radius + EPSILON. Omitting
// the kill preserved a landing reflection while repeatedly
// re-grounding the mover, producing Campaign P #269's long slide.
if (ci.LastKnownContactPlaneValid)
{
oi.StopVelocity();
var sphereCenter = sp.GlobalCurrCenter[0].Origin;
var radius = sp.GlobalSphere[0].Radius;
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
+ ci.LastKnownContactPlane.D;
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
{
ci.SetContactPlane(
ci.LastKnownContactPlane,
ci.LastKnownContactPlaneCellId,
ci.LastKnownContactPlaneIsWater);
}
}
if (!ci.CollisionNormalValid)
ci.SetCollisionNormal(Vector3.UnitZ); // default: push up
@ -5541,14 +5565,11 @@ public sealed class Transition
if (ci.CollisionNormalValid)
ci.SetSlidingNormal(ci.CollisionNormal);
// Preserve contact plane for next step.
// L.2.3c (2026-04-29): only OVERWRITE LastKnown when current is valid.
// Previously: `LastKnownValid = ContactPlaneValid` cleared
// LastKnown whenever current was invalid — destroying the prior frame's
// contact memory. After StepUpSlide cleared ContactPlane mid-step
// (failed step-up against a too-tall wall), this propagated to
// LastKnown and the player went airborne for a frame, flickering the
// falling animation. Now LastKnown survives transient losses.
// Retail 0x0050ACFF-0x0050AD9A overwrites last-known validity with
// current contact validity, copies the plane only when valid, and then
// derives Contact/OnWalkable from that final plane. A remembered plane
// can have been restored only in the non-OK recovery branch above; it
// must never re-ground a clean move away from the surface.
if (ci.ContactPlaneValid)
{
ci.LastKnownContactPlaneValid = true;
@ -5569,79 +5590,9 @@ public sealed class Transition
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
else if (ci.LastKnownContactPlaneValid)
{
// L.2.3c: current contact lost transiently (e.g. StepUpSlide
// cleared it during a failed step-up) but the prior frame's
// contact is still valid — keep the mover grounded via the
// last-known plane. Without this, every wall bump dropped the
// player into the falling animation for one frame.
//
// L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the
// last-known plane if the sphere is still actually near it.
// Geometrically: `angle` is the signed distance from the
// sphere center to the plane. If |angle| exceeds the sphere
// radius (plus a tiny epsilon), the sphere has SEPARATED
// from the plane — typically because we fell off an edge or
// the body dropped vertically while the resolver bounced
// through edge-slide attempts. Without this guard the player
// gets stuck mid-fall in a falling animation forever (live
// bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the
// remembered floor, but still being marked Contact + OnWalkable).
//
// Matches ACE PhysicsObj's pre-reuse check on the last-known
// plane and retail's CPhysicsObj::get_object_info logic.
// A6.P3 slice 1 (2026-05-21). Retail uses global_curr_center (NOT
// global_sphere->center) for this proximity check — see
// acclient_2013_pseudo_c.txt:272568. global_sphere is the START
// sphere of the transition; global_curr_center is the CURRENT center
// after sub-step accumulation. Using the wrong one made the proximity
// guard fire on the wrong reference point.
var sphereCenter = sp.GlobalCurrCenter[0].Origin;
var radius = sp.GlobalSphere[0].Radius;
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
+ ci.LastKnownContactPlane.D;
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
{
// ── Mechanism B — restore CP from LKCP per retail ────────────────
// A6.P3 slice 1 (2026-05-21). Retail oracle:
// acclient_2013_pseudo_c.txt:272577 (inside CTransition::validate_transition
// at line 272547). When the sphere is geometrically close to the
// LastKnownContactPlane, retail restores CP from LKCP via
// set_contact_plane(&collision_info, &last_known_contact_plane,
// last_known_contact_plane_is_water). This closes the gap that the
// stripped TryFindIndoorWalkablePlane synthesis path used to fill —
// when no fresh Path-6 CP write lands in this transition, CP is
// retained from the previous frame instead of being re-synthesized.
//
// NOTE: SetContactPlane also re-latches LKCP fields
// (TransitionTypes.cs:258-261), which is a no-op here since we
// pass LKCP as the source.
ci.SetContactPlane(
ci.LastKnownContactPlane,
ci.LastKnownContactPlaneCellId,
ci.LastKnownContactPlaneIsWater);
// Still close enough to the last-known plane — preserve
// grounded state. L.2.3i FloorZ test for OnWalkable.
oi.State |= ObjectInfoState.Contact;
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
else
{
// Sphere has separated from the last-known plane.
// Drop the memory and let the body resolve normally
// (gravity → next-frame terrain probe → real contact).
ci.LastKnownContactPlaneValid = false;
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}
}
else
{
ci.LastKnownContactPlaneValid = false;
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}

View file

@ -1841,6 +1841,11 @@ public sealed class PlayerMovementController
for (int qi = 0; qi < quantumBatch.Count; qi++)
{
float tickDt = quantumBatch.GetQuantum(qi);
bool captureQuantum = PlayerPhysicsQuantumCapture.IsEnabled;
uint captureCellBefore = CellId;
PlayerPhysicsBodyTraceSnapshot captureQuantumStart = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
// CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects
// advance their PartArray first. The complete Frame survives; only its
@ -1931,7 +1936,13 @@ public sealed class PlayerMovementController
}
_body.calc_acceleration();
PlayerPhysicsBodyTraceSnapshot capturePreIntegration = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
_body.UpdatePhysicsInternal(tickDt);
PlayerPhysicsBodyTraceSnapshot capturePostIntegration = captureQuantum
? PlayerPhysicsQuantumCapture.Snapshot(_body)
: default;
// Retail process_hooks is the final UpdatePositionInternal step:
// after physics, before the transition and manager tail.
@ -2121,6 +2132,30 @@ public sealed class PlayerMovementController
_motion.CheckForCompletedMotions,
PositionManager);
if (captureQuantum)
{
PlayerPhysicsQuantumCapture.Log(
tickDt,
captureCellBefore,
CellId,
input,
capturePreIntegration.Position - captureQuantumStart.Position,
candidateMoved,
captureQuantumStart,
capturePreIntegration,
capturePostIntegration,
new PlayerPhysicsResolveTraceSnapshot(
resolveResult.Position,
resolveResult.CellId,
resolveResult.Ok,
resolveResult.IsOnGround,
resolveResult.InContact,
resolveResult.OnWalkable,
resolveResult.CollisionNormalValid,
resolveResult.CollisionNormal),
PlayerPhysicsQuantumCapture.Snapshot(_body));
}
}
// ── 4. Determine outbound motion commands ─────────────────────────────

View file

@ -0,0 +1,191 @@
using System.Numerics;
using System.Text.Json;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Opt-in, player-only trace of the complete retail object quantum.
/// <para>
/// Set <c>ACDREAM_CAPTURE_PLAYER_QUANTA=&lt;path&gt;</c> before process
/// startup to emit one JSON-Lines record per admitted physics quantum. The
/// capture is deliberately outside the physics implementation: it observes
/// the same stage boundaries as retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> without changing their order or
/// introducing a second simulation path. When disabled, the hot path pays
/// one static null/empty check and performs no allocations.
/// </para>
/// </summary>
internal static class PlayerPhysicsQuantumCapture
{
internal static string? CapturePath { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_CAPTURE_PLAYER_QUANTA");
internal static bool IsEnabled => !string.IsNullOrWhiteSpace(CapturePath);
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
IncludeFields = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
private static readonly object s_writerLock = new();
private static StreamWriter? s_writer;
private static long s_sequence;
private static bool s_processExitHooked;
internal static PlayerPhysicsBodyTraceSnapshot Snapshot(PhysicsBody body) =>
new(
Position: body.Position,
Orientation: body.Orientation,
Velocity: body.Velocity,
Acceleration: body.Acceleration,
GroundNormal: body.GroundNormal,
ContactPlaneValid: body.ContactPlaneValid,
ContactPlane: body.ContactPlane,
Friction: body.Friction,
Elasticity: body.Elasticity,
State: (uint)body.State,
TransientState: (uint)body.TransientState,
FramesStationaryFall: body.FramesStationaryFall);
internal static void Log(
float dt,
uint cellBefore,
uint cellAfter,
MovementInput input,
Vector3 rootAndManagerDelta,
bool candidateMoved,
PlayerPhysicsBodyTraceSnapshot quantumStart,
PlayerPhysicsBodyTraceSnapshot preIntegration,
PlayerPhysicsBodyTraceSnapshot postIntegration,
PlayerPhysicsResolveTraceSnapshot resolve,
PlayerPhysicsBodyTraceSnapshot postCommit)
{
string? path = CapturePath;
if (string.IsNullOrWhiteSpace(path))
return;
var record = new PlayerPhysicsQuantumTraceRecord(
Sequence: Interlocked.Increment(ref s_sequence) - 1,
TimestampTicks: System.Diagnostics.Stopwatch.GetTimestamp(),
Dt: dt,
CellBefore: cellBefore,
CellAfter: cellAfter,
Input: new PlayerMovementInputTraceSnapshot(
input.Forward,
input.Backward,
input.StrafeLeft,
input.StrafeRight,
input.TurnLeft,
input.TurnRight,
input.Run,
input.Jump),
RootAndManagerDelta: rootAndManagerDelta,
CandidateMoved: candidateMoved,
QuantumStart: quantumStart,
PreIntegration: preIntegration,
PostIntegration: postIntegration,
Resolve: resolve,
PostCommit: postCommit);
string json = JsonSerializer.Serialize(record, s_jsonOptions);
lock (s_writerLock)
{
EnsureWriter_NoLock(path);
s_writer!.WriteLine(json);
s_writer.Flush();
}
}
internal static void Close()
{
lock (s_writerLock)
{
s_writer?.Dispose();
s_writer = null;
}
}
internal static void ResetForTest()
{
Close();
CapturePath = null;
Interlocked.Exchange(ref s_sequence, 0);
}
private static void EnsureWriter_NoLock(string path)
{
if (s_writer is not null)
return;
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
s_writer = new StreamWriter(new FileStream(
path,
FileMode.Append,
FileAccess.Write,
FileShare.Read))
{
AutoFlush = false,
};
if (!s_processExitHooked)
{
AppDomain.CurrentDomain.ProcessExit += static (_, _) => Close();
s_processExitHooked = true;
}
}
}
internal sealed record PlayerPhysicsQuantumTraceRecord(
long Sequence,
long TimestampTicks,
float Dt,
uint CellBefore,
uint CellAfter,
PlayerMovementInputTraceSnapshot Input,
Vector3 RootAndManagerDelta,
bool CandidateMoved,
PlayerPhysicsBodyTraceSnapshot QuantumStart,
PlayerPhysicsBodyTraceSnapshot PreIntegration,
PlayerPhysicsBodyTraceSnapshot PostIntegration,
PlayerPhysicsResolveTraceSnapshot Resolve,
PlayerPhysicsBodyTraceSnapshot PostCommit);
internal readonly record struct PlayerMovementInputTraceSnapshot(
bool Forward,
bool Backward,
bool StrafeLeft,
bool StrafeRight,
bool TurnLeft,
bool TurnRight,
bool Run,
bool Jump);
internal readonly record struct PlayerPhysicsBodyTraceSnapshot(
Vector3 Position,
Quaternion Orientation,
Vector3 Velocity,
Vector3 Acceleration,
Vector3 GroundNormal,
bool ContactPlaneValid,
Plane ContactPlane,
float Friction,
float Elasticity,
uint State,
uint TransientState,
int FramesStationaryFall);
internal readonly record struct PlayerPhysicsResolveTraceSnapshot(
Vector3 Position,
uint CellId,
bool Ok,
bool IsOnGround,
bool InContact,
bool OnWalkable,
bool CollisionNormalValid,
Vector3 CollisionNormal);