fix(physics): port retail slope landing stop
This commit is contained in:
parent
1d8371dbe5
commit
5a0f9868a6
13 changed files with 870 additions and 103 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────
|
||||
|
|
|
|||
191
src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs
Normal file
191
src/AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs
Normal 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=<path></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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue