fix(runtime): align portal and movement presentation
Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once. User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
e95f55f25b
commit
124e046976
30 changed files with 1362 additions and 348 deletions
|
|
@ -81,7 +81,12 @@ public readonly record struct MovementResult(
|
|||
bool SidestepUsesRunHold = false,
|
||||
// The host acknowledges this clock/queue only after the MoveToState has
|
||||
// actually been put on the wire.
|
||||
bool IsMouseLookMovementEvent = false);
|
||||
bool IsMouseLookMovementEvent = false,
|
||||
// CommandInterpreter::SendMovementEvent @ 0x006B4680 passes the
|
||||
// MovementManager's complete RawMotionState into MoveToStatePack. An
|
||||
// absent style bit unpacks as NonCombat, so the canonical raw style must
|
||||
// travel with every input-boundary snapshot sent to ACE.
|
||||
uint CurrentStyle = 0x8000003Du);
|
||||
|
||||
/// <summary>
|
||||
/// Portal-space state for the player movement controller.
|
||||
|
|
@ -135,6 +140,13 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
public float StepDownHeight { get; set; } = 0.4f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::m_scale</c>. Grounded CSequence root
|
||||
/// displacement is multiplied by this value before PositionManager
|
||||
/// composition (<c>UpdatePositionInternal @ 0x00512C30</c>).
|
||||
/// </summary>
|
||||
public float ObjectScale { get; set; } = 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Current portal-space state. Set to PortalSpace when the server sends
|
||||
/// PlayerTeleport (0xF751); set back to InWorld once the destination
|
||||
|
|
@ -339,6 +351,8 @@ public sealed class PlayerMovementController
|
|||
private float _physicsAccum;
|
||||
private Vector3 _prevPhysicsPos;
|
||||
private Vector3 _currPhysicsPos;
|
||||
private Func<float, Vector3>? _advanceAnimationRootMotion;
|
||||
private Vector3 _pendingAnimationRootMotion;
|
||||
|
||||
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
|
||||
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
|
||||
|
|
@ -586,6 +600,7 @@ public sealed class PlayerMovementController
|
|||
_prevTurnLeftHeld = input.TurnLeft;
|
||||
_prevTurnRightHeld = input.TurnRight;
|
||||
_prevRunHeld = input.Run;
|
||||
|
||||
_prevRunHold = input.Run;
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
}
|
||||
|
|
@ -867,6 +882,20 @@ public sealed class PlayerMovementController
|
|||
_motion.GetCycleVelocity = accessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the owning PartArray/CSequence update to the local physics tick.
|
||||
/// The callback runs after this frame's input edges have reached the motion
|
||||
/// table and returns the complete local translation emitted by
|
||||
/// <c>CSequence::update</c>. The controller accumulates sub-quantum deltas
|
||||
/// and consumes them through the same PositionManager frame as retail.
|
||||
/// </summary>
|
||||
public void AttachAnimationRootMotionSource(Func<float, Vector3> advance)
|
||||
{
|
||||
_advanceAnimationRootMotion = advance
|
||||
?? throw new ArgumentNullException(nameof(advance));
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
|
||||
/// stamps only <c>last_sent_position_time</c>. The carried frame and
|
||||
|
|
@ -913,7 +942,8 @@ public sealed class PlayerMovementController
|
|||
TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run,
|
||||
SidestepUsesRunHold: sidestep.HasValue
|
||||
&& raw.SidestepHoldKey == HoldKey.Run,
|
||||
IsMouseLookMovementEvent: mouseLookEvent);
|
||||
IsMouseLookMovementEvent: mouseLookEvent,
|
||||
CurrentStyle: raw.CurrentStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1065,6 +1095,7 @@ public sealed class PlayerMovementController
|
|||
// Reset physics clock so any subsequent update_object calls start fresh.
|
||||
_body.LastUpdateTime = 0.0;
|
||||
_physicsAccum = 0f;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1078,6 +1109,7 @@ public sealed class PlayerMovementController
|
|||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
}
|
||||
|
||||
|
|
@ -1098,6 +1130,7 @@ public sealed class PlayerMovementController
|
|||
{
|
||||
_simTimeSeconds += dt;
|
||||
_physicsAccum = 0f;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
Vector3 previousPosition = _body.Position;
|
||||
bool previousContact = _body.InContact;
|
||||
bool previousOnWalkable = _body.OnWalkable;
|
||||
|
|
@ -1164,7 +1197,8 @@ public sealed class PlayerMovementController
|
|||
TurnCommand: null,
|
||||
ForwardSpeed: null,
|
||||
SidestepSpeed: null,
|
||||
TurnSpeed: null);
|
||||
TurnSpeed: null,
|
||||
CurrentStyle: _motion.RawState.CurrentStyle);
|
||||
}
|
||||
|
||||
public MovementResult Update(float dt, MovementInput input)
|
||||
|
|
@ -1219,7 +1253,8 @@ public sealed class PlayerMovementController
|
|||
TurnCommand: null,
|
||||
ForwardSpeed: null,
|
||||
SidestepSpeed: null,
|
||||
TurnSpeed: null);
|
||||
TurnSpeed: null,
|
||||
CurrentStyle: _motion.RawState.CurrentStyle);
|
||||
}
|
||||
|
||||
// ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
|
||||
|
|
@ -1385,6 +1420,21 @@ public sealed class PlayerMovementController
|
|||
_prevTurnRightHeld = input.TurnRight;
|
||||
_prevRunHeld = input.Run;
|
||||
|
||||
// Retail input reaches MotionTableManager before this object's
|
||||
// CPartArray update. Advance only after the edge dispatch above so a
|
||||
// Ready-to-Walk/Run link contributes its authored displacement on the
|
||||
// same object tick. Sub-quantum render frames accumulate until the
|
||||
// 30 Hz physics gate consumes them below.
|
||||
bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null;
|
||||
if (_advanceAnimationRootMotion is { } advanceRootMotion)
|
||||
{
|
||||
Vector3 rootDelta = advanceRootMotion(dt);
|
||||
if (_body.OnWalkable)
|
||||
_pendingAnimationRootMotion += rootDelta * ObjectScale;
|
||||
else
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
}
|
||||
|
||||
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
|
||||
// 2026-05-16 — retail-faithful turn rate.
|
||||
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
|
||||
|
|
@ -1423,41 +1473,32 @@ public sealed class PlayerMovementController
|
|||
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
|
||||
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
|
||||
|
||||
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
|
||||
// R4-V5: runs during a moveto too — the manager's BeginMoveForward
|
||||
// dispatched WalkForward/RunForward through _DoMotion into the SAME
|
||||
// interpreted state, so this one per-frame apply turns it into body
|
||||
// velocity exactly like input-driven motion (the #75 "two writers"
|
||||
// hazard is gone: there is only this writer now).
|
||||
|
||||
// D6.2: the forward/sidestep command determination + DoMotion +
|
||||
// DoInterpretedMotion moved into the apply_raw_movement call above, so
|
||||
// the interpreted state (and thus get_state_velocity) is already
|
||||
// populated + normalized for all directions.
|
||||
|
||||
// Only replace velocity with motion interpreter output when grounded.
|
||||
// While airborne, the physics body's integrated velocity (from LeaveGround)
|
||||
// persists — gravity pulls Z down, horizontal momentum is preserved.
|
||||
// Retail AC works this way: you maintain momentum in the air.
|
||||
// ── 2. Install grounded movement source ───────────────────────────────
|
||||
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
|
||||
// PartArray into one local Frame. Ordinary grounded movement is that
|
||||
// animation-authored displacement, not get_state_velocity(). The
|
||||
// latter remains the explicit fallback for headless/test controllers
|
||||
// that have no attached animation runtime and for jump launch below.
|
||||
if (_body.OnWalkable)
|
||||
{
|
||||
float savedWorldVz = _body.Velocity.Z;
|
||||
// D6.2: velocity for ALL directions comes from the normalized
|
||||
// interpreted state — backward (WalkForward ×-0.65) and strafe-left
|
||||
// (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
|
||||
// that adjust_motion ran above. The hand-mirrored formulas are gone
|
||||
// (register TS-22 retired).
|
||||
var stateVel = _motion.get_state_velocity();
|
||||
// R4-V5 wedge fix: PRESERVE the flag — retail's
|
||||
// last_move_was_autonomous is stored at EVENT boundaries only
|
||||
// (input DoMotion/StopMotion edges above, the P1 wire-unpack
|
||||
// store, LeaveGround's own autonomous=1 write), never re-stamped
|
||||
// by the per-tick velocity write. V5's first cut stamped it per
|
||||
// frame here, which mis-routed the pump's A3 dual dispatch on
|
||||
// event-transition frames (apply_raw clobbering a just-armed
|
||||
// moveto's dispatched motion with the raw Ready state).
|
||||
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
|
||||
autonomous: _body.LastMoveWasAutonomous);
|
||||
if (hasAnimationRootMotion)
|
||||
{
|
||||
// Do not let m_velocityVector move the body a second time.
|
||||
// The pending animation delta is composed with the
|
||||
// PositionManager below and swept through the normal collision
|
||||
// path in the same physics quantum.
|
||||
_body.Velocity = new Vector3(0f, 0f, savedWorldVz);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stateVel = _motion.get_state_velocity();
|
||||
// Preserve LastMoveWasAutonomous: retail stores it at motion
|
||||
// event boundaries, never at this per-tick fallback write.
|
||||
_body.set_local_velocity(
|
||||
new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
|
||||
autonomous: _body.LastMoveWasAutonomous);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
||||
|
|
@ -1540,6 +1581,7 @@ public sealed class PlayerMovementController
|
|||
{
|
||||
// Stale frame (debugger break, GC pause). Discard accumulated dt.
|
||||
_physicsAccum = 0f;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
}
|
||||
|
|
@ -1559,18 +1601,21 @@ public sealed class PlayerMovementController
|
|||
// body orientation); the heading is RELATIVE and writes Yaw —
|
||||
// the authoritative facing the body quaternion is re-derived
|
||||
// from every frame (a quaternion-only write would be clobbered).
|
||||
// No-op while nothing is stuck (untouched delta frame).
|
||||
if (PositionManager is { } ppm)
|
||||
{
|
||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
ppm.AdjustOffset(pmDelta, tickDt);
|
||||
if (pmDelta.Origin != Vector3.Zero)
|
||||
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
|
||||
if (!pmDelta.Orientation.IsIdentity)
|
||||
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
|
||||
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
|
||||
+ pmDelta.GetHeading());
|
||||
}
|
||||
// Seed the SAME frame that PositionManager receives. Retail's
|
||||
// Sticky interpolation deliberately replaces PartArray root
|
||||
// displacement rather than adding a second movement source.
|
||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
if (hasAnimationRootMotion && _body.OnWalkable)
|
||||
pmDelta.Origin = _pendingAnimationRootMotion;
|
||||
_pendingAnimationRootMotion = Vector3.Zero;
|
||||
|
||||
PositionManager?.AdjustOffset(pmDelta, tickDt);
|
||||
if (pmDelta.Origin != Vector3.Zero)
|
||||
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
|
||||
if (!pmDelta.Orientation.IsIdentity)
|
||||
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
|
||||
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
|
||||
+ pmDelta.GetHeading());
|
||||
|
||||
_body.calc_acceleration();
|
||||
_body.UpdatePhysicsInternal(tickDt);
|
||||
|
|
@ -1868,7 +1913,8 @@ public sealed class PlayerMovementController
|
|||
TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue,
|
||||
SidestepUsesRunHold: _activeInputSidestepUsesRunHold
|
||||
&& outSidestepCmd.HasValue,
|
||||
IsMouseLookMovementEvent: mouseMovementEventDue);
|
||||
IsMouseLookMovementEvent: mouseMovementEventDue,
|
||||
CurrentStyle: _motion.RawState.CurrentStyle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue