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:
Erik 2026-07-15 08:19:23 +02:00
parent b26f84cc69
commit 7b7ffcd278
36 changed files with 3884 additions and 761 deletions

View file

@ -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();
}

View file

@ -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 &amp; 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;
}
}

View file

@ -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

View file

@ -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;
}

View file

@ -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;

View file

@ -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();

View file

@ -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;

View file

@ -305,33 +305,29 @@ public static class GameEventWiring
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return;
uint wielderGuid = p.Value.WielderGuid != 0u
? p.Value.WielderGuid
: (playerGuid?.Invoke() ?? 0u);
items.MoveItem(
uint wielderGuid = playerGuid?.Invoke() ?? 0u;
items.ApplyConfirmedServerWield(
p.Value.ItemGuid,
newContainerId: wielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmWield(p.Value.ItemGuid); // Exact IR_WIELD completion + optimistic reconciliation.
wielderGuid,
(AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
});
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is null) return;
items.MoveItem(
items.ApplyConfirmedServerMove(
p.Value.ItemGuid,
p.Value.ContainerGuid,
newWielderId: 0u,
newSlot: (int)p.Value.Placement,
containerTypeHint: p.Value.ContainerType);
items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move
});
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
// opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and
// record the new entries in order (ContainerSlot = index — ACE writes entries
// OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController)
// repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents.
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
// every UI consumer repaints from the same snapshot. Retail: ClientUISystem::OnViewContents.
dispatcher.Register(GameEventType.ViewContents, e =>
{
var p = GameEvents.ParseViewContents(e.Payload.Span);
@ -352,8 +348,10 @@ public static class GameEventWiring
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
if (guid is not null)
{
items.MoveItem(guid.Value, newContainerId: 0u);
items.ConfirmMove(guid.Value);
items.ApplyConfirmedServerMove(
guid.Value,
newContainerId: 0u,
newWielderId: 0u);
}
});
@ -596,20 +594,35 @@ public static class GameEventWiring
entries[i] = new ContainerContentEntry(
p.Value.Inventory[i].Guid,
p.Value.Inventory[i].ContainerType);
items.ReplaceContents(ownerGuid, entries);
items.InitializeInventoryManifest(ownerGuid, entries);
}
else
{
foreach (var inv in p.Value.Inventory)
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
}
foreach (var eq in p.Value.Equipped)
if (ownerGuid != 0u)
{
items.RecordMembership(
eq.Guid,
containerId: ownerGuid,
equip: (EquipMask)eq.EquipLocation,
priority: eq.Priority);
var equipment = new EquipmentManifestEntry[p.Value.Equipped.Count];
for (int i = 0; i < equipment.Length; i++)
{
var eq = p.Value.Equipped[i];
equipment[i] = new EquipmentManifestEntry(
eq.Guid,
(EquipMask)eq.EquipLocation,
eq.Priority);
}
items.InitializeEquipmentManifest(ownerGuid, equipment);
}
else
{
foreach (var eq in p.Value.Equipped)
{
items.RecordMembership(
eq.Guid,
equip: (EquipMask)eq.EquipLocation,
priority: eq.Priority);
}
}
// D.5.1 Task 4: forward shortcut bar entries to the caller so the

View file

@ -350,19 +350,14 @@ public static class GameEvents
/// <summary>0x0023 WieldObject: server-driven equip.</summary>
public readonly record struct WieldObject(
uint ItemGuid,
uint EquipLoc,
uint WielderGuid);
uint EquipLoc);
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
uint wielderGuid = payload.Length >= 12
? BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))
: 0u;
return new WieldObject(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
wielderGuid);
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.

View file

@ -100,6 +100,7 @@ public static class WeenieErrorMessages
[0x0498] = "You have moved too far!", // YouHaveMovedTooFar
[0x0499] = "That is not a valid destination!", // TeleToInvalidPosition
[0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.",
[0x0550] = "Out of Range!", // MissileOutOfRange
// Fellowship
[0x0528] = "The fellowship is locked; you cannot open locked fellowships.",

View file

@ -10,6 +10,12 @@ namespace AcDream.Core.Items;
/// </summary>
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
/// <summary>One login-time PlayerDescription equipment placement.</summary>
public readonly record struct EquipmentManifestEntry(
uint Guid,
EquipMask EquipLocation,
uint Priority);
/// <summary>
/// Server rejection of an inventory move request. <paramref name="RolledBack"/>
/// is true when the table restored a locally optimistic move; false for
@ -21,6 +27,35 @@ public readonly record struct MoveRequestFailure(
uint WeenieError,
bool RolledBack);
/// <summary>
/// One side of retail's <c>ServerSaysMoveItem_s</c> placement transition.
/// Container, slot, wielder, and equip location are captured before listeners
/// run so they never have to infer the old state from the already-mutated item.
/// </summary>
public readonly record struct ClientObjectPlacement(
uint ContainerId,
int ContainerSlot,
uint WielderId,
EquipMask EquipLocation)
{
public static ClientObjectPlacement From(ClientObject item) => new(
item.ContainerId,
item.ContainerSlot,
item.WielderId,
item.CurrentlyEquippedLocation);
}
/// <summary>
/// Retail-shaped inventory placement notice. Named retail
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
/// publishes both complete placements after applying the new state.
/// </summary>
public readonly record struct ClientObjectMove(
uint ItemId,
ClientObject? Item,
ClientObjectPlacement Previous,
ClientObjectPlacement Current);
public enum ClientObjectRemovalReason
{
Ordinary,
@ -72,21 +107,29 @@ public sealed class ClientObjectTable
private readonly ConcurrentDictionary<uint, ClientObject> _objects = new();
private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
private readonly Dictionary<uint, List<uint>> _equipmentIndex = new();
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
/// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? ObjectAdded;
/// <summary>
/// Fires when an object's container / slot changes (moved between
/// packs, equipped, unequipped, dropped on ground). Old and new
/// container ids are 0 if origin or destination is "world" / "nowhere".
/// Fires after a complete placement notice (moved between packs,
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/>
/// is null when retail publishes the notice before CreateObject resolves
/// the GUID; the old/new placement remains authoritative notice data.
/// </summary>
public event Action<ClientObject, uint, uint>? ObjectMoved;
public event Action<ClientObjectMove>? ObjectMoved;
/// <summary>
/// Fires after retail ViewContents replaces a container's ordered member
/// lists. This is a list-projection change, not an item ownership move.
/// </summary>
public event Action<uint>? ContainerContentsReplaced;
/// <summary>
/// Fires after an optimistic inventory/equipment move is rejected and
@ -103,7 +146,7 @@ public sealed class ClientObjectTable
/// rollback bookkeeping: retail clears IR_WIELD on the matching server
/// response even when separate reconciliation state exists for the item.
/// </summary>
public event Action<ClientObject>? WieldConfirmed;
public event Action<uint>? WieldConfirmed;
/// <summary>
/// Fires for every InventoryServerSaveFailed response, including requests
@ -164,8 +207,12 @@ public sealed class ClientObjectTable
public void AddOrUpdate(ClientObject item)
{
ArgumentNullException.ThrowIfNull(item);
bool existed = _objects.ContainsKey(item.ObjectId);
bool existed = _objects.TryGetValue(item.ObjectId, out ClientObject? prior);
ClientObjectPlacement previous = prior is null
? default
: ClientObjectPlacement.From(prior);
_objects[item.ObjectId] = item;
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item));
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
@ -180,25 +227,156 @@ public sealed class ClientObjectTable
}
/// <summary>
/// Handle a server-driven move — called from
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
/// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the
/// unwield path manages it (a wielded item is modeled as contained-by-the-wielder),
/// so RollbackMove restores full pre-move state through this method alone.
/// Move the local projection of an item without changing authoritative
/// WielderId. Optimistic inventory requests use this path because retail
/// UIAttemptWield sends the request without rewriting public weenie fields.
/// </summary>
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
return ApplyPlacement(
item,
new ClientObjectPlacement(
newContainerId,
newSlot,
item.WielderId,
newEquipLocation),
containerTypeHint);
}
uint oldContainer = item.ContainerId;
item.ContainerId = newContainerId;
item.ContainerSlot = newSlot;
item.CurrentlyEquippedLocation = newEquipLocation;
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint;
Reindex(item, oldContainer);
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
/// <summary>
/// Apply an authoritative server move, including the item's wielder
/// ownership. Retail <c>ACCWeenieObject::ServerSaysMoveItem @
/// 0x0058DBB0</c> updates <c>_containerID</c>, <c>_wielderID</c>, and
/// <c>_location</c> in one callback. Keeping the old wielder after an
/// unwield makes <c>DetermineUseResult @ 0x00588460</c> classify a bow in
/// the backpack as already wielded, so a later double click sends no
/// request.
/// </summary>
public bool ApplyServerMove(
uint itemId,
uint newContainerId,
uint newWielderId,
int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None,
uint? containerTypeHint = null)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
return ApplyPlacement(
item,
new ClientObjectPlacement(
newContainerId,
newSlot,
newWielderId,
newEquipLocation),
containerTypeHint);
}
private bool ApplyPlacement(
ClientObject item,
ClientObjectPlacement current,
uint? containerTypeHint = null)
{
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
item.ObjectId,
current.ContainerId);
item.ContainerId = current.ContainerId;
item.ContainerSlot = current.ContainerSlot;
item.WielderId = current.WielderId;
item.CurrentlyEquippedLocation = current.EquipLocation;
if (containerTypeHint is { } hint)
item.ContainerTypeHint = hint;
Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous);
PublishContainerContentsChanges(changedContainers);
return true;
}
private void PublishPlacementChange(
ClientObject item,
ClientObjectPlacement previous)
{
ClientObjectPlacement current = ClientObjectPlacement.From(item);
UpdateEquipmentIndex(item.ObjectId, previous, current);
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current));
}
/// <summary>
/// Applies a server-confirmed container/world move. The matching pending
/// request is reconciled before <see cref="ObjectMoved"/> is published,
/// matching retail <c>ServerSaysMoveItem</c>: a reentrant listener may start
/// a new request without the old confirmation consuming it afterward.
/// </summary>
public bool ApplyConfirmedServerMove(
uint itemId,
uint newContainerId,
uint newWielderId,
int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None,
uint? containerTypeHint = null)
{
ConfirmMove(itemId);
if (ApplyServerMove(
itemId,
newContainerId,
newWielderId,
newSlot,
newEquipLocation,
containerTypeHint))
{
return true;
}
ObjectMoved?.Invoke(new ClientObjectMove(
itemId,
Item: null,
Previous: default,
Current: new ClientObjectPlacement(
newContainerId,
newSlot,
newWielderId,
newEquipLocation)));
return false;
}
/// <summary>
/// Applies retail WieldObject (0x0023). The packet has only item and
/// location; the local player is the wielder, while ContainerId is zero.
/// Pending reconciliation precedes notifications as in
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>.
/// </summary>
public bool ApplyConfirmedServerWield(
uint itemId,
uint wielderId,
EquipMask equipLocation)
{
ConfirmMove(itemId);
var placement = new ClientObjectPlacement(
ContainerId: 0u,
ContainerSlot: 0,
WielderId: wielderId,
EquipLocation: equipLocation);
if (!_objects.TryGetValue(itemId, out ClientObject? item))
{
ObjectMoved?.Invoke(new ClientObjectMove(
itemId,
Item: null,
Previous: default,
Current: placement));
WieldConfirmed?.Invoke(itemId);
return false;
}
if (!ApplyPlacement(
item,
placement))
{
return false;
}
WieldConfirmed?.Invoke(itemId);
return true;
}
@ -209,10 +387,9 @@ public sealed class ClientObjectTable
private void RecordPending(uint itemId, ClientObject item)
{
if (_pendingMoves.TryGetValue(itemId, out var p))
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
_pendingMoves[itemId] = (p.placement, p.outstanding + 1);
else
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
item.CurrentlyEquippedLocation, 1);
_pendingMoves[itemId] = (ClientObjectPlacement.From(item), 1);
}
/// <summary>
@ -234,9 +411,13 @@ public sealed class ClientObjectTable
// containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with
// the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint
// (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list.
uint oldContainer = item.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
uint oldContainer = previous.ContainerId;
item.ContainerId = newContainerId;
item.CurrentlyEquippedLocation = EquipMask.None;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId,
newContainerId);
if (oldContainer != 0 && oldContainer != newContainerId
&& _containerIndex.TryGetValue(oldContainer, out var srcList))
@ -253,15 +434,16 @@ public sealed class ClientObjectTable
}
else item.ContainerSlot = newSlot;
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
PublishPlacementChange(item, previous);
PublishContainerContentsChanges(changedContainers);
return true;
}
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's
/// WieldObject 0x0023 confirm does via <see cref="MoveItem"/> (acdream models a wielded item as
/// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the
/// confirmed state and <see cref="RollbackMove"/> via MoveItem fully restores pre-move state. Fires
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask. WielderId remains unchanged
/// until <see cref="ApplyConfirmedServerWield"/> receives the authoritative WieldObject, matching retail's
/// UIAttemptWield request/confirmation split. The confirmation converts this temporary projection to
/// ContainerId zero + authoritative WielderId. <see cref="RollbackMove"/> restores the local projection. Fires
/// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023
/// echo) / RollbackMove (on 0x00A0) reconcile.</summary>
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
@ -291,24 +473,10 @@ public sealed class ClientObjectTable
}
else
{
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
_pendingMoves[itemId] = (p.placement, p.outstanding - 1);
}
}
/// <summary>
/// Reconcile an authoritative WieldObject response and publish the exact
/// retail inventory-request completion boundary. The notification is not
/// conditional on the optimistic rollback counter reaching zero.
/// Retail: ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 clears the
/// matching prevRequestObjectID directly.
/// </summary>
public void ConfirmWield(uint itemId)
{
ConfirmMove(itemId);
if (_objects.TryGetValue(itemId, out ClientObject? item))
WieldConfirmed?.Invoke(item);
}
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its
/// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
/// any still-outstanding moves). False if nothing was pending.</summary>
@ -316,7 +484,13 @@ public sealed class ClientObjectTable
{
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId);
if (!MoveItem(itemId, pre.container, pre.slot, pre.equip))
ClientObjectPlacement placement = pre.placement;
if (!ApplyServerMove(
itemId,
placement.ContainerId,
placement.WielderId,
placement.ContainerSlot,
placement.EquipLocation))
return false;
MoveRolledBack?.Invoke(_objects[itemId]);
return true;
@ -357,12 +531,18 @@ public sealed class ClientObjectTable
bool notifyObjectRemoved)
{
if (!_objects.TryRemove(itemId, out var item)) return false;
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
l.Remove(itemId);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId,
exceptContainerId: 0u);
UpdateEquipmentIndex(
itemId,
ClientObjectPlacement.From(item),
default);
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
if (notifyObjectRemoved)
ObjectRemoved?.Invoke(item);
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
PublishContainerContentsChanges(changedContainers);
return true;
}
@ -437,10 +617,13 @@ public sealed class ClientObjectTable
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item));
ObjectUpdated?.Invoke(item);
return true;
}
@ -489,6 +672,7 @@ public sealed class ClientObjectTable
_objects[d.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!;
@ -524,17 +708,23 @@ public sealed class ClientObjectTable
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,
obj.ContainerId);
Reindex(obj, oldContainer);
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
return obj;
}
/// <summary>
/// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at <paramref name="equip"/>), creating an
/// empty entry if CreateObject hasn't arrived yet. Equipped entries may
/// specify the player as <paramref name="containerId"/> so login and live
/// WieldObject updates share the same contained-by-wielder projection.
/// empty entry if CreateObject hasn't arrived yet. PlayerDescription may
/// use the player as <paramref name="containerId"/> for its login-time
/// placement projection; live WieldObject confirmation later establishes
/// canonical ContainerId zero plus WielderId player.
/// Never touches icon/name/type/effects — that data comes from CreateObject.
/// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0,
@ -548,6 +738,7 @@ public sealed class ClientObjectTable
_objects[guid] = obj;
}
uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
if (containerId != 0)
obj.ContainerId = containerId;
obj.CurrentlyEquippedLocation = equip;
@ -555,11 +746,130 @@ public sealed class ClientObjectTable
obj.ContainerSlot = -1;
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
if (priority is { } p) obj.Priority = p;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,
obj.ContainerId);
Reindex(obj, oldContainer);
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
return obj;
}
/// <summary>
/// Initialize PlayerDescription equipment in canonical retail form while
/// preserving the packed InventoryPlacement list order exactly. Retail
/// <c>UpdateObjectInventory @ 0x00559550</c> assigns the complete list; it
/// does not replay live head-insertion for each login entry.
/// </summary>
public void InitializeEquipmentManifest(
uint wielderId,
IReadOnlyList<EquipmentManifestEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (wielderId == 0u) return;
var ordered = new List<uint>(entries.Count);
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
var changedContainers = new List<uint>();
var incoming = new HashSet<uint>();
for (int i = 0; i < entries.Count; i++)
incoming.Add(entries[i].Guid);
// UpdateObjectInventory assigns a complete authoritative list. Clear
// canonical placement on members omitted by a replay/replacement
// manifest before installing the new order; replacing only the index
// leaves ghost equipment visible to AutoWield and burden queries.
if (_equipmentIndex.TryGetValue(wielderId, out List<uint>? priorEquipment))
{
foreach (uint priorId in priorEquipment.ToArray())
{
if (incoming.Contains(priorId)
|| !_objects.TryGetValue(priorId, out ClientObject? prior)
|| prior is null
|| (prior.WielderId != wielderId
&& !(prior.ContainerId == wielderId
&& prior.CurrentlyEquippedLocation != EquipMask.None)))
continue;
prior.ContainerId = 0u;
prior.ContainerSlot = -1;
prior.WielderId = 0u;
prior.CurrentlyEquippedLocation = EquipMask.None;
prior.Priority = 0u;
if (RemoveFromOtherContainerIndexes(priorId, exceptContainerId: 0u)
is { } removedFrom)
{
changedContainers.AddRange(removedFrom);
}
notifications.Add((prior, true));
}
}
for (int i = 0; i < entries.Count; i++)
{
EquipmentManifestEntry entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj;
}
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
obj.ContainerId = 0u;
obj.ContainerSlot = -1;
obj.WielderId = wielderId;
obj.CurrentlyEquippedLocation = entry.EquipLocation;
obj.Priority = entry.Priority;
if (RemoveFromOtherContainerIndexes(entry.Guid, exceptContainerId: 0u)
is { } changed)
{
changedContainers.AddRange(changed);
}
Reindex(obj, previous.ContainerId);
RemoveEquipmentIndexMembership(entry.Guid);
notifications.Add((obj, existed));
}
_equipmentIndex[wielderId] = ordered;
foreach ((ClientObject item, bool existed) in notifications)
{
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
PublishContainerContentsChanges(changedContainers);
}
private List<uint>? RemoveFromOtherContainerIndexes(
uint itemId,
uint exceptContainerId)
{
List<uint>? changed = null;
foreach ((uint containerId, List<uint> members) in _containerIndex)
{
if (containerId == exceptContainerId || !members.Remove(itemId))
continue;
(changed ??= new List<uint>()).Add(containerId);
}
return changed;
}
private void PublishContainerContentsChanges(List<uint>? changed)
{
if (changed is null || changed.Count == 0)
return;
var published = new HashSet<uint>();
foreach (uint containerId in changed)
{
if (!published.Add(containerId))
continue;
ContainerContentsReplaced?.Invoke(containerId);
}
}
private void Reindex(ClientObject obj, uint oldContainerId)
{
if (oldContainerId != obj.ContainerId && oldContainerId != 0
@ -582,6 +892,62 @@ public sealed class ClientObjectTable
}
}
private static uint EquipmentOwner(ClientObjectPlacement placement)
{
if (placement.EquipLocation == EquipMask.None)
return 0u;
return placement.WielderId != 0u
? placement.WielderId
: placement.ContainerId;
}
private void RemoveEquipmentIndexMembership(uint itemId)
{
List<uint>? emptyOwners = null;
foreach ((uint ownerId, List<uint> placements) in _equipmentIndex)
{
placements.Remove(itemId);
if (placements.Count == 0)
(emptyOwners ??= new List<uint>()).Add(ownerId);
}
if (emptyOwners is null)
return;
foreach (uint ownerId in emptyOwners)
_equipmentIndex.Remove(ownerId);
}
/// <summary>
/// Maintain retail's <c>_invPlacement</c> order. Named retail
/// <c>SetPlayerWieldLocation @ 0x0058D8C0</c> inserts a placement at the
/// head; <c>GetObjectAtLocation @ 0x0058CE00</c> scans head-to-tail.
/// </summary>
private void UpdateEquipmentIndex(
uint itemId,
ClientObjectPlacement previous,
ClientObjectPlacement current)
{
if (previous == current)
return;
uint previousOwner = EquipmentOwner(previous);
if (previousOwner != 0u
&& _equipmentIndex.TryGetValue(previousOwner, out List<uint>? oldList))
{
oldList.Remove(itemId);
if (oldList.Count == 0)
_equipmentIndex.Remove(previousOwner);
}
uint currentOwner = EquipmentOwner(current);
if (currentOwner == 0u)
return;
if (!_equipmentIndex.TryGetValue(currentOwner, out List<uint>? currentList))
_equipmentIndex[currentOwner] = currentList = new List<uint>();
currentList.Remove(itemId);
currentList.Insert(0, itemId);
}
private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
? o.ContainerSlot
@ -596,13 +962,25 @@ public sealed class ClientObjectTable
? l.ToArray() : System.Array.Empty<uint>();
/// <summary>
/// Replace a container's entire membership with <paramref name="guids"/> (in order) — the
/// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a
/// container. Members no longer present are detached (ContainerId → 0); new members are
/// recorded with ContainerSlot = list index. ACE writes ViewContents entries
/// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the
/// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints.
/// Retail consumer: ClientUISystem::OnViewContents.
/// Snapshot of items equipped by <paramref name="wielderId"/>. Confirmed
/// retail equipment has ContainerId zero and WielderId set; the
/// ContainerId arm keeps the optimistic pre-confirm projection visible.
/// </summary>
public IReadOnlyList<ClientObject> GetEquippedBy(uint wielderId) =>
_equipmentIndex.TryGetValue(wielderId, out List<uint>? placements)
? placements
.Select(Get)
.Where(item => item is not null
&& item.CurrentlyEquippedLocation != EquipMask.None
&& (item.WielderId == wielderId || item.ContainerId == wielderId))
.Cast<ClientObject>()
.ToArray()
: Array.Empty<ClientObject>();
/// <summary>
/// Replace only a viewed container's ordered membership projection.
/// Retail <c>ACCObjectMaint::ViewObjectContents @ 0x00558A70</c> rebuilds
/// the container lists without mutating child ownership or equip state.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
{
@ -616,51 +994,110 @@ public sealed class ClientObjectTable
}
/// <summary>
/// Replace a container's entire membership with <paramref name="entries"/> in retail order.
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType
/// chooses the loose-item list versus the side-pack/container list.
/// Replace only a viewed container's ordered membership projection.
/// ContentProfile order is retained in <see cref="GetContents"/> while the
/// child weenie's canonical placement remains owned by move/wield events.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (containerId == 0) return;
var keep = new HashSet<uint>();
foreach (var entry in entries)
keep.Add(entry.Guid);
// Detach prior members no longer present (they left the container server-side). GetContents
// returns a snapshot, so mutating the index inside the loop is safe.
foreach (var old in GetContents(containerId))
{
if (keep.Contains(old)) continue;
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
o.ContainerId = 0;
o.CurrentlyEquippedLocation = EquipMask.None;
Reindex(o, containerId);
ObjectMoved?.Invoke(o, containerId, 0);
}
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
var ordered = new List<uint>(entries.Count);
var added = new List<ClientObject>();
for (int i = 0; i < entries.Count; i++)
{
var entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
obj.ContainerId = containerId;
obj.ContainerTypeHint = entry.ContainerType;
if (!existed) added.Add(obj);
}
_containerIndex[containerId] = ordered;
foreach (ClientObject item in added)
ObjectAdded?.Invoke(item);
ContainerContentsReplaced?.Invoke(containerId);
}
/// <summary>
/// Initialize canonical inventory ownership from PlayerDescription. This
/// login manifest is object-state initialization, not a move transition.
/// </summary>
public void InitializeInventoryManifest(
uint ownerId,
IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (ownerId == 0u) return;
var ordered = new List<uint>(entries.Count);
var changedContainers = new List<uint>();
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
var incoming = new HashSet<uint>();
for (int i = 0; i < entries.Count; i++)
incoming.Add(entries[i].Guid);
if (_containerIndex.TryGetValue(ownerId, out List<uint>? priorInventory))
{
foreach (uint priorId in priorInventory.ToArray())
{
if (incoming.Contains(priorId)
|| !_objects.TryGetValue(priorId, out ClientObject? prior)
|| prior is null
|| prior.ContainerId != ownerId)
continue;
ClientObjectPlacement previous = ClientObjectPlacement.From(prior);
prior.ContainerId = 0u;
prior.ContainerSlot = -1;
prior.WielderId = 0u;
prior.CurrentlyEquippedLocation = EquipMask.None;
prior.Priority = 0u;
UpdateEquipmentIndex(
prior.ObjectId,
previous,
ClientObjectPlacement.From(prior));
notifications.Add((prior, true));
}
}
for (int i = 0; i < entries.Count; i++)
{
ContainerContentEntry entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj;
}
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
obj.ContainerId = ownerId;
obj.ContainerSlot = i;
obj.WielderId = 0u;
obj.CurrentlyEquippedLocation = EquipMask.None;
obj.ContainerTypeHint = entry.ContainerType;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj);
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
if (RemoveFromOtherContainerIndexes(entry.Guid, ownerId) is { } changed)
changedContainers.AddRange(changed);
UpdateEquipmentIndex(entry.Guid, previous, ClientObjectPlacement.From(obj));
notifications.Add((obj, existed));
}
_containerIndex[ownerId] = ordered;
foreach ((ClientObject item, bool existed) in notifications)
{
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
ContainerContentsReplaced?.Invoke(ownerId);
PublishContainerContentsChanges(changedContainers);
}
/// <summary>
@ -680,10 +1117,11 @@ public sealed class ClientObjectTable
return total;
}
// NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An
// NOTE: WielderId is populated from authoritative wire data only (CreateObject →
// Ingest or ApplyServerMove). An
// optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder).
// A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone.
// Equipment queries must therefore accept authoritative WielderId OR the optimistic ContainerId.
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
{
if (o.WielderId == ownerGuid) return true;
@ -705,6 +1143,7 @@ public sealed class ClientObjectTable
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();
_equipmentIndex.Clear();
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
Cleared?.Invoke();
}

View file

@ -952,9 +952,17 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </summary>
public WeenieError PerformMovement(MovementStruct mvs)
{
var p = new MovementParameters
// Retail CMotionInterp::PerformMovement (0x00528E80) forwards the
// MovementStruct's params pointer unchanged for types 1-4. Preserve
// that exact parameter object when the CPhysicsObj boundary supplied
// one: rebuilding only speed/modify bits loses CancelMoveTo,
// SetHoldKey, Autonomous, and the remaining retail bitfield. The
// legacy scalar fields remain as a compatibility path for older
// callers that construct a MovementStruct without Params.
var p = mvs.Params ?? new MovementParameters
{
Speed = mvs.Speed,
Autonomous = mvs.Autonomous,
ModifyInterpretedState = mvs.ModifyInterpretedState,
ModifyRawState = mvs.ModifyRawState,
};

View file

@ -5,11 +5,10 @@ namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode
/// (retail's <c>CameraInstantMouseLook</c>). While active, mouse-X
/// delta drives the character's heading AND the chase camera yaw
/// together (combined drive — the camera "instantly" follows the
/// character because mouse-X moves the character, and the chase
/// camera always tracks the character). Mouse-Y is left to the
/// caller — typically pitches the chase camera only.
/// delta produces retail's signed horizontal camera adjustment. The host
/// routes that adjustment through the player's TurnLeft/TurnRight movement
/// commands; the chase camera follows the resulting character heading.
/// Mouse-Y is left to the caller — typically pitches the chase camera only.
///
/// <para>
/// The class owns three transitions:
@ -27,17 +26,28 @@ namespace AcDream.UI.Abstractions.Input;
/// <para>
/// <see cref="ApplyDelta"/> is the per-frame mouse-move hook: when
/// active, scales <paramref name="dx"/> by sensitivity and feeds the
/// caller-supplied yaw mutator. The mutator is the only side-channel —
/// the class doesn't know about <c>PlayerMovementController</c> or
/// <c>ChaseCamera</c>.
/// caller-supplied movement-adjustment sink. The sink is the only
/// side-channel — the class doesn't know about
/// <c>PlayerMovementController</c> or <c>ChaseCamera</c>.
/// </para>
/// </summary>
public sealed class MouseLookState
{
private readonly Action<float> _applyYawDelta;
private readonly Action<float> _applyHorizontalAdjustment;
private int _horizontalExtent;
private float _queuedDeltaX;
private float _queuedDeltaY;
private float _lastMouseInputTime;
/// <summary>
/// Retail <c>CInputManager_WIN32::GetInput @ 0x006897A0</c> does not
/// synthesize an idle sample until the cursor has been still for strictly
/// more than 0.2 seconds.
/// </summary>
public const float IdleZeroDelaySeconds = 0.2f;
/// <summary>True while MMB is held AND ImGui isn't capturing the
/// mouse. Mouse-X deltas drive yaw only when this is true.</summary>
/// mouse. Mouse-X deltas drive turn adjustment only when this is true.</summary>
public bool Active { get; private set; }
/// <summary>Cursor X at the moment <see cref="Press"/> activated.
@ -48,17 +58,19 @@ public sealed class MouseLookState
public float CapturedCursorY { get; private set; }
/// <summary>
/// Per-radian yaw multiplier applied to mouse-X delta. The same
/// Per-pixel adjustment multiplier applied to mouse-X delta. The same
/// chase-camera sensitivity factor used elsewhere in GameWindow is
/// folded in by the caller; this class only owns the constant
/// scale that converts pixels to radians. Default 0.004 matches
/// the K.1b RMB-orbit factor.
/// scale used before CameraSet's ×2 speed and 1.5 cap. Retail
/// <c>MouseLookHandler</c> uses exactly 0.0666666701 after the configured
/// input sensitivity.
/// </summary>
public float SensitivityRadiansPerPixel { get; set; } = 0.004f;
public float SensitivityPerPixel { get; set; } = 0.0666666701f;
public MouseLookState(Action<float> applyYawDelta)
public MouseLookState(Action<float> applyHorizontalAdjustment)
{
_applyYawDelta = applyYawDelta ?? throw new ArgumentNullException(nameof(applyYawDelta));
_applyHorizontalAdjustment = applyHorizontalAdjustment
?? throw new ArgumentNullException(nameof(applyHorizontalAdjustment));
}
/// <summary>
@ -71,13 +83,21 @@ public sealed class MouseLookState
/// <param name="wantCaptureMouse">Mirror of
/// <c>ImGui.GetIO().WantCaptureMouse</c>. When true, the press is
/// ignored so a hover-over-panel MMB doesn't grab the cursor.</param>
public void Press(float cursorX, float cursorY, bool wantCaptureMouse)
public void Press(
float cursorX,
float cursorY,
bool wantCaptureMouse,
float nowSeconds = 0f)
{
if (wantCaptureMouse) return;
if (Active) return;
Active = true;
CapturedCursorX = cursorX;
CapturedCursorY = cursorY;
_horizontalExtent = 0;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
_lastMouseInputTime = nowSeconds;
}
/// <summary>MMB release transition. Always deactivates if active.</summary>
@ -85,6 +105,9 @@ public sealed class MouseLookState
{
if (!Active) return;
Active = false;
_horizontalExtent = 0;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
}
/// <summary>
@ -94,24 +117,83 @@ public sealed class MouseLookState
/// </summary>
public void OnWantCaptureMouseChanged(bool wantCaptureMouse)
{
if (Active && wantCaptureMouse) Active = false;
if (Active && wantCaptureMouse)
{
Active = false;
_horizontalExtent = 0;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
}
}
/// <summary>
/// Accumulates event-driven Silk mouse motion for the next update sample.
/// Retail's extent gate is clocked by samples, not by the number of native
/// mouse messages delivered during one frame.
/// </summary>
public void QueueDelta(float dx, float dy = 0f)
{
if (Active && float.IsFinite(dx) && float.IsFinite(dy))
{
_queuedDeltaX += dx;
_queuedDeltaY += dy;
}
}
/// <summary>
/// Consumes the frame's accumulated raw device motion once. When there is
/// no net movement, returns a synthetic zero only after the strict retail
/// 0.2-second idle boundary. The caller filters this one aggregate and then
/// passes its horizontal component to <see cref="ApplyDelta"/>.
/// </summary>
public bool TryTakeRawSample(
float nowSeconds,
out float dx,
out float dy)
{
dx = 0f;
dy = 0f;
if (!Active)
{
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
return false;
}
dx = _queuedDeltaX;
dy = _queuedDeltaY;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
if (dx != 0f || dy != 0f)
{
_lastMouseInputTime = nowSeconds;
return true;
}
return nowSeconds > _lastMouseInputTime + IdleZeroDelaySeconds;
}
/// <summary>
/// Apply a per-frame mouse-X delta. When active, scales by
/// <see cref="SensitivityRadiansPerPixel"/> times the
/// <see cref="SensitivityPerPixel"/> times the
/// caller-supplied <paramref name="extraSensitivity"/> (typically
/// the chase-camera sens) and feeds it to the yaw mutator. Sign
/// the chase-camera sens) and feeds it to the movement sink. Sign
/// matches retail: dragging the mouse RIGHT yaws the character to
/// the right (positive yaw delta).
/// the right (a negative adjustment in acdream's turn convention).
/// </summary>
/// <param name="dx">Pixels of horizontal mouse motion since the
/// last frame.</param>
/// <param name="extraSensitivity">Multiplier (e.g. chase-camera
/// sensitivity) applied on top of the radians-per-pixel scale.</param>
/// sensitivity) applied on top of the per-pixel scale.</param>
public void ApplyDelta(float dx, float extraSensitivity)
{
if (!Active) return;
ProcessDelta(dx, extraSensitivity);
}
private void ProcessDelta(float dx, float extraSensitivity)
{
// Sign: dragging the mouse RIGHT (dx > 0) should yaw the
// character to the right. With the acdream Yaw convention
// (where Yaw 0 = +X, increasing to +Y), positive yaw is
@ -119,6 +201,20 @@ public sealed class MouseLookState
// yaw goes DOWN (more clockwise). The dispatcher convention
// for chase YawOffset is `YawOffset -= dx * factor`; we keep
// the same sign here so character + camera rotate identically.
_applyYawDelta(-dx * SensitivityRadiansPerPixel * extraSensitivity);
float adjustment = -dx * SensitivityPerPixel * extraSensitivity;
if (adjustment == 0f)
{
_horizontalExtent = 0;
return;
}
// MouseLookHandler @ 0x00458D80 increments mouselook_x_extent and
// calls Rotate only after five consecutive non-zero samples. A zero
// sample resets the extent.
_horizontalExtent++;
if (_horizontalExtent <= 5)
return;
_applyHorizontalAdjustment(adjustment);
}
}