fix(gameplay): reconcile wield ownership and target facing
Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks. Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state. Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration. Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
b26f84cc69
commit
7b7ffcd278
36 changed files with 3884 additions and 761 deletions
|
|
@ -27,6 +27,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
|
||||
private readonly CombatState _combat;
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
|
|
@ -50,6 +51,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
CombatState combat,
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? prepareAttackRequest = null,
|
||||
Action? sendCancelAttack = null,
|
||||
Func<bool>? isDualWield = null,
|
||||
Func<bool>? playerReadyForAttack = null,
|
||||
|
|
@ -58,6 +60,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
|
||||
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
|
||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
||||
_isDualWield = isDualWield ?? (() => false);
|
||||
|
|
@ -73,6 +76,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
|
||||
public float DesiredPower { get; private set; } = InitialDesiredPower;
|
||||
public bool AttackRequestInProgress => _attackRequestInProgress;
|
||||
public float RequestedAttackPower => _requestedAttackPower;
|
||||
public bool BuildInProgress => _buildInProgress;
|
||||
|
||||
/// <summary>The level retail publishes to the embedded combat meter.</summary>
|
||||
|
|
@ -257,8 +261,13 @@ public sealed class CombatAttackController : IDisposable
|
|||
|| !_canStartAttack())
|
||||
return;
|
||||
|
||||
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
|
||||
// and requested power first, then FinishJump and
|
||||
// CommandInterpreter::MaybeStopCompletely. The host callback owns the
|
||||
// player movement object and must run before any later attack send.
|
||||
_attackRequestInProgress = true;
|
||||
_requestedAttackPower = 1f;
|
||||
_prepareAttackRequest();
|
||||
_currentBuildIsAutomatic = false;
|
||||
AttemptStartBuildingAttack();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,21 @@ public readonly record struct MovementResult(
|
|||
// = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0.
|
||||
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
|
||||
float? JumpExtent = null, // non-null when a jump was triggered this frame
|
||||
Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
|
||||
Vector3? JumpVelocity = null, // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
|
||||
// Retail updates mouse-origin turn motions continuously but sends the
|
||||
// resulting movement state only on ToggleMouseLook start/stop and the
|
||||
// CameraSet half-second cadence. Keep packet ownership separate from the
|
||||
// local motion-state change so mouse sampling cannot flood the server.
|
||||
bool ShouldSendMovementEvent = false,
|
||||
// CameraSet::Rotate always calls MovePlayer with holdRun=true for the turn
|
||||
// axis, independently of the player's ordinary walk/run toggle.
|
||||
bool TurnUsesRunHold = false,
|
||||
// CameraInstantMouseLook remaps keyboard turn to sidestep with the same
|
||||
// per-axis Run hold, independently of the global walk/run toggle.
|
||||
bool SidestepUsesRunHold = false,
|
||||
// The host acknowledges this clock/queue only after the MoveToState has
|
||||
// actually been put on the wire.
|
||||
bool IsMouseLookMovementEvent = false);
|
||||
|
||||
/// <summary>
|
||||
/// Portal-space state for the player movement controller.
|
||||
|
|
@ -99,8 +113,6 @@ public sealed class PlayerMovementController
|
|||
private readonly MotionInterpreter _motion;
|
||||
private readonly PlayerWeenie _weenie;
|
||||
|
||||
public float MouseTurnSensitivity { get; set; } = 0.003f;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum Z increase per movement step before the move is rejected.
|
||||
/// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip-
|
||||
|
|
@ -138,6 +150,25 @@ public sealed class PlayerMovementController
|
|||
public uint CellId { get; private set; }
|
||||
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Returns retail's canonical outbound <c>Position</c>: the physics body's
|
||||
/// carried cell id plus its landblock-local frame origin. Retail
|
||||
/// <c>CommandInterpreter::SendMovementEvent @ 0x006B4680</c> and
|
||||
/// <c>SendPositionEvent @ 0x006B4770</c> serialize
|
||||
/// <c>CPhysicsObj::m_position</c> directly; render/streaming origins are not
|
||||
/// part of the protocol frame.
|
||||
/// </summary>
|
||||
internal bool TryGetOutboundPosition(
|
||||
Quaternion rotation,
|
||||
out uint cellId,
|
||||
out Vector3 localOrigin)
|
||||
{
|
||||
AcDream.Core.Physics.Position canonical = _body.CellPosition;
|
||||
cellId = canonical.ObjCellId;
|
||||
localOrigin = canonical.Frame.Origin;
|
||||
return PositionFrameValidation.IsValid(cellId, localOrigin, rotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local-player entity id used to skip self-collision in the
|
||||
/// airborne sweep. GameWindow updates this whenever the local
|
||||
|
|
@ -223,18 +254,44 @@ public sealed class PlayerMovementController
|
|||
private bool _prevTurnLeftHeld;
|
||||
private bool _prevTurnRightHeld;
|
||||
private bool _prevRunHeld;
|
||||
private bool _hasInputSnapshot;
|
||||
|
||||
// Heartbeat timer.
|
||||
// Cadence is 1.0 sec to match holtburger's
|
||||
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
|
||||
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
|
||||
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
|
||||
// but wasteful and probably looked like jitter to observers.
|
||||
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / 0x00458310).
|
||||
// Mouse look owns a transient turn command in the SAME MotionInterpreter
|
||||
// as keyboard and server turns; it never writes Yaw directly. The latest
|
||||
// filtered sample replaces the preceding sample, matching CameraSet's
|
||||
// per-mouse-message MovePlayer call.
|
||||
private bool _mouseLookActive;
|
||||
private bool _mouseTurnSamplePending;
|
||||
private float _mouseTurnAdjustment;
|
||||
private uint? _activeInputTurnCommand;
|
||||
private float _activeInputTurnSpeed;
|
||||
private bool _activeInputTurnFromMouse;
|
||||
private uint? _activeInputSidestepCommand;
|
||||
private bool _activeInputSidestepUsesRunHold;
|
||||
private bool _mouseMovementEventCandidate;
|
||||
private bool _mouseMovementEventPending;
|
||||
private float _lastMouseMovementEventTime;
|
||||
private bool _controlledByServer = true;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's mouse-look movement report interval from
|
||||
/// <c>CameraSet::Rotate</c> and <c>MouseLookHandler</c>.
|
||||
/// </summary>
|
||||
public const float MouseMovementEventInterval = 0.5f;
|
||||
|
||||
private const float MouseTurnDeadZone = 0.02f;
|
||||
private const float MouseTurnSpeedScale = 2.0f;
|
||||
private const float MouseTurnMaximumSpeed = 1.5f;
|
||||
|
||||
// Position-event comparison interval. Retail does not send an idle packet
|
||||
// every second: after the interval elapses it compares the complete frame
|
||||
// and sends only when the cell, origin, or orientation changed.
|
||||
/// <summary>
|
||||
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's
|
||||
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
|
||||
/// at address 0x006b45e0) which gates on either (a) position-or-cell
|
||||
/// change since the last send, or (b) at-rest 1 sec heartbeat elapsed.
|
||||
/// at address 0x006b45e0). Inside the interval it reacts to cell/contact
|
||||
/// changes; after the interval it compares cell plus the complete frame.
|
||||
/// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
|
||||
///
|
||||
/// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
|
||||
|
|
@ -245,13 +302,11 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
|
||||
|
||||
private System.Numerics.Vector3 _lastSentPos;
|
||||
private uint _lastSentCellId;
|
||||
private AcDream.Core.Physics.Position _lastSentPosition;
|
||||
private System.Numerics.Plane _lastSentContactPlane;
|
||||
private float _lastSentTime;
|
||||
private bool _lastSentInitialized;
|
||||
private float _simTimeSeconds;
|
||||
public bool HeartbeatDue { get; private set; }
|
||||
|
||||
/// <summary>Sim-time accumulator (advanced by dt at the top of Update).
|
||||
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
|
||||
|
|
@ -386,6 +441,307 @@ public sealed class PlayerMovementController
|
|||
_body.LastMoveWasAutonomous = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins retail instant mouse-look. <c>CameraSet::ToggleMouseLook</c>
|
||||
/// (0x00457490) sends a movement event on both transitions, even before a
|
||||
/// horizontal turn sample arrives.
|
||||
/// </summary>
|
||||
public bool BeginMouseLook(MovementInput input)
|
||||
{
|
||||
if (_mouseLookActive || State != PlayerState.InWorld)
|
||||
return false;
|
||||
|
||||
_mouseLookActive = true;
|
||||
// ToggleMouseLook stores the new mode before MovePlayer enters
|
||||
// TakeControlFromServer, so its ApplyCurrentMovement observes the
|
||||
// remapped turn→sidestep channels immediately.
|
||||
TakeControlFromServer(input);
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
ApplyMouseLookToggleMovement(input, entering: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the latest signed, filtered horizontal mouse adjustment.
|
||||
/// Negative means TurnRight in acdream's yaw convention; positive means
|
||||
/// TurnLeft. Retail <c>CameraSet::Rotate</c> doubles the magnitude, applies
|
||||
/// a 0.02 dead zone, and caps the raw turn speed at 1.5.
|
||||
/// </summary>
|
||||
public void SubmitMouseTurnAdjustment(
|
||||
float signedAdjustment,
|
||||
MovementInput input)
|
||||
{
|
||||
if (!_mouseLookActive || !float.IsFinite(signedAdjustment))
|
||||
return;
|
||||
|
||||
TakeControlFromServer(input);
|
||||
_mouseTurnAdjustment = signedAdjustment;
|
||||
_mouseTurnSamplePending = true;
|
||||
_mouseMovementEventCandidate = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's delayed zero-input <c>MouseLookHandler</c> calls
|
||||
/// <c>CommandInterpreter::StopDrift @ 0x006B4510</c> before filtering the
|
||||
/// zero sample. It stops both turn directions and participates in the
|
||||
/// same half-second movement-report cadence.
|
||||
/// </summary>
|
||||
public void StopMouseDrift(MovementInput input)
|
||||
{
|
||||
if (!_mouseLookActive)
|
||||
return;
|
||||
|
||||
TakeControlFromServer(input);
|
||||
|
||||
var p = MouseAxisParameters(_activeInputTurnSpeed == 0f
|
||||
? 1f
|
||||
: _activeInputTurnSpeed);
|
||||
StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnRight, p);
|
||||
StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnLeft, p);
|
||||
_activeInputTurnCommand = null;
|
||||
_activeInputTurnSpeed = 0f;
|
||||
_activeInputTurnFromMouse = false;
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
_mouseMovementEventCandidate = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends retail instant mouse-look. The next update restores any held
|
||||
/// keyboard turn (or stops the mouse-origin turn) and publishes the final
|
||||
/// heading through MoveToState.
|
||||
/// </summary>
|
||||
public bool EndMouseLook(MovementInput input)
|
||||
{
|
||||
if (!_mouseLookActive && !_activeInputTurnFromMouse)
|
||||
return false;
|
||||
|
||||
_mouseLookActive = false;
|
||||
// Retail clears the mode before TakeControlFromServer/ApplyCurrentMovement
|
||||
// so a held sidestep-remapped turn is restored directly as turn.
|
||||
TakeControlFromServer(input);
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
|
||||
ApplyMouseLookToggleMovement(input, entering: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ApplyMouseLookToggleMovement(MovementInput input, bool entering)
|
||||
{
|
||||
// CameraSet::ToggleMouseLook @ 0x00457490 routes the pseudo-command
|
||||
// CameraInstantMouseLook through CommandInterpreter::MovePlayer
|
||||
// @ 0x006B3F40. While entering, held keyboard turns move to the
|
||||
// sidestep channel. On exit, MovePlayer reverses that mapping and
|
||||
// ApplyCurrentMovement replays every currently held channel before the
|
||||
// synchronous movement packet is captured.
|
||||
(uint? sidestep, bool useRunHold) = DesiredInputSidestep(input, entering);
|
||||
ApplyInputSidestep(sidestep, useRunHold, reapply: !entering);
|
||||
|
||||
uint? turn = entering
|
||||
? null
|
||||
: input.TurnRight
|
||||
? MotionCommand.TurnRight
|
||||
: input.TurnLeft
|
||||
? MotionCommand.TurnLeft
|
||||
: null;
|
||||
ApplyInputTurn(turn, speed: 1f, fromMouse: false, reapply: !entering);
|
||||
|
||||
// TakeControlFromServer::ApplyCurrentMovement and both sides of
|
||||
// ToggleMouseLook replay the complete current input, not merely the
|
||||
// turn/sidestep channels affected by CameraInstantMouseLook.
|
||||
bool runHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run;
|
||||
if (runHeld != input.Run)
|
||||
_motion.set_hold_run(input.Run, interrupt: true);
|
||||
|
||||
uint? desiredForward = input.Forward
|
||||
? MotionCommand.WalkForward
|
||||
: input.Backward
|
||||
? MotionCommand.WalkBackward
|
||||
: null;
|
||||
|
||||
uint rawForward = _motion.RawState.ForwardCommand;
|
||||
uint? activeForward = rawForward == RawMotionState.Default.ForwardCommand
|
||||
? null
|
||||
: rawForward;
|
||||
if (activeForward is { } active && active != desiredForward)
|
||||
{
|
||||
StopMotionAtPhysicsObjectBoundary(
|
||||
active,
|
||||
new AcDream.Core.Physics.Motion.MovementParameters());
|
||||
}
|
||||
|
||||
if (desiredForward is { } command
|
||||
&& (activeForward != desiredForward || !entering))
|
||||
DoMotionAtPhysicsObjectBoundary(
|
||||
command,
|
||||
new AcDream.Core.Physics.Motion.MovementParameters());
|
||||
|
||||
_hasInputSnapshot = true;
|
||||
_prevForwardHeld = input.Forward;
|
||||
_prevBackwardHeld = input.Backward;
|
||||
_prevStrafeLeftHeld = input.StrafeLeft;
|
||||
_prevStrafeRightHeld = input.StrafeRight;
|
||||
_prevTurnLeftHeld = input.TurnLeft;
|
||||
_prevTurnRightHeld = input.TurnRight;
|
||||
_prevRunHeld = input.Run;
|
||||
_prevRunHold = input.Run;
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
}
|
||||
|
||||
private static (uint? Command, bool UseRunHold) DesiredInputSidestep(
|
||||
MovementInput input,
|
||||
bool mouseLookActive)
|
||||
{
|
||||
if (input.StrafeRight)
|
||||
return (MotionCommand.SideStepRight, false);
|
||||
if (input.StrafeLeft)
|
||||
return (MotionCommand.SideStepLeft, false);
|
||||
if (mouseLookActive && input.TurnRight)
|
||||
return (MotionCommand.SideStepRight, true);
|
||||
if (mouseLookActive && input.TurnLeft)
|
||||
return (MotionCommand.SideStepLeft, true);
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
private bool ApplyInputSidestep(
|
||||
uint? desired,
|
||||
bool useRunHold,
|
||||
bool reapply = false)
|
||||
{
|
||||
bool changed = desired != _activeInputSidestepCommand;
|
||||
if (_activeInputSidestepCommand is { } active
|
||||
&& (changed || reapply))
|
||||
{
|
||||
StopMotionAtPhysicsObjectBoundary(
|
||||
active,
|
||||
_activeInputSidestepUsesRunHold
|
||||
? MouseAxisParameters(1f)
|
||||
: new());
|
||||
}
|
||||
|
||||
if (desired is { } command && (changed || reapply))
|
||||
{
|
||||
DoMotionAtPhysicsObjectBoundary(
|
||||
command,
|
||||
useRunHold ? MouseAxisParameters(1f) : new());
|
||||
}
|
||||
|
||||
_activeInputSidestepCommand = desired;
|
||||
_activeInputSidestepUsesRunHold = desired.HasValue && useRunHold;
|
||||
return changed || (reapply && desired.HasValue);
|
||||
}
|
||||
|
||||
private bool ApplyInputTurn(
|
||||
uint? desired,
|
||||
float speed,
|
||||
bool fromMouse,
|
||||
bool reapply = false)
|
||||
{
|
||||
bool commandChanged = desired != _activeInputTurnCommand;
|
||||
bool bothPresent = desired.HasValue && _activeInputTurnCommand.HasValue;
|
||||
bool speedChanged = bothPresent
|
||||
&& MathF.Abs(speed - _activeInputTurnSpeed) >= 0.0001f;
|
||||
bool ownerChanged = bothPresent && fromMouse != _activeInputTurnFromMouse;
|
||||
bool apply = commandChanged || speedChanged || ownerChanged
|
||||
|| (reapply && desired.HasValue);
|
||||
|
||||
if (_activeInputTurnCommand is { } active && apply)
|
||||
{
|
||||
StopMotionAtPhysicsObjectBoundary(
|
||||
active,
|
||||
_activeInputTurnFromMouse
|
||||
? MouseAxisParameters(_activeInputTurnSpeed)
|
||||
: new());
|
||||
}
|
||||
|
||||
if (desired is { } command && apply)
|
||||
{
|
||||
DoMotionAtPhysicsObjectBoundary(
|
||||
command,
|
||||
fromMouse ? MouseAxisParameters(speed) : new());
|
||||
}
|
||||
|
||||
_activeInputTurnCommand = desired;
|
||||
_activeInputTurnSpeed = desired.HasValue ? speed : 0f;
|
||||
_activeInputTurnFromMouse = desired.HasValue && fromMouse;
|
||||
return apply;
|
||||
}
|
||||
|
||||
private static AcDream.Core.Physics.Motion.MovementParameters MouseAxisParameters(
|
||||
float speed) => new()
|
||||
{
|
||||
Speed = speed,
|
||||
SetHoldKey = false,
|
||||
HoldKeyToApply = HoldKey.Run,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CommandInterpreter::TakeControlFromServer</c>
|
||||
/// (0x006B32D0). A genuine user movement first terminates the server-owned
|
||||
/// movement state, marks subsequent movement autonomous, and lets the next
|
||||
/// input update re-apply every key that is still physically held.
|
||||
/// </summary>
|
||||
private void TakeControlFromServer(MovementInput? currentInput = null)
|
||||
{
|
||||
if (!_controlledByServer)
|
||||
return;
|
||||
|
||||
_controlledByServer = false;
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
StopCompletelyAtPhysicsObjectBoundary();
|
||||
_activeInputTurnCommand = null;
|
||||
_activeInputTurnSpeed = 0f;
|
||||
_activeInputTurnFromMouse = false;
|
||||
_activeInputSidestepCommand = null;
|
||||
_activeInputSidestepUsesRunHold = false;
|
||||
|
||||
// Retail follows StopCompletely with ApplyCurrentMovement. Our key
|
||||
// snapshot is supplied to Update, so clearing the edge history makes
|
||||
// that same held input re-enter through the normal DoMotion boundary.
|
||||
_prevForwardHeld = false;
|
||||
_prevBackwardHeld = false;
|
||||
_prevStrafeLeftHeld = false;
|
||||
_prevStrafeRightHeld = false;
|
||||
_prevTurnLeftHeld = false;
|
||||
_prevTurnRightHeld = false;
|
||||
_prevRunHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run;
|
||||
|
||||
// MouseLookHandler runs before the normal per-frame input edge pass.
|
||||
// When it is the action that takes control, replay the device levels
|
||||
// synchronously just as retail ApplyCurrentMovement does; otherwise a
|
||||
// StopCompletely would erase held movement while the edge history is
|
||||
// simultaneously advanced past it.
|
||||
if (currentInput is { } input)
|
||||
ApplyMouseLookToggleMovement(input, entering: _mouseLookActive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
|
||||
/// invokes <c>CommandInterpreter::MaybeStopCompletely</c> before building
|
||||
/// the attack. This removes a mouse-origin drift immediately and schedules
|
||||
/// the final authoritative heading ahead of the eventual attack send.
|
||||
/// </summary>
|
||||
public bool PrepareForAttackRequest()
|
||||
{
|
||||
// CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op
|
||||
// while an authoritative server movement owns the player.
|
||||
if (_controlledByServer)
|
||||
return false;
|
||||
|
||||
StopCompletelyAtPhysicsObjectBoundary();
|
||||
_activeInputTurnCommand = null;
|
||||
_activeInputTurnSpeed = 0f;
|
||||
_activeInputTurnFromMouse = false;
|
||||
_activeInputSidestepCommand = null;
|
||||
_activeInputSidestepUsesRunHold = false;
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetCharacterSkills(int runSkill, int jumpSkill)
|
||||
{
|
||||
_weenie.SetSkills(runSkill, jumpSkill);
|
||||
|
|
@ -400,6 +756,59 @@ public sealed class PlayerMovementController
|
|||
/// </summary>
|
||||
internal MotionInterpreter Motion => _motion;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::StopCompletely</c> (0x00510180) enters through
|
||||
/// <c>MovementManager::PerformMovement</c> (0x005240D0), not directly
|
||||
/// through <c>CMotionInterp::StopCompletely</c>. The facade's type-5 path
|
||||
/// performs the required zero-duration animation completion sweep after
|
||||
/// the interpreter has queued its matching pending-motion node.
|
||||
/// </summary>
|
||||
internal WeenieError StopCompletelyAtPhysicsObjectBoundary() =>
|
||||
Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.StopCompletely,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::DoMotion</c> (0x00510020) constructs a type-1
|
||||
/// <c>MovementStruct</c> carrying the original parameters pointer and
|
||||
/// routes it through <c>MovementManager::PerformMovement</c>. That outer
|
||||
/// boundary is observable: <c>CMotionInterp::PerformMovement</c>
|
||||
/// (0x00528E80) performs the synchronous completed-animation sweep after
|
||||
/// the motion dispatch.
|
||||
/// </summary>
|
||||
private WeenieError DoMotionAtPhysicsObjectBoundary(
|
||||
uint motion,
|
||||
AcDream.Core.Physics.Motion.MovementParameters parameters)
|
||||
{
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
return Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.RawCommand,
|
||||
Motion = motion,
|
||||
Params = parameters,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CPhysicsObj::StopMotion</c> (0x005100D0), the type-3 peer of
|
||||
/// <see cref="DoMotionAtPhysicsObjectBoundary"/>. Player input must use
|
||||
/// this facade; MoveToManager's private _DoMotion/_StopMotion path stays
|
||||
/// directly on CMotionInterp, matching retail.
|
||||
/// </summary>
|
||||
private WeenieError StopMotionAtPhysicsObjectBoundary(
|
||||
uint motion,
|
||||
AcDream.Core.Physics.Motion.MovementParameters parameters)
|
||||
{
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
return Movement.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.StopRawCommand,
|
||||
Motion = motion,
|
||||
Params = parameters,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>R4-V5: CONTACT transient-state bit (retail
|
||||
/// <c>transient_state & 1</c>) — the <see cref="MoveTo"/> manager's
|
||||
/// UseTime tick gate reads exactly this bit (decomp §6a @307781; a
|
||||
|
|
@ -426,7 +835,10 @@ public sealed class PlayerMovementController
|
|||
/// during a server moveto so apply_raw can't clobber the manager's
|
||||
/// dispatched motions with the idle raw state.</summary>
|
||||
internal void SetLastMoveWasAutonomous(bool autonomous)
|
||||
=> _body.LastMoveWasAutonomous = autonomous;
|
||||
{
|
||||
_body.LastMoveWasAutonomous = autonomous;
|
||||
_controlledByServer = !autonomous;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the player's AnimationSequencer current cycle velocity into
|
||||
|
|
@ -456,28 +868,95 @@ public sealed class PlayerMovementController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Called by the network outbound layer after every
|
||||
/// AutonomousPosition or MoveToState that carries the player's
|
||||
/// position. Resets the diff-driven heartbeat clock so the next
|
||||
/// `HeartbeatDue` evaluation requires either a fresh state change
|
||||
/// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
|
||||
/// Mirrors retail's SendPositionEvent at
|
||||
/// <c>acclient_2013_pseudo_c.txt:700345-700348</c> which updates
|
||||
/// `last_sent_position`, `last_sent_position_time`, AND
|
||||
/// `last_sent_contact_plane` after every send.
|
||||
/// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
|
||||
/// stamps only <c>last_sent_position_time</c>. The carried frame and
|
||||
/// contact plane remain those from the last AutonomousPosition packet.
|
||||
/// </summary>
|
||||
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||
uint cellId,
|
||||
System.Numerics.Plane contactPlane,
|
||||
float nowSeconds)
|
||||
public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false)
|
||||
{
|
||||
_lastSentPos = worldPos;
|
||||
_lastSentCellId = cellId;
|
||||
_lastSentTime = nowSeconds;
|
||||
if (mouseLookEvent)
|
||||
{
|
||||
_mouseMovementEventPending = false;
|
||||
_lastMouseMovementEventTime = nowSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current raw movement axes for an input-boundary
|
||||
/// SendMovementEvent. ToggleMouseLook sends synchronously, so the host
|
||||
/// cannot wait for the next physics update to reconstruct this state.
|
||||
/// </summary>
|
||||
public MovementResult CaptureMovementResult(bool mouseLookEvent)
|
||||
{
|
||||
var raw = _motion.RawState;
|
||||
uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand
|
||||
? null : raw.ForwardCommand;
|
||||
uint? sidestep = raw.SidestepCommand == RawMotionState.Default.SidestepCommand
|
||||
? null : raw.SidestepCommand;
|
||||
uint? turn = raw.TurnCommand == RawMotionState.Default.TurnCommand
|
||||
? null : raw.TurnCommand;
|
||||
return new MovementResult(
|
||||
Position: Position,
|
||||
RenderPosition: RenderPosition,
|
||||
CellId: CellId,
|
||||
IsOnGround: CanSendPositionEvent,
|
||||
MotionStateChanged: true,
|
||||
ForwardCommand: forward,
|
||||
SidestepCommand: sidestep,
|
||||
TurnCommand: turn,
|
||||
ForwardSpeed: forward.HasValue ? raw.ForwardSpeed : null,
|
||||
SidestepSpeed: sidestep.HasValue ? raw.SidestepSpeed : null,
|
||||
TurnSpeed: turn.HasValue ? raw.TurnSpeed : null,
|
||||
IsRunning: raw.CurrentHoldKey == HoldKey.Run,
|
||||
ShouldSendMovementEvent: true,
|
||||
TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run,
|
||||
SidestepUsesRunHold: sidestep.HasValue
|
||||
&& raw.SidestepHoldKey == HoldKey.Run,
|
||||
IsMouseLookMovementEvent: mouseLookEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
|
||||
/// stamps the send time, complete cell-local frame (origin and
|
||||
/// orientation), and contact plane after an AutonomousPosition packet.
|
||||
/// </summary>
|
||||
public void NotePositionSent(AcDream.Core.Physics.Position position,
|
||||
System.Numerics.Plane contactPlane,
|
||||
float nowSeconds)
|
||||
{
|
||||
_lastSentPosition = position;
|
||||
_lastSentContactPlane = contactPlane;
|
||||
_lastSentTime = nowSeconds;
|
||||
_lastSentInitialized = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Faithful port of retail
|
||||
/// <c>CommandInterpreter::ShouldSendPositionEvent</c> (0x006B45E0).
|
||||
/// Before the one-second interval expires, only a cell or contact-plane
|
||||
/// change sends. Once it expires, the complete frame is compared, including
|
||||
/// orientation; this is what publishes a stationary heading change.
|
||||
/// </summary>
|
||||
internal bool ShouldSendPositionEvent(
|
||||
AcDream.Core.Physics.Position currentPosition,
|
||||
System.Numerics.Plane currentContactPlane,
|
||||
float nowSeconds)
|
||||
{
|
||||
if (!_lastSentInitialized)
|
||||
return true;
|
||||
|
||||
bool cellChanged = _lastSentPosition.ObjCellId != currentPosition.ObjCellId;
|
||||
if ((_lastSentTime + HeartbeatInterval) >= nowSeconds)
|
||||
{
|
||||
return cellChanged
|
||||
|| !ApproxPlaneEqual(_lastSentContactPlane, currentContactPlane);
|
||||
}
|
||||
|
||||
return cellChanged
|
||||
|| !ApproxFrameEqual(_lastSentPosition.Frame, currentPosition.Frame);
|
||||
}
|
||||
|
||||
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
|
||||
// [cell-transit] probe fires from a single chokepoint. Both the
|
||||
// server-snap path (SetPosition) and the per-frame resolver path
|
||||
|
|
@ -538,7 +1017,17 @@ public sealed class PlayerMovementController
|
|||
// R3-W6: retail's teleport idle is a FULL stop (StopCompletely
|
||||
// 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity,
|
||||
// enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready).
|
||||
_motion.StopCompletely();
|
||||
StopCompletelyAtPhysicsObjectBoundary();
|
||||
_activeInputTurnCommand = null;
|
||||
_activeInputTurnSpeed = 0f;
|
||||
_activeInputTurnFromMouse = false;
|
||||
_activeInputSidestepCommand = null;
|
||||
_activeInputSidestepUsesRunHold = false;
|
||||
_mouseLookActive = false;
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
_mouseMovementEventCandidate = false;
|
||||
_mouseMovementEventPending = false;
|
||||
// R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
|
||||
// UnStick (@0x00514eee) right after the moveto cancel: a teleport
|
||||
// tears down any active stick. (StopInterpolating/UnConstrain have no
|
||||
|
|
@ -556,6 +1045,7 @@ public sealed class PlayerMovementController
|
|||
_prevTurnLeftHeld = false;
|
||||
_prevTurnRightHeld = false;
|
||||
_prevRunHeld = false;
|
||||
_hasInputSnapshot = false;
|
||||
|
||||
// Reset physics clock so any subsequent update_object calls start fresh.
|
||||
_body.LastUpdateTime = 0.0;
|
||||
|
|
@ -732,8 +1222,31 @@ public sealed class PlayerMovementController
|
|||
// InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled),
|
||||
// the retail user-input cancel chain (TS-36 retired; set_hold_run's
|
||||
// interrupt:true is the same chain for the Shift edge).
|
||||
bool motionEdgeFired = false;
|
||||
if (!_hasInputSnapshot)
|
||||
{
|
||||
// GameWindow supplies the configured default walk/run mode as a
|
||||
// level, not a physical key edge. Seed that first snapshot without
|
||||
// claiming movement control; a simultaneous directional edge below
|
||||
// still takes control normally.
|
||||
_hasInputSnapshot = true;
|
||||
_prevRunHeld = input.Run;
|
||||
_prevRunHold = input.Run;
|
||||
_motion.set_hold_run(input.Run, interrupt: false);
|
||||
}
|
||||
|
||||
bool motionEdgeFired = false;
|
||||
bool movementEventRequested = false;
|
||||
{
|
||||
bool userInputEdge = input.Run != _prevRunHeld
|
||||
|| input.Forward != _prevForwardHeld
|
||||
|| input.Backward != _prevBackwardHeld
|
||||
|| input.StrafeLeft != _prevStrafeLeftHeld
|
||||
|| input.StrafeRight != _prevStrafeRightHeld
|
||||
|| input.TurnLeft != _prevTurnLeftHeld
|
||||
|| input.TurnRight != _prevTurnRightHeld;
|
||||
if (userInputEdge)
|
||||
TakeControlFromServer();
|
||||
|
||||
var p = new AcDream.Core.Physics.Motion.MovementParameters();
|
||||
|
||||
// Shift/run edge FIRST — retail's set_hold_run re-applies
|
||||
|
|
@ -752,37 +1265,84 @@ public sealed class PlayerMovementController
|
|||
// the old level-triggered build, which always reflected the
|
||||
// currently-held set).
|
||||
if (input.Forward && !_prevForwardHeld)
|
||||
{ _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; }
|
||||
{ DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; }
|
||||
else if (input.Backward && !_prevBackwardHeld && !input.Forward)
|
||||
{ _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
|
||||
{ DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
|
||||
if (!input.Forward && _prevForwardHeld)
|
||||
{
|
||||
if (input.Backward)
|
||||
_motion.DoMotion(MotionCommand.WalkBackward, p);
|
||||
DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p);
|
||||
else
|
||||
_motion.StopMotion(MotionCommand.WalkForward, p);
|
||||
StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p);
|
||||
motionEdgeFired = true;
|
||||
}
|
||||
else if (!input.Backward && _prevBackwardHeld && !input.Forward)
|
||||
{ _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
|
||||
{ StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
|
||||
|
||||
// Sidestep channel.
|
||||
if (input.StrafeRight && !_prevStrafeRightHeld)
|
||||
{ _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
|
||||
else if (input.StrafeLeft && !_prevStrafeLeftHeld)
|
||||
{ _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; }
|
||||
else if (!input.StrafeRight && !input.StrafeLeft
|
||||
&& (_prevStrafeRightHeld || _prevStrafeLeftHeld))
|
||||
{ _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
|
||||
// Sidestep channel. CameraInstantMouseLook remaps held keyboard
|
||||
// TurnLeft/TurnRight into this channel while MMB is active.
|
||||
(uint? desiredSidestep, bool sidestepUsesRunHold) =
|
||||
DesiredInputSidestep(input, _mouseLookActive);
|
||||
if (ApplyInputSidestep(desiredSidestep, sidestepUsesRunHold))
|
||||
motionEdgeFired = true;
|
||||
|
||||
// Turn channel.
|
||||
if (input.TurnRight && !_prevTurnRightHeld)
|
||||
{ _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; }
|
||||
else if (input.TurnLeft && !_prevTurnLeftHeld)
|
||||
{ _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; }
|
||||
else if (!input.TurnRight && !input.TurnLeft
|
||||
&& (_prevTurnRightHeld || _prevTurnLeftHeld))
|
||||
{ _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; }
|
||||
// Everything above is an ordinary input edge and therefore owns
|
||||
// an immediate SendMovementEvent. Mouse-origin turn updates below
|
||||
// deliberately do not: CameraSet reports them on its separate
|
||||
// half-second cadence.
|
||||
movementEventRequested = motionEdgeFired;
|
||||
|
||||
// Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse
|
||||
// input through CommandInterpreter::MovePlayer as TurnLeft /
|
||||
// TurnRight, so character yaw, physics orientation, raw wire state,
|
||||
// and ACE authority all share this one MotionInterpreter owner.
|
||||
bool keyboardTurnEdge = input.TurnRight != _prevTurnRightHeld
|
||||
|| input.TurnLeft != _prevTurnLeftHeld;
|
||||
if (keyboardTurnEdge)
|
||||
movementEventRequested = true;
|
||||
|
||||
uint? desiredTurnCommand = _mouseLookActive
|
||||
? null
|
||||
: input.TurnRight
|
||||
? MotionCommand.TurnRight
|
||||
: input.TurnLeft
|
||||
? MotionCommand.TurnLeft
|
||||
: null;
|
||||
float desiredTurnSpeed = 1f;
|
||||
bool desiredTurnFromMouse = false;
|
||||
|
||||
if (_mouseLookActive && _mouseTurnSamplePending)
|
||||
{
|
||||
float adjustment = _mouseTurnAdjustment;
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
|
||||
if (MathF.Abs(adjustment) >= MouseTurnDeadZone)
|
||||
{
|
||||
desiredTurnCommand = adjustment < 0f
|
||||
? MotionCommand.TurnRight
|
||||
: MotionCommand.TurnLeft;
|
||||
desiredTurnSpeed = MathF.Min(
|
||||
MathF.Abs(adjustment) * MouseTurnSpeedScale,
|
||||
MouseTurnMaximumSpeed);
|
||||
desiredTurnFromMouse = true;
|
||||
}
|
||||
}
|
||||
else if (_mouseLookActive && _activeInputTurnFromMouse)
|
||||
{
|
||||
// Eventless render frames do not synthesize a zero. Preserve
|
||||
// the last Rotate motion until the delayed retail idle handler
|
||||
// explicitly calls StopMouseDrift.
|
||||
desiredTurnCommand = _activeInputTurnCommand;
|
||||
desiredTurnSpeed = _activeInputTurnSpeed;
|
||||
desiredTurnFromMouse = true;
|
||||
}
|
||||
|
||||
if (ApplyInputTurn(
|
||||
desiredTurnCommand,
|
||||
desiredTurnSpeed,
|
||||
desiredTurnFromMouse))
|
||||
motionEdgeFired = true;
|
||||
|
||||
// Retail stores last_move_was_autonomous = 1 at the CPhysicsObj
|
||||
// INPUT boundary — CPhysicsObj::DoMotion @00510030 /
|
||||
|
|
@ -796,7 +1356,10 @@ public sealed class PlayerMovementController
|
|||
// routes the per-tick pump's A3 dual dispatch (raw for
|
||||
// input-driven motion, interpreted for server/manager-driven).
|
||||
if (motionEdgeFired)
|
||||
{
|
||||
_body.LastMoveWasAutonomous = true;
|
||||
_controlledByServer = false;
|
||||
}
|
||||
}
|
||||
|
||||
_prevForwardHeld = input.Forward;
|
||||
|
|
@ -828,14 +1391,14 @@ public sealed class PlayerMovementController
|
|||
// and TurnToHeading nodes dispatch TurnRight/TurnLeft through
|
||||
// _DoMotion into the SAME interpreted turn state, so this one
|
||||
// integrator rotates the player for both input and moveto turns.
|
||||
// Mouse turn stays a direct Yaw delta (deliberately generates no
|
||||
// turn command — avoids MoveToState spam from mouse jitter).
|
||||
// Mouse-origin turns now enter the same interpreted turn state above.
|
||||
// Packet cadence is carried separately by ShouldSendMovementEvent, so
|
||||
// local mouse sampling cannot flood MoveToState.
|
||||
if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
|
||||
{
|
||||
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
|
||||
* _motion.InterpretedState.TurnSpeed * dt;
|
||||
}
|
||||
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
|
||||
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
|
||||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||||
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
||||
|
|
@ -1192,35 +1755,24 @@ public sealed class PlayerMovementController
|
|||
outForwardSpeed = 1.0f;
|
||||
}
|
||||
|
||||
// Strafe: retail uses speed=1.0 for SideStep (see holtburger
|
||||
// common.rs::locomotion_command_for_state). 0.5 was our earlier guess
|
||||
// and made strafing feel lethargic; the retail feel is full-speed
|
||||
// sidestep matching the walk forward pace.
|
||||
if (input.StrafeRight)
|
||||
// Source the outbound axis from the canonical input-owned raw channel.
|
||||
// CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this
|
||||
// same channel, so reconstructing it from only physical strafe keys
|
||||
// would send ACE an idle sidestep while the local body moved.
|
||||
if (_activeInputSidestepCommand is { } activeInputSidestep)
|
||||
{
|
||||
outSidestepCmd = MotionCommand.SideStepRight;
|
||||
outSidestepSpeed = 1.0f;
|
||||
}
|
||||
else if (input.StrafeLeft)
|
||||
{
|
||||
outSidestepCmd = MotionCommand.SideStepLeft;
|
||||
outSidestepSpeed = 1.0f;
|
||||
outSidestepCmd = activeInputSidestep;
|
||||
outSidestepSpeed = _motion.RawState.SidestepSpeed;
|
||||
}
|
||||
|
||||
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
|
||||
// directly to Yaw above and doesn't generate a turn command — if it
|
||||
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft
|
||||
// every frame, causing stateChanged=True on every frame and flooding
|
||||
// the server with MoveToState spam.
|
||||
if (input.TurnRight)
|
||||
// Turn commands come from the current user-owned turn channel. This is
|
||||
// keyboard A/D or CameraSet's transient mouse-origin turn, never a
|
||||
// server-owned MoveTo turn. The raw speed is the exact value passed to
|
||||
// MotionInterpreter before hold-run adjustment.
|
||||
if (_activeInputTurnCommand is { } activeInputTurn)
|
||||
{
|
||||
outTurnCmd = MotionCommand.TurnRight;
|
||||
outTurnSpeed = 1.0f;
|
||||
}
|
||||
else if (input.TurnLeft)
|
||||
{
|
||||
outTurnCmd = MotionCommand.TurnLeft;
|
||||
outTurnSpeed = 1.0f;
|
||||
outTurnCmd = activeInputTurn;
|
||||
outTurnSpeed = _activeInputTurnSpeed;
|
||||
}
|
||||
|
||||
// ── 7. Detect motion state change ─────────────────────────────────────
|
||||
|
|
@ -1245,6 +1797,15 @@ public sealed class PlayerMovementController
|
|||
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|
||||
|| runHold != _prevRunHold
|
||||
|| motionEdgeFired;
|
||||
|
||||
bool mouseMovementEventDue = _mouseMovementEventPending
|
||||
|| (_mouseMovementEventCandidate
|
||||
&& _simTimeSeconds > _lastMouseMovementEventTime + MouseMovementEventInterval);
|
||||
_mouseMovementEventCandidate = false;
|
||||
if (mouseMovementEventDue)
|
||||
_mouseMovementEventPending = true;
|
||||
bool shouldSendMovementEvent = movementEventRequested || mouseMovementEventDue;
|
||||
|
||||
_prevForwardCmd = outForwardCmd;
|
||||
_prevSidestepCmd = outSidestepCmd;
|
||||
_prevTurnCmd = outTurnCmd;
|
||||
|
|
@ -1258,63 +1819,11 @@ public sealed class PlayerMovementController
|
|||
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
|
||||
}
|
||||
|
||||
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
|
||||
// 2026-05-16 (closes #74) — retail-faithful AP cadence per
|
||||
// CommandInterpreter::ShouldSendPositionEvent at
|
||||
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
|
||||
//
|
||||
// Branch 1 — interval NOT yet elapsed (< 1 sec since last
|
||||
// send): send only if cell changed OR contact-plane changed
|
||||
// (mid-walk events that matter — stair / hill / cell cross).
|
||||
//
|
||||
// Branch 2 — interval HAS elapsed (>= 1 sec): send only if
|
||||
// cell OR position frame changed. Truly idle = no send
|
||||
// (retail's `last_sent.frame == player.frame` check at
|
||||
// acclient_2013_pseudo_c.txt:700248-700265).
|
||||
//
|
||||
// SendPositionEvent (line 700327) gates the actual send on
|
||||
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
|
||||
// OnWalkable both set. We mirror that gate so airborne and
|
||||
// wall-contact-without-walkable suppress AP entirely;
|
||||
// MoveToState carries jump/fall snapshots while airborne.
|
||||
//
|
||||
// Effective rates:
|
||||
// Truly idle (grounded, no movement) : 0 Hz
|
||||
// Smooth movement (no cell/plane changes) : ~1 Hz (interval)
|
||||
// Cell crossings + stair/hill steps : per-event
|
||||
// Airborne : 0 Hz
|
||||
//
|
||||
// Bootstrap: when NotePositionSent has never been called
|
||||
// (_lastSentInitialized=false), every state-changed branch is
|
||||
// forced true so the first AP gets a chance to fire.
|
||||
|
||||
bool intervalElapsed = !_lastSentInitialized
|
||||
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||
|
||||
bool cellChanged = !_lastSentInitialized
|
||||
|| _lastSentCellId != CellId;
|
||||
bool planeChanged = !_lastSentInitialized
|
||||
|| !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane);
|
||||
bool frameChanged = !_lastSentInitialized
|
||||
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||
|
||||
bool sendThisFrame = intervalElapsed
|
||||
? (cellChanged || frameChanged)
|
||||
: (cellChanged || planeChanged);
|
||||
|
||||
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||
// set simultaneously, NOT a bitwise-OR mask test.
|
||||
HeartbeatDue = CanSendPositionEvent && sendThisFrame;
|
||||
|
||||
// R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
|
||||
// run pacing now comes from the ported machinery itself
|
||||
// (apply_run_to_command scales the interpreted speed by my_run_rate;
|
||||
// the DefaultSink dispatch plays the cycle at that speed — the same
|
||||
// source remotes use).
|
||||
bool anyDirectional = input.Forward || input.Backward
|
||||
|| input.StrafeLeft || input.StrafeRight;
|
||||
|
||||
// R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED —
|
||||
// the MoveToManager's own aux-turn steering and TurnToHeading nodes
|
||||
// dispatch TurnRight/TurnLeft through _DoMotion (the retail
|
||||
|
|
@ -1333,34 +1842,36 @@ public sealed class PlayerMovementController
|
|||
ForwardSpeed: outForwardSpeed,
|
||||
SidestepSpeed: outSidestepSpeed,
|
||||
TurnSpeed: outTurnSpeed,
|
||||
// Run hold-key applies to ANY active directional axis, not just
|
||||
// forward (per holtburger's build_motion_state_raw_motion_state:
|
||||
// "uses the same value for every active per-axis hold key"). The
|
||||
// pre-fix condition `input.Run && input.Forward` made strafe-run
|
||||
// and backward-run incorrectly broadcast as walk to observers,
|
||||
// who then animated walk + dead-reckoned at walk speed while the
|
||||
// server position moved at run speed — visible as observer lag.
|
||||
IsRunning: input.Run && anyDirectional,
|
||||
// CurrentHoldKey is a level independent of active axes; every
|
||||
// active ordinary axis inherits it during wire projection. This
|
||||
// preserves idle run/walk toggles as well as strafe/backward run.
|
||||
IsRunning: input.Run,
|
||||
JustLanded: justLanded,
|
||||
JumpExtent: outJumpExtent,
|
||||
JumpVelocity: outJumpVelocity);
|
||||
JumpVelocity: outJumpVelocity,
|
||||
ShouldSendMovementEvent: shouldSendMovementEvent,
|
||||
TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue,
|
||||
SidestepUsesRunHold: _activeInputSidestepUsesRunHold
|
||||
&& outSidestepCmd.HasValue,
|
||||
IsMouseLookMovementEvent: mouseMovementEventDue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Position-equality test for diff-driven AP cadence.
|
||||
/// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263
|
||||
/// which is essentially exact float comparison after a memcmp of
|
||||
/// the frame struct. For floating-point safety we use a tiny epsilon
|
||||
/// — sub-millimeter — that's well below any movement we'd want to
|
||||
/// suppress sending for.
|
||||
/// Retail <c>Frame::is_equal</c> (0x00424C30) compares both the origin
|
||||
/// and all four quaternion components with a 0.0002-unit epsilon. It does
|
||||
/// not treat the mathematically equivalent <c>q</c>/<c>-q</c> pair as the
|
||||
/// same stored frame.
|
||||
/// </summary>
|
||||
private static bool ApproxPositionEqual(
|
||||
System.Numerics.Vector3 a, System.Numerics.Vector3 b)
|
||||
private static bool ApproxFrameEqual(CellFrame a, CellFrame b)
|
||||
{
|
||||
const float Epsilon = 0.001f; // 1 mm
|
||||
return MathF.Abs(a.X - b.X) < Epsilon
|
||||
&& MathF.Abs(a.Y - b.Y) < Epsilon
|
||||
&& MathF.Abs(a.Z - b.Z) < Epsilon;
|
||||
const float Epsilon = 0.000199999995f;
|
||||
return MathF.Abs(a.Origin.X - b.Origin.X) <= Epsilon
|
||||
&& MathF.Abs(a.Origin.Y - b.Origin.Y) <= Epsilon
|
||||
&& MathF.Abs(a.Origin.Z - b.Origin.Z) <= Epsilon
|
||||
&& MathF.Abs(a.Orientation.W - b.Orientation.W) < Epsilon
|
||||
&& MathF.Abs(a.Orientation.X - b.Orientation.X) < Epsilon
|
||||
&& MathF.Abs(a.Orientation.Y - b.Orientation.Y) < Epsilon
|
||||
&& MathF.Abs(a.Orientation.Z - b.Orientation.Z) < Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1375,11 +1886,10 @@ public sealed class PlayerMovementController
|
|||
private static bool ApproxPlaneEqual(
|
||||
System.Numerics.Plane a, System.Numerics.Plane b)
|
||||
{
|
||||
const float NormalEpsilon = 1e-4f;
|
||||
const float DistanceEpsilon = 0.001f;
|
||||
return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon
|
||||
&& MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon
|
||||
&& MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon
|
||||
&& MathF.Abs(a.D - b.D) < DistanceEpsilon;
|
||||
const float Epsilon = 0.000199999995f;
|
||||
return MathF.Abs(a.Normal.X - b.Normal.X) <= Epsilon
|
||||
&& MathF.Abs(a.Normal.Y - b.Normal.Y) <= Epsilon
|
||||
&& MathF.Abs(a.Normal.Z - b.Normal.Z) <= Epsilon
|
||||
&& MathF.Abs(a.D - b.D) < Epsilon;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -570,10 +570,10 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
return new PaletteOverride(spawn.BasePaletteId ?? 0, ranges);
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObject item, uint _, uint __)
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (item.CurrentlyEquippedLocation == EquipMask.None)
|
||||
Remove(item.ObjectId);
|
||||
if (move.Current.EquipLocation == EquipMask.None)
|
||||
Remove(move.ItemId);
|
||||
}
|
||||
|
||||
private void OnMoveRolledBack(ClientObject item)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -222,13 +222,13 @@ internal sealed class AutoWieldController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
private void OnObjectMoved(ClientObject item, uint _, uint newContainerId)
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (_pendingSwitch is not { } pending
|
||||
|| pending.BlockingItemId == 0
|
||||
|| item.ObjectId != pending.BlockingItemId
|
||||
|| newContainerId != _playerGuid()
|
||||
|| item.CurrentlyEquippedLocation != EquipMask.None)
|
||||
|| move.ItemId != pending.BlockingItemId
|
||||
|| move.Current.ContainerId != _playerGuid()
|
||||
|| move.Current.EquipLocation != EquipMask.None)
|
||||
return;
|
||||
|
||||
// Clear before re-entry: the second AutoWield pass must see the newly
|
||||
|
|
@ -238,10 +238,10 @@ internal sealed class AutoWieldController : IDisposable
|
|||
TryWield(requested, pending.RequestedMask);
|
||||
}
|
||||
|
||||
private void OnWieldConfirmed(ClientObject item)
|
||||
private void OnWieldConfirmed(uint itemId)
|
||||
{
|
||||
if (_pendingSwitch is { BlockingItemId: 0 } pending
|
||||
&& item.ObjectId == pending.RequestedItemId)
|
||||
&& itemId == pending.RequestedItemId)
|
||||
_pendingSwitch = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
// Rebuild on any change to the player's possessions.
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
|
|
@ -239,8 +240,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
}
|
||||
if (Concerns(o)) Populate();
|
||||
}
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
if ((move.Item is { } item && Concerns(item))
|
||||
|| move.Previous.ContainerId == player
|
||||
|| move.Current.ContainerId == player
|
||||
|| move.Previous.WielderId == player
|
||||
|| move.Current.WielderId == player)
|
||||
Populate();
|
||||
}
|
||||
private void OnContainerContentsReplaced(uint containerId)
|
||||
{
|
||||
if (containerId == EffectiveOpen() || containerId == _playerGuid())
|
||||
Populate();
|
||||
}
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
private void OnObjectsCleared() => Populate();
|
||||
|
|
@ -702,6 +716,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_disposed = true;
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
|
|
|
|||
|
|
@ -211,8 +211,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
else if (Concerns(o))
|
||||
Populate();
|
||||
}
|
||||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
if ((move.Item is { } item && Concerns(item))
|
||||
|| move.Previous.ContainerId == player
|
||||
|| move.Current.ContainerId == player
|
||||
|| move.Previous.WielderId == player
|
||||
|| move.Current.WielderId == player)
|
||||
Populate();
|
||||
}
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
|
||||
private void OnObjectsCleared()
|
||||
{
|
||||
|
|
@ -224,8 +232,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||
/// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always
|
||||
/// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by
|
||||
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to
|
||||
/// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag).</summary>
|
||||
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the
|
||||
/// complete old/new retail placement for transitions that satisfy neither after mutation.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
{
|
||||
uint p = _playerGuid();
|
||||
|
|
|
|||
|
|
@ -216,13 +216,17 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Populate();
|
||||
}
|
||||
|
||||
private void OnRepositoryObjectMoved(ClientObject obj, uint oldContainer, uint newContainer)
|
||||
private void OnRepositoryObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||||
if (obj.ObjectId == _ammoObjectId
|
||||
|| (player != 0 && (oldContainer == player || newContainer == player)))
|
||||
if (move.ItemId == _ammoObjectId
|
||||
|| (player != 0
|
||||
&& (move.Previous.ContainerId == player
|
||||
|| move.Current.ContainerId == player
|
||||
|| move.Previous.WielderId == player
|
||||
|| move.Current.WielderId == player)))
|
||||
RefreshAmmo();
|
||||
if (IsShortcutGuid(obj.ObjectId))
|
||||
if (IsShortcutGuid(move.ItemId))
|
||||
Populate();
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +242,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
return obj.ObjectId == _ammoObjectId
|
||||
|| (player != 0 && obj.ObjectId == player)
|
||||
|| (player != 0
|
||||
&& obj.ContainerId == player
|
||||
&& (obj.WielderId == player || obj.ContainerId == player)
|
||||
&& (obj.CurrentlyEquippedLocation
|
||||
& (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0);
|
||||
}
|
||||
|
|
@ -246,13 +250,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
private void RefreshAmmo()
|
||||
{
|
||||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||||
var placements = new List<ClientObject>();
|
||||
if (player != 0)
|
||||
{
|
||||
foreach (uint objectId in _repo.GetContents(player))
|
||||
if (_repo.Get(objectId) is { } item)
|
||||
placements.Add(item);
|
||||
}
|
||||
IReadOnlyList<ClientObject> placements = player != 0
|
||||
? _repo.GetEquippedBy(player)
|
||||
: Array.Empty<ClientObject>();
|
||||
|
||||
ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(placements);
|
||||
_ammoObjectId = result.ObjectId;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue