Merge feature/animation-system-complete — Phase L.1c animation MVP
21 commits porting retail's MoveToManager-equivalent client-side behavior for server-controlled creature locomotion and combat engagement. Shipped as MVP after live visual verification across multiple iteration rounds with the user. Highlights: -186a584— initial Phase L.1c port: extracts Origin / target guid / MovementParameters block from MoveTo packets (movementType 6/7), adds RemoteMoveToDriver per-tick body-orientation steering with ±20° aux-turn-equivalent snap tolerance. -d247aef— corrected arrival predicate semantics + 1.5 s stale-destination timeout for entities leaving the streaming view. -f794832— root-caused "creature won't stop to attack" via two research subagents converging on retail CMotionInterp::move_to_interpreted_state's unconditional forward_command bulk-copy. Lifted ServerMoveToActive flag clearing + InterpretedState bulk-copy out of substate-only branch so Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear stale MoveTo state and zero forward velocity. -ff6d3d0— RemoteMoveToDriver.ClampApproachVelocity caps horizontal velocity at the final-approach tick so body lands EXACTLY at DistanceToObject instead of overshooting through the player. -37de771— bulk-copy ForwardCommand for MoveTo packets too (closed the regression where MoveTo creatures stayed at default ForwardCommand=Ready in InterpretedState and only translated via UpdatePosition snaps). -34d7f4d+e71ed73— AnimationSequencer.HasCycle query + fallback chain (requested → WalkForward → Ready → no-op) at BOTH the OnLiveMotionUpdated path AND the spawn handler. Prevents ClearCyclicTail from wiping the body's cyclic tail when ACE CreateObject carries CurrentMotionState.ForwardCommand pointing to an Action-class motion (e.g. AttackHigh1 from a mid-swing creature) which has no cyclic-table entry — was the "torso on the ground" symptom for monsters seen in combat by a fresh observer. Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt (MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80, CMotionInterp::move_to_interpreted_state 0x00528xxx, MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/ ACE.Server/Physics/Animation/MoveToManager.cs (port aid), references/holtburger/ (cross-check on snapshot-only client behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md (the Phase L.1c pseudocode doc). Tests: 1404 → 1422 (parser type-7 path retention, type-6 target guid retention, driver arrival semantics, retail-faithful chase/flee branches, approach-velocity clamp scenarios, HasCycle present/missing, AttackHigh1 wire layout). Pending follow-ups (filed for future): target-guid live resolution for type 6 packets (residual chase lag), StickToObject sticky-target guid trailing field, full MoveToManager state machine port (CheckProgressMade stall detector, Sticky/StickTo, use_final_heading, pending_actions queue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b93dfe95d8
44 changed files with 4580 additions and 301 deletions
|
|
@ -226,6 +226,71 @@ public sealed class GameWindow : IDisposable
|
|||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
public System.Numerics.Vector3 LastServerPos;
|
||||
/// <summary>
|
||||
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||||
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||||
/// it for a server-controlled creature, derive it from consecutive
|
||||
/// authoritative positions instead of guessing from player RUM state.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 ServerVelocity;
|
||||
public bool HasServerVelocity;
|
||||
/// <summary>
|
||||
/// True while a server MoveToObject/MoveToPosition packet is the
|
||||
/// active locomotion source. Retail runs these through MoveToManager
|
||||
/// and CMotionInterp; the per-tick remote driver consults this to
|
||||
/// decide whether to feed body steering through
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
|
||||
/// the InterpretedMotionState path.
|
||||
/// </summary>
|
||||
public bool ServerMoveToActive;
|
||||
|
||||
/// <summary>
|
||||
/// True once a MoveTo packet's full path payload (Origin + thresholds)
|
||||
/// has been parsed and the world-converted destination is stored on
|
||||
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
|
||||
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
|
||||
/// Phase L.1c (2026-04-28).
|
||||
/// </summary>
|
||||
public bool HasMoveToDestination;
|
||||
|
||||
/// <summary>
|
||||
/// World-space destination from the most recent MoveTo packet's
|
||||
/// <c>Origin</c> field, converted via the same landblock-grid
|
||||
/// arithmetic <c>OnLivePositionUpdated</c> uses.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 MoveToDestinationWorld;
|
||||
|
||||
/// <summary>
|
||||
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
|
||||
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
|
||||
/// the chase-arrival threshold per retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c>.
|
||||
/// </summary>
|
||||
public float MoveToMinDistance;
|
||||
|
||||
/// <summary>
|
||||
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
|
||||
/// the flee branch (<c>move_away</c>); chase uses
|
||||
/// <see cref="MoveToMinDistance"/>.
|
||||
/// </summary>
|
||||
public float MoveToDistanceToObject;
|
||||
|
||||
/// <summary>
|
||||
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
|
||||
/// <c>0x200</c>) is set on the active packet — i.e. this is a
|
||||
/// chase. False = flee (<c>move_away</c>) or static target.
|
||||
/// </summary>
|
||||
public bool MoveToMoveTowards;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
|
||||
/// for this entity. Used by the per-tick driver to give up
|
||||
/// steering when no refresh has arrived for
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
|
||||
/// — typically because the entity left our streaming view and
|
||||
/// the server stopped broadcasting its MoveTo updates.
|
||||
/// </summary>
|
||||
public double LastMoveToPacketTime;
|
||||
/// <summary>
|
||||
/// Legacy field — no longer used for slerp (retail hard-snaps
|
||||
/// per FUN_00514b90 set_frame). Kept to avoid churn.
|
||||
/// </summary>
|
||||
|
|
@ -532,6 +597,13 @@ public sealed class GameWindow : IDisposable
|
|||
/// keys the render list; this parallel dictionary keys by server guid.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
||||
private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();
|
||||
private uint? _selectedTargetGuid;
|
||||
private readonly record struct LiveEntityInfo(
|
||||
string? Name,
|
||||
AcDream.Core.Items.ItemType ItemType);
|
||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
||||
private int _liveSpawnReceived; // diagnostics
|
||||
private int _liveSpawnHydrated;
|
||||
private int _liveDropReasonNoPos;
|
||||
|
|
@ -1315,6 +1387,7 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine($"live: connecting to {endpoint} as {user}");
|
||||
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||
|
|
@ -1666,20 +1739,7 @@ public sealed class GameWindow : IDisposable
|
|||
// For a respawn, drop the previous rendering state here before we
|
||||
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
||||
// its value is the live WorldEntity we need to dispose.
|
||||
if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
|
||||
{
|
||||
_worldState.RemoveEntityByServerGuid(spawn.Guid);
|
||||
_worldGameState.RemoveById(existingEntity.Id);
|
||||
_animatedEntities.Remove(existingEntity.Id);
|
||||
// Physics collision registry entry is keyed by local id too.
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||
// clear using the same guid the new spawn will use. Leaving old
|
||||
// SnapResidual / DeadReckonedPos in would make the next first
|
||||
// UpdatePosition look like a 2m-residual soft-snap.
|
||||
_remoteDeadReckon.Remove(spawn.Guid);
|
||||
_remoteLastMove.Remove(spawn.Guid);
|
||||
}
|
||||
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||
|
||||
// Log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
|
|
@ -1691,12 +1751,19 @@ public sealed class GameWindow : IDisposable
|
|||
: "no-pos";
|
||||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
||||
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
|
||||
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||||
Console.WriteLine(
|
||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||||
$"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
|
||||
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
|
||||
spawn.Name,
|
||||
spawn.ItemType is { } rawItemType
|
||||
? (AcDream.Core.Items.ItemType)rawItemType
|
||||
: AcDream.Core.Items.ItemType.None);
|
||||
|
||||
// Target the statue specifically for full diagnostic dump: Name match
|
||||
// is cheap and gives us exactly one entity's worth of log regardless
|
||||
|
|
@ -2045,9 +2112,64 @@ public sealed class GameWindow : IDisposable
|
|||
if (mtable is not null)
|
||||
{
|
||||
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
||||
uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle;
|
||||
uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u;
|
||||
sequencer.SetCycle(seqStyle, seqMotion);
|
||||
uint seqStyle = stanceOverride is > 0
|
||||
? (0x80000000u | (uint)stanceOverride.Value)
|
||||
: (uint)mtable.DefaultStyle;
|
||||
uint seqMotion;
|
||||
if (commandOverride is > 0)
|
||||
{
|
||||
uint resolved = AcDream.Core.Physics.MotionCommandResolver
|
||||
.ReconstructFullCommand(commandOverride.Value);
|
||||
seqMotion = resolved != 0
|
||||
? resolved
|
||||
: (0x40000000u | (uint)commandOverride.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
seqMotion = AcDream.Core.Physics.MotionCommand.Ready;
|
||||
}
|
||||
|
||||
// Phase L.1c followup (2026-04-28): apply the same
|
||||
// missing-cycle fallback the OnLiveMotionUpdated path
|
||||
// uses. Without this, a monster spawned in combat
|
||||
// stance with the wire's seqMotion absent from its
|
||||
// MotionTable hits ClearCyclicTail() with no
|
||||
// replacement enqueue, every body part snaps to its
|
||||
// setup-default offset, and the visual collapses to
|
||||
// "torso on the ground" — visible to acdream
|
||||
// observers when another client is in combat with a
|
||||
// monster, until the first OnLiveMotionUpdated UM
|
||||
// applies the same fallback there.
|
||||
uint spawnCycle = seqMotion;
|
||||
if (!sequencer.HasCycle(seqStyle, spawnCycle))
|
||||
{
|
||||
uint origCycle = spawnCycle;
|
||||
// RunForward → WalkForward → Ready
|
||||
if ((spawnCycle & 0xFFu) == 0x07
|
||||
&& sequencer.HasCycle(seqStyle, 0x45000005u))
|
||||
{
|
||||
spawnCycle = 0x45000005u;
|
||||
}
|
||||
else if (sequencer.HasCycle(seqStyle, 0x41000003u))
|
||||
{
|
||||
spawnCycle = 0x41000003u;
|
||||
}
|
||||
else
|
||||
{
|
||||
spawnCycle = 0;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " +
|
||||
$"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " +
|
||||
$"→ fallback=0x{spawnCycle:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
if (spawnCycle != 0)
|
||||
sequencer.SetCycle(seqStyle, spawnCycle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2152,6 +2274,41 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||
{
|
||||
if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true)
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
||||
return false;
|
||||
|
||||
_worldState.RemoveEntityByServerGuid(serverGuid);
|
||||
_worldGameState.RemoveById(existingEntity.Id);
|
||||
_animatedEntities.Remove(existingEntity.Id);
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
|
||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||
// clear using the same guid the next spawn/update would use.
|
||||
_remoteDeadReckon.Remove(serverGuid);
|
||||
_remoteLastMove.Remove(serverGuid);
|
||||
_liveEntityInfoByGuid.Remove(serverGuid);
|
||||
_entitiesByServerGuid.Remove(serverGuid);
|
||||
if (_selectedTargetGuid == serverGuid)
|
||||
_selectedTargetGuid = null;
|
||||
|
||||
if (logDelete)
|
||||
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
||||
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
|
||||
|
|
@ -2186,11 +2343,13 @@ public sealed class GameWindow : IDisposable
|
|||
&& update.Guid != _playerServerGuid)
|
||||
{
|
||||
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||||
float spd = update.MotionState.ForwardSpeed ?? 0f;
|
||||
float spd = update.MotionState.ForwardSpeed
|
||||
?? ((update.MotionState.MoveToSpeed ?? 0f)
|
||||
* (update.MotionState.MoveToRunRate ?? 0f));
|
||||
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
|
||||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||
Console.WriteLine(
|
||||
$"UM guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
|
||||
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
|
||||
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
|
||||
}
|
||||
|
||||
|
|
@ -2231,10 +2390,27 @@ public sealed class GameWindow : IDisposable
|
|||
// command == null → retail stop signal → Ready
|
||||
// command.Value == 0 → explicit 0 (rare) → Ready
|
||||
// otherwise → resolve class byte and use full cmd
|
||||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||
uint fullMotion;
|
||||
if (!command.HasValue || command.Value == 0)
|
||||
if ((!command.HasValue || command.Value == 0)
|
||||
&& update.MotionState.IsServerControlledMoveTo)
|
||||
{
|
||||
// Retail MoveToManager::BeginMoveForward calls
|
||||
// MovementParameters::get_command (0x0052AA00), then
|
||||
// _DoMotion -> adjust_motion. With CanRun and enough
|
||||
// distance, WalkForward + HoldKey_Run becomes RunForward,
|
||||
// and CMotionInterp::apply_run_to_command (0x00527BE0)
|
||||
// multiplies speed by the packet's runRate.
|
||||
var seed = AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.PlanMoveToStart(
|
||||
update.MotionState.MoveToSpeed ?? 1f,
|
||||
update.MotionState.MoveToRunRate ?? 1f,
|
||||
update.MotionState.MoveToCanRun);
|
||||
fullMotion = seed.Motion;
|
||||
speedMod = seed.SpeedMod;
|
||||
}
|
||||
else if (!command.HasValue || command.Value == 0)
|
||||
{
|
||||
// Stop — return to the style's default substate (Ready).
|
||||
fullMotion = 0x41000003u;
|
||||
}
|
||||
else
|
||||
|
|
@ -2262,8 +2438,6 @@ public sealed class GameWindow : IDisposable
|
|||
// apply_run_to_command). Treating zero as "unspecified / 1.0"
|
||||
// produces "slow walk that never stops" — exactly what the
|
||||
// stop bug looked like.
|
||||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||
&& update.Guid != _playerServerGuid)
|
||||
Console.WriteLine(
|
||||
|
|
@ -2295,6 +2469,125 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
else
|
||||
{
|
||||
var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion);
|
||||
bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action
|
||||
or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier
|
||||
or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote;
|
||||
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
||||
&& rmCheck.Airborne;
|
||||
|
||||
// Retail MotionTable::GetObjectSequence routes action-class
|
||||
// ForwardCommand values (creature attacks, chat-emotes) through
|
||||
// the Action branch, where the swing is appended before the
|
||||
// current cyclic tail and currState.Substate remains Ready.
|
||||
// Treating 0x10000051/52/53 as SetCycle commands made the
|
||||
// immediate follow-up Ready packet abort the swing.
|
||||
// Phase L.1c followup (2026-04-28): the next two state-update
|
||||
// blocks are LIFTED out of the substate-only `else` branch so
|
||||
// they run for BOTH overlay (Action/Modifier/ChatEmote) and
|
||||
// substate (Walk/Run/Ready/etc) packets. Two separate research
|
||||
// agents converged on the same root cause for the user-
|
||||
// observed "creature just runs instead of attacking" symptom:
|
||||
//
|
||||
// 1. Attack swings arrive as mt=0 with
|
||||
// ForwardCommand=AttackHigh1 (Action class). Retail's
|
||||
// CMotionInterp::move_to_interpreted_state
|
||||
// (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies
|
||||
// forward_command from the wire into the body's
|
||||
// InterpretedState UNCONDITIONALLY. With
|
||||
// forward_command=AttackHigh1, get_state_velocity
|
||||
// returns 0 because its gate is RunForward||WalkForward
|
||||
// — body stops moving forward.
|
||||
//
|
||||
// 2. The acdream overlay branch was routing through
|
||||
// PlayAction (animation overlay) but skipping ALL of:
|
||||
// - ServerMoveToActive flag update
|
||||
// - MoveToPath capture
|
||||
// - InterpretedState.ForwardCommand assignment
|
||||
// So during a swing UM, the body's InterpretedState
|
||||
// stayed at RunForward from the prior MoveTo packet,
|
||||
// ServerMoveToActive stayed true, and the per-tick
|
||||
// remote driver kept steering + applying RunForward
|
||||
// velocity through every frame.
|
||||
//
|
||||
// Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState
|
||||
// here because the latter is a heuristic that ONLY handles
|
||||
// WalkForward / RunForward / WalkBackward / SideStep / Turn
|
||||
// / Ready (MotionInterpreter.cs:941-970). For an Action
|
||||
// command (e.g. AttackHigh1 = 0x10000062) the switch falls
|
||||
// through and InterpretedState is silently NOT updated —
|
||||
// exactly the bug we are fixing. Direct field assignment
|
||||
// matches retail's <c>copy_movement_from</c> bulk-copy
|
||||
// (acclient_2013_pseudo_c.txt:293301-293311).
|
||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||
{
|
||||
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
|
||||
|
||||
// Bulk-copy the wire's resolved ForwardCommand + speed
|
||||
// into InterpretedState UNCONDITIONALLY (overlay,
|
||||
// substate, AND MoveTo packets). Matches retail's
|
||||
// copy_movement_from semantics
|
||||
// (acclient_2013_pseudo_c.txt:293301-293311) which does
|
||||
// not filter by MovementType.
|
||||
//
|
||||
// For MoveTo packets, fullMotion is the RunForward seed
|
||||
// from PlanMoveToStart, so this populates
|
||||
// ForwardCommand=RunForward + ForwardSpeed=speed*runRate
|
||||
// — what the OLD substate-only DoInterpretedMotion call
|
||||
// (commit f794832 removed) used to set. Without it,
|
||||
// apply_current_movement reads the default
|
||||
// ForwardCommand=Ready and produces zero velocity, so
|
||||
// chasing creatures only translate via UpdatePosition
|
||||
// hard-snaps and at spawn appear posed at default
|
||||
// (visible as "torso on the ground" until the first UP
|
||||
// snap hits).
|
||||
//
|
||||
// For overlay (Action) packets this sets ForwardCommand
|
||||
// to the Attack/Twitch/etc command, and
|
||||
// get_state_velocity returns 0 because the gate is
|
||||
// RunForward||WalkForward — body stops moving forward.
|
||||
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
|
||||
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
|
||||
|
||||
if (update.MotionState.IsServerControlledMoveTo
|
||||
&& update.MotionState.MoveToPath is { } path)
|
||||
{
|
||||
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.OriginToWorld(
|
||||
path.OriginCellId,
|
||||
path.OriginX,
|
||||
path.OriginY,
|
||||
path.OriginZ,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
remoteMot.MoveToMinDistance = path.MinDistance;
|
||||
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
|
||||
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
|
||||
remoteMot.HasMoveToDestination = true;
|
||||
remoteMot.LastMoveToPacketTime =
|
||||
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
}
|
||||
else if (!update.MotionState.IsServerControlledMoveTo)
|
||||
{
|
||||
// Off MoveTo — clear stale destination so the per-tick
|
||||
// driver doesn't keep steering.
|
||||
remoteMot.HasMoveToDestination = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (forwardIsOverlay)
|
||||
{
|
||||
if (!remoteIsAirborne)
|
||||
{
|
||||
AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand(
|
||||
ae.Sequencer,
|
||||
fullStyle,
|
||||
fullMotion,
|
||||
speedMod <= 0f ? 1f : speedMod);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pick which cycle to play on the sequencer. Priority:
|
||||
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
|
||||
// 2. Else sidestep cmd if active — legs strafe.
|
||||
|
|
@ -2342,10 +2635,63 @@ public sealed class GameWindow : IDisposable
|
|||
// the post-resolve landing path restores the cycle to
|
||||
// whatever the interpreted state says when the body
|
||||
// lands.
|
||||
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
|
||||
&& rmCheck.Airborne;
|
||||
if (!remoteIsAirborne)
|
||||
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
|
||||
{
|
||||
// Fallback chain for missing cycles in the MotionTable.
|
||||
// SetCycle unconditionally calls ClearCyclicTail() before
|
||||
// looking up the cycle; if the cycle is absent, the body
|
||||
// ends up with no cyclic tail at all and every part snaps
|
||||
// to its setup-default offset — visible as "torso on the
|
||||
// ground" because most creatures' setup-default puts all
|
||||
// limbs at the torso origin.
|
||||
//
|
||||
// This is specifically a regression from commit 186a584
|
||||
// (Phase L.1c port): pre-fix, MoveTo packets fell through
|
||||
// to fullMotion=Ready (which always exists in every
|
||||
// MotionTable). Post-fix, MoveTo packets seed
|
||||
// fullMotion=RunForward, but some creatures (especially
|
||||
// when stance=HandCombat) lack a (combat, RunForward)
|
||||
// cycle. Fall through RunForward → WalkForward → Ready
|
||||
// until we find one the table actually contains.
|
||||
//
|
||||
// Note: this fallback is for the SEQUENCER (visible
|
||||
// animation) only. InterpretedState.ForwardCommand still
|
||||
// gets the wire's (or seeded) ForwardCommand verbatim
|
||||
// so apply_current_movement produces correct velocity.
|
||||
uint cycleToPlay = animCycle;
|
||||
if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay))
|
||||
{
|
||||
uint requested = cycleToPlay;
|
||||
// RunForward (0x44000007) → WalkForward (0x45000005)
|
||||
if ((cycleToPlay & 0xFFu) == 0x07
|
||||
&& ae.Sequencer.HasCycle(fullStyle, 0x45000005u))
|
||||
{
|
||||
cycleToPlay = 0x45000005u;
|
||||
}
|
||||
// WalkForward → Ready (0x41000003)
|
||||
else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u))
|
||||
{
|
||||
cycleToPlay = 0x41000003u;
|
||||
}
|
||||
// Ready missing too — leave the existing cycle alone
|
||||
// by not calling SetCycle at all (avoids the
|
||||
// ClearCyclicTail wipe).
|
||||
else
|
||||
{
|
||||
cycleToPlay = 0;
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"UM cycle missing for guid=0x{update.Guid:X8} " +
|
||||
$"style=0x{fullStyle:X8} requested=0x{requested:X8} " +
|
||||
$"→ fallback=0x{cycleToPlay:X8}");
|
||||
}
|
||||
}
|
||||
if (cycleToPlay != 0)
|
||||
ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed);
|
||||
}
|
||||
|
||||
// Retail runs the full MotionInterp state machine on every
|
||||
// remote. Route each wire command (forward, sidestep, turn)
|
||||
|
|
@ -2359,12 +2705,17 @@ public sealed class GameWindow : IDisposable
|
|||
// FUN_00528f70 DoInterpretedMotion
|
||||
// FUN_00528960 get_state_velocity
|
||||
// FUN_00529210 apply_current_movement
|
||||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||||
// ServerMoveToActive flag, MoveToPath capture, and the
|
||||
// InterpretedState.ForwardCommand bulk-copy are already
|
||||
// handled by the LIFTED block above (so overlay-class swings
|
||||
// also clear stale MoveTo state and update the body's
|
||||
// forward command). This branch only handles sidestep /
|
||||
// turn axes plus the ObservedOmega seed — none of which
|
||||
// appear on overlay packets, so the existing logic is
|
||||
// correct unchanged. (`remoteMot` is the same dictionary
|
||||
// entry obtained at the top of the lifted block.)
|
||||
if (remoteMot is not null)
|
||||
{
|
||||
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
|
||||
remoteMot.Motion.DoInterpretedMotion(
|
||||
fullMotion, speedMod, modifyInterpretedState: true);
|
||||
|
||||
// Sidestep axis.
|
||||
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
|
||||
{
|
||||
|
|
@ -2421,6 +2772,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||||
|
|
@ -2452,57 +2804,20 @@ public sealed class GameWindow : IDisposable
|
|||
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
}
|
||||
|
||||
// Route the Commands list — one-shot Actions, Modifiers, and
|
||||
// ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
|
||||
// live in the motion table's Links / Modifiers dicts, not
|
||||
// Cycles, and are played on top of the current cycle via
|
||||
// PlayAction which resolves the right dict and interleaves the
|
||||
// action frames before the cyclic tail.
|
||||
//
|
||||
// A typical NPC wave looks like:
|
||||
// ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
|
||||
// [{0x0003=Ready, ...}]
|
||||
// Each item runs through PlayAction (for 0x10/0x20 mask) or the
|
||||
// standard SetCycle path (for 0x40 SubState). We leave SubState
|
||||
// commands to fall through to the next UpdateMotion; that's how
|
||||
// retail handles transition sequences (Wave → Ready).
|
||||
// Route command-list entries through the shared Core router.
|
||||
// Retail/ACE send these as 16-bit MotionCommand lows in
|
||||
// InterpretedMotionState.Commands[]; the router reconstructs the
|
||||
// class byte and chooses PlayAction for actions/modifiers/emotes
|
||||
// or SetCycle for persistent substates.
|
||||
if (update.MotionState.Commands is { Count: > 0 } cmds)
|
||||
{
|
||||
foreach (var item in cmds)
|
||||
{
|
||||
// Restore the 32-bit MotionCommand from the wire's 16-bit
|
||||
// truncation by OR-ing class bits. The class is encoded
|
||||
// in the low byte's high nibble via command ranges:
|
||||
// 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
|
||||
// 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
|
||||
// 0x0051-0x00A1 — Action class (0x10xx xxxx)
|
||||
//
|
||||
// The retail MotionCommand enum carries the class byte in
|
||||
// bits 24-31. DatReaderWriter's enum values match. For
|
||||
// broadcasts, servers emit only low 16 bits (ACE
|
||||
// InterpretedMotionState.cs:139). We reconstruct via a
|
||||
// range-based lookup. See MotionCommand.generated.cs.
|
||||
uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
|
||||
if (fullCmd == 0) continue;
|
||||
|
||||
// Action class: play through the link dict then drop back
|
||||
// to the current cycle. Modifier class: resolve from the
|
||||
// Modifiers dict and combine on top. SubState: cycle
|
||||
// change; route through SetCycle so the style-specific
|
||||
// cycle fallback applies.
|
||||
uint cls = fullCmd & 0xFF000000u;
|
||||
if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
|
||||
|| cls == 0x12000000u || cls == 0x13000000u)
|
||||
{
|
||||
ae.Sequencer.PlayAction(fullCmd, item.Speed);
|
||||
}
|
||||
else if ((cls & 0x40000000u) != 0)
|
||||
{
|
||||
// Substate in the command list — typically the "and
|
||||
// then return to Ready" item. Update the cycle.
|
||||
ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
|
||||
}
|
||||
// else: Style / UI / Toggle class — not animation-driving.
|
||||
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
|
||||
ae.Sequencer,
|
||||
fullStyle,
|
||||
item.Command,
|
||||
item.Speed);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -2604,6 +2919,39 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private static bool IsRemoteLocomotion(uint motion)
|
||||
{
|
||||
uint low = motion & 0xFFu;
|
||||
return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
|
||||
}
|
||||
|
||||
private void ApplyServerControlledVelocityCycle(
|
||||
uint serverGuid,
|
||||
AnimatedEntity ae,
|
||||
RemoteMotion rm,
|
||||
System.Numerics.Vector3 velocity)
|
||||
{
|
||||
if (IsPlayerGuid(serverGuid)) return;
|
||||
if (rm.Airborne) return;
|
||||
if (ae.Sequencer is null) return;
|
||||
// MoveTo packets already seeded the retail speed/runRate cycle.
|
||||
// Keep UpdatePosition-derived velocity for render position only;
|
||||
// using it to choose the cycle reverts fast chases back to slow
|
||||
// velocity-estimated animation.
|
||||
if (rm.ServerMoveToActive) return;
|
||||
|
||||
var plan = AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.PlanFromVelocity(velocity);
|
||||
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||||
if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion))
|
||||
return;
|
||||
|
||||
uint style = ae.Sequencer.CurrentStyle != 0
|
||||
? ae.Sequencer.CurrentStyle
|
||||
: 0x8000003Du;
|
||||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||
}
|
||||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
// Phase A.1: track the most recently updated entity's landblock so the
|
||||
|
|
@ -2673,6 +3021,26 @@ public sealed class GameWindow : IDisposable
|
|||
// slerp doesn't visibly rotate from Identity to truth.
|
||||
rmState.Body.Orientation = rot;
|
||||
}
|
||||
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
System.Numerics.Vector3? serverVelocity = update.Velocity;
|
||||
if (serverVelocity is null
|
||||
&& !IsPlayerGuid(update.Guid)
|
||||
&& rmState.LastServerPosTime > 0.0)
|
||||
{
|
||||
double elapsed = nowSec - rmState.LastServerPosTime;
|
||||
if (elapsed > 0.001)
|
||||
serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
|
||||
}
|
||||
if (serverVelocity is { } authoritativeVelocity)
|
||||
{
|
||||
rmState.ServerVelocity = authoritativeVelocity;
|
||||
rmState.HasServerVelocity = true;
|
||||
}
|
||||
else if (!IsPlayerGuid(update.Guid))
|
||||
{
|
||||
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rmState.HasServerVelocity = false;
|
||||
}
|
||||
rmState.Body.Position = worldPos;
|
||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||
|
|
@ -2712,7 +3080,7 @@ public sealed class GameWindow : IDisposable
|
|||
rmState.Body.Orientation = rot;
|
||||
rmState.TargetOrientation = rot;
|
||||
rmState.LastServerPos = worldPos;
|
||||
rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
rmState.LastServerPosTime = nowSec;
|
||||
// Align the body's physics clock with our clock so update_object
|
||||
// doesn't sub-step a huge initial gap.
|
||||
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
||||
|
|
@ -2737,6 +3105,7 @@ public sealed class GameWindow : IDisposable
|
|||
// carries no stop information for our ACE.
|
||||
if (svel.LengthSquared() < 0.04f)
|
||||
{
|
||||
rmState.ServerMoveToActive = false;
|
||||
rmState.Motion.StopCompletely();
|
||||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
||||
&& aeForStop.Sequencer is not null)
|
||||
|
|
@ -2749,6 +3118,21 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity)
|
||||
{
|
||||
rmState.Body.Velocity = rmState.ServerVelocity;
|
||||
}
|
||||
|
||||
if (!IsPlayerGuid(update.Guid)
|
||||
&& rmState.HasServerVelocity
|
||||
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
|
||||
{
|
||||
ApplyServerControlledVelocityCycle(
|
||||
update.Guid,
|
||||
aeForVelocity,
|
||||
rmState,
|
||||
rmState.ServerVelocity);
|
||||
}
|
||||
|
||||
entity.Position = rmState.Body.Position;
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
|
|
@ -5009,7 +5393,114 @@ public sealed class GameWindow : IDisposable
|
|||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
|
||||
{
|
||||
double velocityAge = nowSec - rm.LastServerPosTime;
|
||||
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
||||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
ApplyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Body.Velocity = rm.ServerVelocity;
|
||||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive
|
||||
&& rm.HasMoveToDestination)
|
||||
{
|
||||
// Phase L.1c port of retail MoveToManager per-tick
|
||||
// steering (HandleMoveToPosition @ 0x00529d80).
|
||||
// Steer body orientation toward the latest
|
||||
// server-supplied destination, then let
|
||||
// apply_current_movement set Velocity from the
|
||||
// RunForward cycle through the now-correct heading.
|
||||
|
||||
// Stale-destination guard (2026-04-28): if no
|
||||
// MoveTo packet has refreshed the destination
|
||||
// recently, the entity has likely left our
|
||||
// streaming view or the server cancelled the
|
||||
// move without us seeing the cancel UM. Continuing
|
||||
// to steer toward a stale point produces the
|
||||
// "monster runs in place after popping back into
|
||||
// view" symptom. Clear and stand down.
|
||||
double moveToAge = nowSec - rm.LastMoveToPacketTime;
|
||||
if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds)
|
||||
{
|
||||
rm.HasMoveToDestination = false;
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.Drive(
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.MoveToDestinationWorld,
|
||||
rm.MoveToMinDistance,
|
||||
rm.MoveToDistanceToObject,
|
||||
(float)dt,
|
||||
rm.MoveToMoveTowards,
|
||||
out var steeredOrientation);
|
||||
rm.Body.Orientation = steeredOrientation;
|
||||
|
||||
if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.DriveResult.Arrived)
|
||||
{
|
||||
// Within arrival window — zero velocity until the
|
||||
// next MoveTo packet refreshes the destination
|
||||
// (or the server explicitly stops us with an
|
||||
// interpreted-motion UM cmd=Ready).
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Steering active — apply_current_movement reads
|
||||
// InterpretedState.ForwardCommand=RunForward (set
|
||||
// when the MoveTo packet arrived) and emits
|
||||
// velocity along +Y in body local space. Our
|
||||
// updated orientation rotates that into the right
|
||||
// world direction toward the target.
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
|
||||
// Clamp horizontal velocity so we don't overshoot
|
||||
// the arrival threshold during the final tick of
|
||||
// approach. Without this, a 4 m/s body advances
|
||||
// ~6 cm/tick and visibly runs slightly through
|
||||
// the target before the swing UM lands.
|
||||
float arrivalThreshold = rm.MoveToMoveTowards
|
||||
? rm.MoveToDistanceToObject
|
||||
: rm.MoveToMinDistance;
|
||||
rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver
|
||||
.ClampApproachVelocity(
|
||||
rm.Body.Position,
|
||||
rm.Body.Velocity,
|
||||
rm.MoveToDestinationWorld,
|
||||
arrivalThreshold,
|
||||
(float)dt,
|
||||
rm.MoveToMoveTowards);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
|
||||
{
|
||||
// MoveTo flag set but we haven't seen a path payload
|
||||
// yet (e.g. truncated packet, or a brand-new entity
|
||||
// whose first cycle UM is still in flight). Hold
|
||||
// velocity at zero — same conservative stance as the
|
||||
// 882a07c stabilizer for incomplete state.
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -6081,6 +6572,26 @@ public sealed class GameWindow : IDisposable
|
|||
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster:
|
||||
SelectClosestCombatTarget(showToast: true);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||||
ToggleLiveCombatMode();
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
|
||||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
|
||||
break;
|
||||
|
||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
|
|
@ -6098,6 +6609,123 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void ToggleLiveCombatMode()
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
|
||||
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
|
||||
_liveSession.SendChangeCombatMode(nextMode);
|
||||
Combat.SetCombatMode(nextMode);
|
||||
string text = $"Combat mode {nextMode}";
|
||||
Console.WriteLine($"combat: {text}");
|
||||
_debugVm?.AddToast(text);
|
||||
}
|
||||
|
||||
private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
return;
|
||||
|
||||
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
|
||||
{
|
||||
_debugVm?.AddToast("Enter melee or missile combat first");
|
||||
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
|
||||
return;
|
||||
}
|
||||
|
||||
uint? target = GetSelectedOrClosestCombatTarget();
|
||||
if (target is null)
|
||||
{
|
||||
_debugVm?.AddToast("No monster target");
|
||||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||
return;
|
||||
}
|
||||
|
||||
var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
|
||||
const float FullBar = 1.0f;
|
||||
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
|
||||
{
|
||||
_liveSession.SendMissileAttack(target.Value, height, FullBar);
|
||||
Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_liveSession.SendMeleeAttack(target.Value, height, FullBar);
|
||||
Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
private uint? GetSelectedOrClosestCombatTarget()
|
||||
{
|
||||
if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected))
|
||||
return selected;
|
||||
|
||||
return SelectClosestCombatTarget(showToast: false);
|
||||
}
|
||||
|
||||
private uint? SelectClosestCombatTarget(bool showToast)
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||||
return null;
|
||||
|
||||
uint? bestGuid = null;
|
||||
float bestDistanceSq = float.PositiveInfinity;
|
||||
foreach (var (guid, entity) in _entitiesByServerGuid)
|
||||
{
|
||||
if (!IsLiveCreatureTarget(guid))
|
||||
continue;
|
||||
|
||||
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
||||
entity.Position,
|
||||
playerEntity.Position);
|
||||
if (distanceSq >= bestDistanceSq)
|
||||
continue;
|
||||
|
||||
bestDistanceSq = distanceSq;
|
||||
bestGuid = guid;
|
||||
}
|
||||
|
||||
_selectedTargetGuid = bestGuid;
|
||||
if (bestGuid is { } selected)
|
||||
{
|
||||
string label = DescribeLiveEntity(selected);
|
||||
float distance = MathF.Sqrt(bestDistanceSq);
|
||||
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
||||
if (showToast)
|
||||
_debugVm?.AddToast($"Target {label}");
|
||||
}
|
||||
else if (showToast)
|
||||
{
|
||||
_debugVm?.AddToast("No monster target");
|
||||
Console.WriteLine("combat: no creature target found");
|
||||
}
|
||||
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
private bool IsLiveCreatureTarget(uint guid)
|
||||
{
|
||||
if (guid == _playerServerGuid)
|
||||
return false;
|
||||
if (!_entitiesByServerGuid.ContainsKey(guid))
|
||||
return false;
|
||||
if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info))
|
||||
return false;
|
||||
|
||||
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
private string DescribeLiveEntity(uint guid)
|
||||
{
|
||||
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
|
||||
&& !string.IsNullOrWhiteSpace(info.Name))
|
||||
return info.Name!;
|
||||
return $"0x{guid:X8}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
||||
/// subscriber can call it. Same body as the previous Tab branch in
|
||||
|
|
|
|||
|
|
@ -156,22 +156,20 @@ public static class GameEventWiring
|
|||
dispatcher.Register(GameEventType.VictimNotification, e =>
|
||||
{
|
||||
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
|
||||
if (p is not null) combat.OnVictimNotification(
|
||||
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
|
||||
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
|
||||
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
|
||||
});
|
||||
dispatcher.Register(GameEventType.DefenderNotification, e =>
|
||||
{
|
||||
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
|
||||
if (p is not null) combat.OnDefenderNotification(
|
||||
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
|
||||
p.Value.AttackerName, 0u, p.Value.DamageType,
|
||||
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
|
||||
});
|
||||
dispatcher.Register(GameEventType.AttackerNotification, e =>
|
||||
{
|
||||
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
|
||||
if (p is not null) combat.OnAttackerNotification(
|
||||
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent);
|
||||
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent);
|
||||
});
|
||||
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
|
||||
{
|
||||
|
|
@ -188,12 +186,15 @@ public static class GameEventWiring
|
|||
var p = GameEvents.ParseAttackDone(e.Payload.Span);
|
||||
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
|
||||
});
|
||||
dispatcher.Register(GameEventType.CombatCommenceAttack, e =>
|
||||
{
|
||||
if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span))
|
||||
combat.OnCombatCommenceAttack();
|
||||
});
|
||||
dispatcher.Register(GameEventType.KillerNotification, e =>
|
||||
{
|
||||
// ISSUES.md #10 — orphan parser, never registered before. The
|
||||
// server fires this after a player lands a killing blow.
|
||||
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
|
||||
if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid);
|
||||
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
|
||||
});
|
||||
|
||||
// ── Spells ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -3,60 +3,79 @@ using System.Buffers.Binary;
|
|||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound <c>0x0008 AttackTargetRequest</c> GameAction.
|
||||
/// Outbound combat attack GameActions.
|
||||
///
|
||||
/// Retail/ACE use distinct payloads for melee and missile:
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (inside the <c>0xF7B1</c> GameAction envelope):
|
||||
/// <code>
|
||||
/// u32 0xF7B1 // GameAction envelope opcode
|
||||
/// u32 gameActionSequence // client sequence
|
||||
/// u32 0x0008 // sub-opcode
|
||||
/// u32 targetGuid // who to attack
|
||||
/// f32 powerLevel // [0.0, 1.0] — the power bar position
|
||||
/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons
|
||||
/// u32 0x0008 // TargetedMeleeAttack
|
||||
/// u32 targetGuid
|
||||
/// u32 attackHeight // 1=High, 2=Medium, 3=Low
|
||||
/// f32 powerLevel // [0.0, 1.0]
|
||||
///
|
||||
/// u32 0xF7B1
|
||||
/// u32 gameActionSequence
|
||||
/// u32 0x000A // TargetedMissileAttack
|
||||
/// u32 targetGuid
|
||||
/// u32 attackHeight
|
||||
/// f32 accuracyLevel // [0.0, 1.0]
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The server ALREADY knows the attacker (it's the session's player),
|
||||
/// so this message only carries the target + attack params. The server
|
||||
/// then rolls damage, picks a body part, and broadcasts
|
||||
/// <see cref="GameEventType.VictimNotification"/> / AttackerNotification
|
||||
/// / DefenderNotification / EvasionAttackerNotification /
|
||||
/// EvasionDefenderNotification with the result.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
|
||||
/// </para>
|
||||
/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
|
||||
/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
|
||||
/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
|
||||
/// holtburger protocol game_action.rs.
|
||||
/// </summary>
|
||||
public static class AttackTargetRequest
|
||||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
public const uint SubOpcode = 0x0008u;
|
||||
public const uint TargetedMeleeAttackOpcode = 0x0008u;
|
||||
public const uint TargetedMissileAttackOpcode = 0x000Au;
|
||||
public const uint CancelAttackOpcode = 0x01B7u;
|
||||
|
||||
/// <summary>
|
||||
/// Build the wire body for an attack request.
|
||||
/// </summary>
|
||||
/// <param name="powerLevel">[0..1] melee power bar position.</param>
|
||||
/// <param name="accuracyLevel">[0..1] missile accuracy bar position; pass 0 for melee.</param>
|
||||
/// <param name="attackHeight">1=High, 2=Medium, 3=Low.</param>
|
||||
public static byte[] Build(
|
||||
/// <summary>Build the wire body for a targeted melee attack.</summary>
|
||||
public static byte[] BuildMelee(
|
||||
uint gameActionSequence,
|
||||
uint targetGuid,
|
||||
float powerLevel,
|
||||
float accuracyLevel,
|
||||
uint attackHeight)
|
||||
uint attackHeight,
|
||||
float powerLevel)
|
||||
{
|
||||
byte[] body = new byte[28];
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), powerLevel);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Build the wire body for a targeted missile attack.</summary>
|
||||
public static byte[] BuildMissile(
|
||||
uint gameActionSequence,
|
||||
uint targetGuid,
|
||||
uint attackHeight,
|
||||
float accuracyLevel)
|
||||
{
|
||||
byte[] body = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMissileAttackOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Build the wire body for cancelling an active attack request.</summary>
|
||||
public static byte[] BuildCancel(uint gameActionSequence)
|
||||
{
|
||||
byte[] body = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), CancelAttackOpcode);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,17 @@ public static class CharacterActions
|
|||
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
|
||||
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
|
||||
|
||||
[Flags]
|
||||
public enum CombatMode : uint
|
||||
{
|
||||
Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5,
|
||||
Undef = 0,
|
||||
NonCombat = 0x01,
|
||||
Melee = 0x02,
|
||||
Missile = 0x04,
|
||||
Magic = 0x08,
|
||||
|
||||
ValidCombat = NonCombat | Melee | Missile | Magic,
|
||||
CombatCombat = Melee | Missile | Magic,
|
||||
}
|
||||
|
||||
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// All other fields (weenie header, object description, motion tables,
|
||||
/// Most other fields (extended weenie header, object description, motion tables,
|
||||
/// palettes, texture overrides, animation frames, velocity, ...) are
|
||||
/// consumed-but-ignored so the parse position ends up wherever the
|
||||
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
||||
/// the end of the body to return useful output. We stop after PhysicsData
|
||||
/// since that's the last segment containing fields acdream cares about
|
||||
/// in this phase.
|
||||
/// the end of the body to return useful output. We read through the fixed
|
||||
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
|
||||
/// tails.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -51,6 +51,8 @@ public static class CreateObject
|
|||
public const uint PaletteTypePrefix = 0x04000000u;
|
||||
/// <summary>SurfaceTexture dat id type prefix.</summary>
|
||||
public const uint SurfaceTextureTypePrefix = 0x05000000u;
|
||||
/// <summary>Icon dat id type prefix.</summary>
|
||||
public const uint IconTypePrefix = 0x06000000u;
|
||||
|
||||
[Flags]
|
||||
public enum PhysicsDescriptionFlag : uint
|
||||
|
|
@ -78,9 +80,9 @@ public static class CreateObject
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The three fields acdream cares about. Position and SetupTableId are
|
||||
/// nullable because their corresponding physics-description-flag bits
|
||||
/// may not be set on every CreateObject.
|
||||
/// The spawn fields acdream currently cares about. Position and
|
||||
/// SetupTableId are nullable because their corresponding
|
||||
/// physics-description-flag bits may not be set on every CreateObject.
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(
|
||||
uint Guid,
|
||||
|
|
@ -92,6 +94,7 @@ public static class CreateObject
|
|||
uint? BasePaletteId,
|
||||
float? ObjScale,
|
||||
string? Name,
|
||||
uint? ItemType,
|
||||
ServerMotionState? MotionState,
|
||||
uint? MotionTableId,
|
||||
ushort InstanceSequence = 0,
|
||||
|
|
@ -136,7 +139,59 @@ public static class CreateObject
|
|||
ushort? SideStepCommand = null,
|
||||
float? SideStepSpeed = null,
|
||||
ushort? TurnCommand = null,
|
||||
float? TurnSpeed = null);
|
||||
float? TurnSpeed = null,
|
||||
byte MovementType = 0,
|
||||
uint? MoveToParameters = null,
|
||||
float? MoveToSpeed = null,
|
||||
float? MoveToRunRate = null,
|
||||
MoveToPathData? MoveToPath = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// ACE/retail movement types 6 and 7 are server-controlled
|
||||
/// MoveToObject/MoveToPosition packets. Their union body does not
|
||||
/// carry an InterpretedMotionState.ForwardCommand, so command absence
|
||||
/// is not a stop signal.
|
||||
/// </summary>
|
||||
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
|
||||
|
||||
public bool MoveToCanRun => !MoveToParameters.HasValue
|
||||
|| (MoveToParameters.Value & 0x2u) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// MovementParameters bit 9 (mask 0x200) — set when the creature is
|
||||
/// chasing its target. Cross-checked against acclient.h:31423-31443
|
||||
/// (named retail) + ACE <c>MovementParamFlags.MoveTowards</c>.
|
||||
/// </summary>
|
||||
public bool MoveTowards => MoveToParameters.HasValue
|
||||
&& (MoveToParameters.Value & 0x200u) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
|
||||
/// Wire layout per <c>MovementParameters::UnPackNet</c> @ <c>0x0052ac50</c>
|
||||
/// + the leading <c>Origin</c> + optional target guid for type 6:
|
||||
/// <list type="bullet">
|
||||
/// <item>type 6 (MoveToObject) only: u32 <c>TargetGuid</c></item>
|
||||
/// <item>Origin: u32 <c>cellId</c>, then 3 floats (local x/y/z within the landblock)</item>
|
||||
/// <item>MovementParameters (28 bytes, exact retail order):
|
||||
/// u32 flags, f32 <c>distance_to_object</c>, f32 <c>min_distance</c>,
|
||||
/// f32 <c>fail_distance</c>, f32 <c>speed</c>, f32 <c>walk_run_threshhold</c>,
|
||||
/// f32 <c>desired_heading</c></item>
|
||||
/// </list>
|
||||
/// (The trailing <c>runRate</c> float is captured separately on
|
||||
/// <see cref="ServerMotionState.MoveToRunRate"/>.)
|
||||
/// </summary>
|
||||
public readonly record struct MoveToPathData(
|
||||
uint? TargetGuid,
|
||||
uint OriginCellId,
|
||||
float OriginX,
|
||||
float OriginY,
|
||||
float OriginZ,
|
||||
float DistanceToObject,
|
||||
float MinDistance,
|
||||
float FailDistance,
|
||||
float WalkRunThreshold,
|
||||
float DesiredHeading);
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||||
|
|
@ -390,27 +445,39 @@ public static class CreateObject
|
|||
pos += 9 * 2;
|
||||
AlignTo4(ref pos);
|
||||
|
||||
// --- WeenieHeader: read just the Name field (second after flags). ---
|
||||
// --- WeenieHeader: read the fixed prefix fields we need. ---
|
||||
// ACE WorldObject_Networking.SerializeCreateObject writes:
|
||||
// weenieFlags, Name, WeenieClassId(PackedDword),
|
||||
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
|
||||
// ObjectDescriptionFlags, align.
|
||||
string? name = null;
|
||||
uint? itemType = null;
|
||||
if (body.Length - pos >= 4)
|
||||
{
|
||||
pos += 4; // skip weenieFlags u32
|
||||
try
|
||||
{
|
||||
name = ReadString16L(body, ref pos);
|
||||
_ = ReadPackedDword(body, ref pos); // WeenieClassId
|
||||
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||||
if (body.Length - pos >= 4)
|
||||
itemType = ReadU32(body, ref pos);
|
||||
if (body.Length - pos >= 4)
|
||||
_ = ReadU32(body, ref pos); // ObjectDescriptionFlags
|
||||
AlignTo4(ref pos);
|
||||
}
|
||||
catch { /* truncated name — partial result is still useful */ }
|
||||
}
|
||||
|
||||
return new Parsed(guid, position, setupTableId, animParts,
|
||||
textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId,
|
||||
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
|
||||
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
|
||||
|
||||
// Local helper: if we ran out of fields past PhysicsData, still
|
||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||
Parsed PartialResult() => new(
|
||||
guid, position, setupTableId, animParts,
|
||||
textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId);
|
||||
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -528,6 +595,9 @@ public static class CreateObject
|
|||
float? sidestepSpeed = null;
|
||||
ushort? turnCommand = null;
|
||||
float? turnSpeed = null;
|
||||
uint? moveToParameters = null;
|
||||
float? moveToSpeed = null;
|
||||
float? moveToRunRate = null;
|
||||
List<MotionItem>? commands = null;
|
||||
|
||||
// 0 = Invalid is the only union variant we care about for static
|
||||
|
|
@ -630,14 +700,62 @@ public static class CreateObject
|
|||
}
|
||||
done:;
|
||||
}
|
||||
else if (movementType is 6 or 7)
|
||||
{
|
||||
TryParseMoveToPayload(
|
||||
mv,
|
||||
p,
|
||||
movementType,
|
||||
out moveToParameters,
|
||||
out moveToSpeed,
|
||||
out moveToRunRate);
|
||||
}
|
||||
|
||||
return new ServerMotionState(
|
||||
currentStyle, forwardCommand, forwardSpeed, commands,
|
||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
|
||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
|
||||
movementType,
|
||||
moveToParameters,
|
||||
moveToSpeed,
|
||||
moveToRunRate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseMoveToPayload(
|
||||
ReadOnlySpan<byte> body,
|
||||
int pos,
|
||||
byte movementType,
|
||||
out uint? movementParameters,
|
||||
out float? speed,
|
||||
out float? runRate)
|
||||
{
|
||||
movementParameters = null;
|
||||
speed = null;
|
||||
runRate = null;
|
||||
|
||||
if (movementType == 6)
|
||||
{
|
||||
if (body.Length - pos < 4) return false;
|
||||
pos += 4; // target guid
|
||||
}
|
||||
|
||||
if (body.Length - pos < 16 + 28 + 4) return false;
|
||||
pos += 16; // Origin
|
||||
|
||||
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
pos += 4; // distanceToObject
|
||||
pos += 4; // minDistance
|
||||
pos += 4; // failDistance
|
||||
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
pos += 4; // walkRunThreshold
|
||||
pos += 4; // desiredHeading
|
||||
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/AcDream.Core.Net/Messages/DeleteObject.cs
Normal file
39
src/AcDream.Core.Net/Messages/DeleteObject.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound <c>ObjectDelete</c> GameMessage (opcode <c>0xF747</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail dispatch path:
|
||||
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 reads guid from
|
||||
/// <c>buf+4</c> and instance sequence from <c>buf+8</c>, then calls
|
||||
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0. ACE emits the same
|
||||
/// layout from <c>GameMessageDeleteObject</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DeleteObject
|
||||
{
|
||||
public const uint Opcode = 0xF747u;
|
||||
|
||||
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
|
||||
/// 4-byte opcode, matching every other parser in this namespace.
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < 10)
|
||||
return null;
|
||||
|
||||
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
|
||||
if (opcode != Opcode)
|
||||
return null;
|
||||
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
|
||||
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
|
||||
return new Parsed(guid, instanceSequence);
|
||||
}
|
||||
}
|
||||
|
|
@ -147,56 +147,34 @@ public static class GameEvents
|
|||
|
||||
// ── Combat notifications ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x01AC VictimNotification — "you got hit for X".</summary>
|
||||
public readonly record struct VictimNotification(
|
||||
string AttackerName,
|
||||
uint AttackerGuid,
|
||||
uint DamageType,
|
||||
uint Damage,
|
||||
uint HitQuadrant,
|
||||
uint Critical,
|
||||
uint AttackType);
|
||||
/// <summary>0x01AC VictimNotification - death message for the victim.</summary>
|
||||
public readonly record struct VictimNotification(string DeathMessage);
|
||||
|
||||
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
if (payload.Length - pos < 24) return null;
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint atkType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
return new VictimNotification(name, guid, damageType, damage, quad, crit, atkType);
|
||||
}
|
||||
try { return new VictimNotification(ReadString16L(payload, ref pos)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01AD KillerNotification — "you killed X".</summary>
|
||||
public readonly record struct KillerNotification(string VictimName, uint VictimGuid);
|
||||
/// <summary>0x01AD KillerNotification - death message for the killer.</summary>
|
||||
public readonly record struct KillerNotification(string DeathMessage);
|
||||
|
||||
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
if (payload.Length - pos < 4) return null;
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
|
||||
return new KillerNotification(name, guid);
|
||||
}
|
||||
try { return new KillerNotification(ReadString16L(payload, ref pos)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01B1 AttackerNotification — "you hit X for Y%".</summary>
|
||||
/// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
|
||||
public readonly record struct AttackerNotification(
|
||||
string DefenderName,
|
||||
uint DamageType,
|
||||
double HealthPercent,
|
||||
uint Damage,
|
||||
float DamagePercent);
|
||||
uint Critical,
|
||||
ulong AttackConditions);
|
||||
|
||||
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
|
|
@ -204,23 +182,26 @@ public static class GameEvents
|
|||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
if (payload.Length - pos < 12) return null;
|
||||
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4;
|
||||
return new AttackerNotification(name, damageType, damage, pct);
|
||||
if (payload.Length - pos < 28) return null;
|
||||
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
|
||||
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
|
||||
return new AttackerNotification(name, damageType, pct, damage, crit, cond);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01B2 DefenderNotification — "X hit you for Y".</summary>
|
||||
/// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
|
||||
public readonly record struct DefenderNotification(
|
||||
string AttackerName,
|
||||
uint AttackerGuid,
|
||||
uint DamageType,
|
||||
double HealthPercent,
|
||||
uint Damage,
|
||||
uint HitQuadrant,
|
||||
uint Critical);
|
||||
uint Critical,
|
||||
ulong AttackConditions);
|
||||
|
||||
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
|
|
@ -228,40 +209,42 @@ public static class GameEvents
|
|||
try
|
||||
{
|
||||
string name = ReadString16L(payload, ref pos);
|
||||
if (payload.Length - pos < 20) return null;
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
return new DefenderNotification(name, guid, dtype, dmg, quad, crit);
|
||||
if (payload.Length - pos < 32) return null;
|
||||
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
|
||||
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
|
||||
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
|
||||
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01B3 EvasionAttackerNotification — "X evaded".</summary>
|
||||
/// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
|
||||
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01B4 EvasionDefenderNotification — "you evaded X".</summary>
|
||||
/// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
|
||||
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int pos = 0;
|
||||
try { return ReadString16L(payload, ref pos); } catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>0x01A7 AttackDone — (attackSequence, weenieError).</summary>
|
||||
/// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
|
||||
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
|
||||
|
||||
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
|
||||
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
|
||||
|
||||
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
return new AttackDone(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
if (payload.Length < 4) return null;
|
||||
return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
|
||||
}
|
||||
|
||||
// ── Spell enchantments ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ public static class UpdateMotion
|
|||
float? sidestepSpeed = null;
|
||||
ushort? turnCommand = null;
|
||||
float? turnSpeed = null;
|
||||
uint? moveToParameters = null;
|
||||
float? moveToSpeed = null;
|
||||
float? moveToRunRate = null;
|
||||
CreateObject.MoveToPathData? moveToPath = null;
|
||||
List<CreateObject.MotionItem>? commands = null;
|
||||
|
||||
if (movementType == 0)
|
||||
|
|
@ -135,7 +139,7 @@ public static class UpdateMotion
|
|||
// MovementInvalid branch, just reached via the header'd path.
|
||||
// Includes the Commands list (MotionItem[]) that carries
|
||||
// Actions, emotes, and other one-shots not in ForwardCommand.
|
||||
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
||||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
uint flags = packed & 0x7Fu;
|
||||
|
|
@ -158,13 +162,13 @@ public static class UpdateMotion
|
|||
|
||||
if ((flags & 0x1u) != 0)
|
||||
{
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
||||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
}
|
||||
if ((flags & 0x2u) != 0)
|
||||
{
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
|
||||
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
}
|
||||
|
|
@ -221,14 +225,108 @@ public static class UpdateMotion
|
|||
}
|
||||
done:;
|
||||
}
|
||||
else if (movementType is 6 or 7)
|
||||
{
|
||||
TryParseMoveToPayload(
|
||||
body,
|
||||
pos,
|
||||
movementType,
|
||||
out moveToParameters,
|
||||
out moveToSpeed,
|
||||
out moveToRunRate,
|
||||
out moveToPath);
|
||||
}
|
||||
|
||||
return new Parsed(guid, new CreateObject.ServerMotionState(
|
||||
currentStyle, forwardCommand, forwardSpeed, commands,
|
||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
|
||||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
|
||||
movementType,
|
||||
moveToParameters,
|
||||
moveToSpeed,
|
||||
moveToRunRate,
|
||||
moveToPath));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseMoveToPayload(
|
||||
ReadOnlySpan<byte> body,
|
||||
int pos,
|
||||
byte movementType,
|
||||
out uint? movementParameters,
|
||||
out float? speed,
|
||||
out float? runRate,
|
||||
out CreateObject.MoveToPathData? path)
|
||||
{
|
||||
movementParameters = null;
|
||||
speed = null;
|
||||
runRate = null;
|
||||
path = null;
|
||||
|
||||
// Retail MovementManager::PerformMovement (0x00524440) consumes
|
||||
// MoveToObject/MoveToPosition as:
|
||||
// [object guid, for MoveToObject only]
|
||||
// Origin(cell + xyz)
|
||||
// MovementParameters::UnPackNet (0x0052AC50): flags, distance,
|
||||
// min, fail, speed, walk/run threshold, desired heading
|
||||
// f32 runRate copied into CMotionInterp::my_run_rate.
|
||||
//
|
||||
// Phase L.1c (2026-04-28): the full path payload is now retained on
|
||||
// <see cref="CreateObject.MoveToPathData"/> so the per-tick remote
|
||||
// body driver can steer toward Origin instead of holding velocity at
|
||||
// zero between sparse UpdatePosition snaps. The 882a07c stabilizer
|
||||
// was deliberately conservative because we only had speed+runRate;
|
||||
// with the rest of the packet captured, the body solver has full
|
||||
// path data and can run faithfully.
|
||||
uint? targetGuid = null;
|
||||
if (movementType == 6)
|
||||
{
|
||||
if (body.Length - pos < 4) return false;
|
||||
targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
if (body.Length - pos < 16 + 28 + 4) return false;
|
||||
|
||||
uint originCellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float originX = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float originY = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float originZ = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
|
||||
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
|
||||
path = new CreateObject.MoveToPathData(
|
||||
targetGuid,
|
||||
originCellId,
|
||||
originX,
|
||||
originY,
|
||||
originZ,
|
||||
distanceToObject,
|
||||
minDistance,
|
||||
failDistance,
|
||||
walkRunThreshold,
|
||||
desiredHeading);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Net.Cryptography;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
|
@ -53,12 +54,23 @@ public sealed class WorldSession : IDisposable
|
|||
uint? BasePaletteId,
|
||||
float? ObjScale,
|
||||
string? Name,
|
||||
uint? ItemType,
|
||||
CreateObject.ServerMotionState? MotionState,
|
||||
uint? MotionTableId);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the session parses a 0xF747 ObjectDelete game message.
|
||||
/// Retail routes this through
|
||||
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 →
|
||||
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0; ACE emits it when
|
||||
/// an object leaves the world, including the living creature object
|
||||
/// after its corpse is created.
|
||||
/// </summary>
|
||||
public event Action<DeleteObject.Parsed>? EntityDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
||||
/// whose motion changed and its new server-side stance + forward command.
|
||||
|
|
@ -634,10 +646,17 @@ public sealed class WorldSession : IDisposable
|
|||
parsed.Value.BasePaletteId,
|
||||
parsed.Value.ObjScale,
|
||||
parsed.Value.Name,
|
||||
parsed.Value.ItemType,
|
||||
parsed.Value.MotionState,
|
||||
parsed.Value.MotionTableId));
|
||||
}
|
||||
}
|
||||
else if (op == DeleteObject.Opcode)
|
||||
{
|
||||
var parsed = DeleteObject.TryParse(body);
|
||||
if (parsed is not null)
|
||||
EntityDeleted?.Invoke(parsed.Value);
|
||||
}
|
||||
else if (op == UpdateMotion.Opcode)
|
||||
{
|
||||
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
||||
|
|
@ -909,6 +928,48 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = CharacterActions.BuildChangeCombatMode(
|
||||
seq,
|
||||
(CharacterActions.CombatMode)(uint)mode);
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
|
||||
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = AttackTargetRequest.BuildMelee(
|
||||
seq,
|
||||
targetGuid,
|
||||
(uint)attackHeight,
|
||||
powerLevel);
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail TargetedMissileAttack (0x000A).</summary>
|
||||
public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = AttackTargetRequest.BuildMissile(
|
||||
seq,
|
||||
targetGuid,
|
||||
(uint)attackHeight,
|
||||
accuracyLevel);
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail CancelAttack (0x01B7).</summary>
|
||||
public void SendCancelAttack()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = AttackTargetRequest.BuildCancel(seq);
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
|
||||
/// global community room (General / Trade / LFG / Roleplay /
|
||||
|
|
|
|||
308
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
308
src/AcDream.Core/Combat/CombatAnimationPlanner.cs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-faithful combat animation planner for server-sent motion commands.
|
||||
///
|
||||
/// Retail evidence:
|
||||
/// - <c>ClientCombatSystem::ExecuteAttack</c> (0x0056BB70) only sends the
|
||||
/// targeted melee/missile GameAction and sets response state; it does not
|
||||
/// locally choose or play a swing animation.
|
||||
/// - <c>ClientCombatSystem::HandleCommenceAttackEvent</c> (0x0056AD20)
|
||||
/// updates the power bar/busy state; it carries no MotionCommand.
|
||||
/// - ACE <c>Player_Melee.DoSwingMotion</c> chooses a swing via
|
||||
/// <c>CombatManeuverTable.GetMotion</c> and broadcasts that MotionCommand
|
||||
/// in <c>UpdateMotion</c>.
|
||||
///
|
||||
/// So acdream treats combat GameEvents as state/UI signals and treats
|
||||
/// UpdateMotion command IDs as the animation authority.
|
||||
/// </summary>
|
||||
public static class CombatAnimationPlanner
|
||||
{
|
||||
public static CombatAnimationPlan PlanForEvent(CombatAnimationEvent combatEvent)
|
||||
{
|
||||
_ = combatEvent;
|
||||
return CombatAnimationPlan.None;
|
||||
}
|
||||
|
||||
public static CombatAnimationPlan PlanFromWireCommand(ushort wireCommand, float speedMod = 1f)
|
||||
{
|
||||
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
|
||||
return PlanFromFullCommand(fullCommand, speedMod);
|
||||
}
|
||||
|
||||
public static CombatAnimationPlan PlanFromFullCommand(uint fullCommand, float speedMod = 1f)
|
||||
{
|
||||
var kind = ClassifyMotionCommand(fullCommand);
|
||||
if (kind == CombatAnimationKind.None)
|
||||
return CombatAnimationPlan.None;
|
||||
|
||||
return new CombatAnimationPlan(
|
||||
kind,
|
||||
AnimationCommandRouter.Classify(fullCommand),
|
||||
fullCommand,
|
||||
speedMod);
|
||||
}
|
||||
|
||||
public static CombatAnimationKind ClassifyMotionCommand(uint fullCommand)
|
||||
{
|
||||
return fullCommand switch
|
||||
{
|
||||
CombatAnimationMotionCommands.HandCombat
|
||||
or CombatAnimationMotionCommands.SwordCombat
|
||||
or CombatAnimationMotionCommands.SwordShieldCombat
|
||||
or CombatAnimationMotionCommands.TwoHandedSwordCombat
|
||||
or CombatAnimationMotionCommands.TwoHandedStaffCombat
|
||||
or CombatAnimationMotionCommands.BowCombat
|
||||
or CombatAnimationMotionCommands.CrossbowCombat
|
||||
or CombatAnimationMotionCommands.SlingCombat
|
||||
or CombatAnimationMotionCommands.DualWieldCombat
|
||||
or CombatAnimationMotionCommands.ThrownWeaponCombat
|
||||
or CombatAnimationMotionCommands.AtlatlCombat
|
||||
or CombatAnimationMotionCommands.ThrownShieldCombat
|
||||
or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance,
|
||||
|
||||
CombatAnimationMotionCommands.ThrustMed
|
||||
or CombatAnimationMotionCommands.ThrustLow
|
||||
or CombatAnimationMotionCommands.ThrustHigh
|
||||
or CombatAnimationMotionCommands.SlashHigh
|
||||
or CombatAnimationMotionCommands.SlashMed
|
||||
or CombatAnimationMotionCommands.SlashLow
|
||||
or CombatAnimationMotionCommands.BackhandHigh
|
||||
or CombatAnimationMotionCommands.BackhandMed
|
||||
or CombatAnimationMotionCommands.BackhandLow
|
||||
or CombatAnimationMotionCommands.DoubleSlashLow
|
||||
or CombatAnimationMotionCommands.DoubleSlashMed
|
||||
or CombatAnimationMotionCommands.DoubleSlashHigh
|
||||
or CombatAnimationMotionCommands.TripleSlashLow
|
||||
or CombatAnimationMotionCommands.TripleSlashMed
|
||||
or CombatAnimationMotionCommands.TripleSlashHigh
|
||||
or CombatAnimationMotionCommands.DoubleThrustLow
|
||||
or CombatAnimationMotionCommands.DoubleThrustMed
|
||||
or CombatAnimationMotionCommands.DoubleThrustHigh
|
||||
or CombatAnimationMotionCommands.TripleThrustLow
|
||||
or CombatAnimationMotionCommands.TripleThrustMed
|
||||
or CombatAnimationMotionCommands.TripleThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandSlashHigh
|
||||
or CombatAnimationMotionCommands.OffhandSlashMed
|
||||
or CombatAnimationMotionCommands.OffhandSlashLow
|
||||
or CombatAnimationMotionCommands.OffhandThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandThrustMed
|
||||
or CombatAnimationMotionCommands.OffhandThrustLow
|
||||
or CombatAnimationMotionCommands.OffhandDoubleSlashLow
|
||||
or CombatAnimationMotionCommands.OffhandDoubleSlashMed
|
||||
or CombatAnimationMotionCommands.OffhandDoubleSlashHigh
|
||||
or CombatAnimationMotionCommands.OffhandTripleSlashLow
|
||||
or CombatAnimationMotionCommands.OffhandTripleSlashMed
|
||||
or CombatAnimationMotionCommands.OffhandTripleSlashHigh
|
||||
or CombatAnimationMotionCommands.OffhandDoubleThrustLow
|
||||
or CombatAnimationMotionCommands.OffhandDoubleThrustMed
|
||||
or CombatAnimationMotionCommands.OffhandDoubleThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandTripleThrustLow
|
||||
or CombatAnimationMotionCommands.OffhandTripleThrustMed
|
||||
or CombatAnimationMotionCommands.OffhandTripleThrustHigh
|
||||
or CombatAnimationMotionCommands.OffhandKick
|
||||
or CombatAnimationMotionCommands.PunchFastHigh
|
||||
or CombatAnimationMotionCommands.PunchFastMed
|
||||
or CombatAnimationMotionCommands.PunchFastLow
|
||||
or CombatAnimationMotionCommands.PunchSlowHigh
|
||||
or CombatAnimationMotionCommands.PunchSlowMed
|
||||
or CombatAnimationMotionCommands.PunchSlowLow
|
||||
or CombatAnimationMotionCommands.OffhandPunchFastHigh
|
||||
or CombatAnimationMotionCommands.OffhandPunchFastMed
|
||||
or CombatAnimationMotionCommands.OffhandPunchFastLow
|
||||
or CombatAnimationMotionCommands.OffhandPunchSlowHigh
|
||||
or CombatAnimationMotionCommands.OffhandPunchSlowMed
|
||||
or CombatAnimationMotionCommands.OffhandPunchSlowLow => CombatAnimationKind.MeleeSwing,
|
||||
|
||||
CombatAnimationMotionCommands.Shoot
|
||||
or CombatAnimationMotionCommands.MissileAttack1
|
||||
or CombatAnimationMotionCommands.MissileAttack2
|
||||
or CombatAnimationMotionCommands.MissileAttack3
|
||||
or CombatAnimationMotionCommands.Reload => CombatAnimationKind.MissileAttack,
|
||||
|
||||
CombatAnimationMotionCommands.AttackHigh1
|
||||
or CombatAnimationMotionCommands.AttackMed1
|
||||
or CombatAnimationMotionCommands.AttackLow1
|
||||
or CombatAnimationMotionCommands.AttackHigh2
|
||||
or CombatAnimationMotionCommands.AttackMed2
|
||||
or CombatAnimationMotionCommands.AttackLow2
|
||||
or CombatAnimationMotionCommands.AttackHigh3
|
||||
or CombatAnimationMotionCommands.AttackMed3
|
||||
or CombatAnimationMotionCommands.AttackLow3
|
||||
or CombatAnimationMotionCommands.AttackHigh4
|
||||
or CombatAnimationMotionCommands.AttackMed4
|
||||
or CombatAnimationMotionCommands.AttackLow4
|
||||
or CombatAnimationMotionCommands.AttackHigh5
|
||||
or CombatAnimationMotionCommands.AttackMed5
|
||||
or CombatAnimationMotionCommands.AttackLow5
|
||||
or CombatAnimationMotionCommands.AttackHigh6
|
||||
or CombatAnimationMotionCommands.AttackMed6
|
||||
or CombatAnimationMotionCommands.AttackLow6 => CombatAnimationKind.CreatureAttack,
|
||||
|
||||
CombatAnimationMotionCommands.CastSpell
|
||||
or CombatAnimationMotionCommands.UseMagicStaff
|
||||
or CombatAnimationMotionCommands.UseMagicWand => CombatAnimationKind.SpellCast,
|
||||
|
||||
CombatAnimationMotionCommands.FallDown
|
||||
or CombatAnimationMotionCommands.Twitch1
|
||||
or CombatAnimationMotionCommands.Twitch2
|
||||
or CombatAnimationMotionCommands.Twitch3
|
||||
or CombatAnimationMotionCommands.Twitch4
|
||||
or CombatAnimationMotionCommands.StaggerBackward
|
||||
or CombatAnimationMotionCommands.StaggerForward
|
||||
or CombatAnimationMotionCommands.Sanctuary => CombatAnimationKind.HitReaction,
|
||||
|
||||
MotionCommand.Dead => CombatAnimationKind.Death,
|
||||
|
||||
_ => CombatAnimationKind.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct CombatAnimationPlan(
|
||||
CombatAnimationKind Kind,
|
||||
AnimationCommandRouteKind RouteKind,
|
||||
uint MotionCommand,
|
||||
float SpeedMod)
|
||||
{
|
||||
public static CombatAnimationPlan None { get; } = new(
|
||||
CombatAnimationKind.None,
|
||||
AnimationCommandRouteKind.None,
|
||||
0u,
|
||||
0f);
|
||||
|
||||
public bool HasMotion => Kind != CombatAnimationKind.None && MotionCommand != 0;
|
||||
}
|
||||
|
||||
public enum CombatAnimationEvent
|
||||
{
|
||||
CombatCommenceAttack,
|
||||
AttackDone,
|
||||
AttackerNotification,
|
||||
DefenderNotification,
|
||||
EvasionAttackerNotification,
|
||||
EvasionDefenderNotification,
|
||||
VictimNotification,
|
||||
KillerNotification,
|
||||
}
|
||||
|
||||
public enum CombatAnimationKind
|
||||
{
|
||||
None = 0,
|
||||
CombatStance,
|
||||
MeleeSwing,
|
||||
MissileAttack,
|
||||
CreatureAttack,
|
||||
SpellCast,
|
||||
HitReaction,
|
||||
Death,
|
||||
}
|
||||
|
||||
internal static class CombatAnimationMotionCommands
|
||||
{
|
||||
public const uint HandCombat = 0x8000003Cu;
|
||||
public const uint SwordCombat = 0x8000003Eu;
|
||||
public const uint BowCombat = 0x8000003Fu;
|
||||
public const uint SwordShieldCombat = 0x80000040u;
|
||||
public const uint CrossbowCombat = 0x80000041u;
|
||||
public const uint SlingCombat = 0x80000043u;
|
||||
public const uint TwoHandedSwordCombat = 0x80000044u;
|
||||
public const uint TwoHandedStaffCombat = 0x80000045u;
|
||||
public const uint DualWieldCombat = 0x80000046u;
|
||||
public const uint ThrownWeaponCombat = 0x80000047u;
|
||||
public const uint Magic = 0x80000049u;
|
||||
public const uint AtlatlCombat = 0x8000013Bu;
|
||||
public const uint ThrownShieldCombat = 0x8000013Cu;
|
||||
|
||||
public const uint FallDown = 0x10000050u;
|
||||
public const uint Twitch1 = 0x10000051u;
|
||||
public const uint Twitch2 = 0x10000052u;
|
||||
public const uint Twitch3 = 0x10000053u;
|
||||
public const uint Twitch4 = 0x10000054u;
|
||||
public const uint StaggerBackward = 0x10000055u;
|
||||
public const uint StaggerForward = 0x10000056u;
|
||||
public const uint Sanctuary = 0x10000057u;
|
||||
public const uint ThrustMed = 0x10000058u;
|
||||
public const uint ThrustLow = 0x10000059u;
|
||||
public const uint ThrustHigh = 0x1000005Au;
|
||||
public const uint SlashHigh = 0x1000005Bu;
|
||||
public const uint SlashMed = 0x1000005Cu;
|
||||
public const uint SlashLow = 0x1000005Du;
|
||||
public const uint BackhandHigh = 0x1000005Eu;
|
||||
public const uint BackhandMed = 0x1000005Fu;
|
||||
public const uint BackhandLow = 0x10000060u;
|
||||
public const uint Shoot = 0x10000061u;
|
||||
public const uint AttackHigh1 = 0x10000062u;
|
||||
public const uint AttackMed1 = 0x10000063u;
|
||||
public const uint AttackLow1 = 0x10000064u;
|
||||
public const uint AttackHigh2 = 0x10000065u;
|
||||
public const uint AttackMed2 = 0x10000066u;
|
||||
public const uint AttackLow2 = 0x10000067u;
|
||||
public const uint AttackHigh3 = 0x10000068u;
|
||||
public const uint AttackMed3 = 0x10000069u;
|
||||
public const uint AttackLow3 = 0x1000006Au;
|
||||
|
||||
public const uint MissileAttack1 = 0x100000D0u;
|
||||
public const uint MissileAttack2 = 0x100000D1u;
|
||||
public const uint MissileAttack3 = 0x100000D2u;
|
||||
public const uint CastSpell = 0x400000D3u;
|
||||
public const uint Reload = 0x100000D4u;
|
||||
public const uint UseMagicStaff = 0x400000E0u;
|
||||
public const uint UseMagicWand = 0x400000E1u;
|
||||
|
||||
public const uint DoubleSlashLow = 0x1000011Fu;
|
||||
public const uint DoubleSlashMed = 0x10000120u;
|
||||
public const uint DoubleSlashHigh = 0x10000121u;
|
||||
public const uint TripleSlashLow = 0x10000122u;
|
||||
public const uint TripleSlashMed = 0x10000123u;
|
||||
public const uint TripleSlashHigh = 0x10000124u;
|
||||
public const uint DoubleThrustLow = 0x10000125u;
|
||||
public const uint DoubleThrustMed = 0x10000126u;
|
||||
public const uint DoubleThrustHigh = 0x10000127u;
|
||||
public const uint TripleThrustLow = 0x10000128u;
|
||||
public const uint TripleThrustMed = 0x10000129u;
|
||||
public const uint TripleThrustHigh = 0x1000012Au;
|
||||
|
||||
public const uint OffhandSlashHigh = 0x10000170u;
|
||||
public const uint OffhandSlashMed = 0x10000171u;
|
||||
public const uint OffhandSlashLow = 0x10000172u;
|
||||
public const uint OffhandThrustHigh = 0x10000173u;
|
||||
public const uint OffhandThrustMed = 0x10000174u;
|
||||
public const uint OffhandThrustLow = 0x10000175u;
|
||||
public const uint OffhandDoubleSlashLow = 0x10000176u;
|
||||
public const uint OffhandDoubleSlashMed = 0x10000177u;
|
||||
public const uint OffhandDoubleSlashHigh = 0x10000178u;
|
||||
public const uint OffhandTripleSlashLow = 0x10000179u;
|
||||
public const uint OffhandTripleSlashMed = 0x1000017Au;
|
||||
public const uint OffhandTripleSlashHigh = 0x1000017Bu;
|
||||
public const uint OffhandDoubleThrustLow = 0x1000017Cu;
|
||||
public const uint OffhandDoubleThrustMed = 0x1000017Du;
|
||||
public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
|
||||
public const uint OffhandTripleThrustLow = 0x1000017Fu;
|
||||
public const uint OffhandTripleThrustMed = 0x10000180u;
|
||||
public const uint OffhandTripleThrustHigh = 0x10000181u;
|
||||
public const uint OffhandKick = 0x10000182u;
|
||||
public const uint AttackHigh4 = 0x10000183u;
|
||||
public const uint AttackMed4 = 0x10000184u;
|
||||
public const uint AttackLow4 = 0x10000185u;
|
||||
public const uint AttackHigh5 = 0x10000186u;
|
||||
public const uint AttackMed5 = 0x10000187u;
|
||||
public const uint AttackLow5 = 0x10000188u;
|
||||
public const uint AttackHigh6 = 0x10000189u;
|
||||
public const uint AttackMed6 = 0x1000018Au;
|
||||
public const uint AttackLow6 = 0x1000018Bu;
|
||||
public const uint PunchFastHigh = 0x1000018Cu;
|
||||
public const uint PunchFastMed = 0x1000018Du;
|
||||
public const uint PunchFastLow = 0x1000018Eu;
|
||||
public const uint PunchSlowHigh = 0x1000018Fu;
|
||||
public const uint PunchSlowMed = 0x10000190u;
|
||||
public const uint PunchSlowLow = 0x10000191u;
|
||||
public const uint OffhandPunchFastHigh = 0x10000192u;
|
||||
public const uint OffhandPunchFastMed = 0x10000193u;
|
||||
public const uint OffhandPunchFastLow = 0x10000194u;
|
||||
public const uint OffhandPunchSlowHigh = 0x10000195u;
|
||||
public const uint OffhandPunchSlowMed = 0x10000196u;
|
||||
public const uint OffhandPunchSlowLow = 0x10000197u;
|
||||
}
|
||||
89
src/AcDream.Core/Combat/CombatManeuverSelector.cs
Normal file
89
src/AcDream.Core/Combat/CombatManeuverSelector.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using DatReaderWriter.DBObjs;
|
||||
using DatMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
using DatMotionStance = DatReaderWriter.Enums.MotionStance;
|
||||
using DatAttackHeight = DatReaderWriter.Enums.AttackHeight;
|
||||
using DatAttackType = DatReaderWriter.Enums.AttackType;
|
||||
|
||||
namespace AcDream.Core.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Selects combat swing motions from the retail <c>CombatTable</c> DBObj.
|
||||
///
|
||||
/// Retail evidence:
|
||||
/// - <c>CombatManeuverTable::Get</c> (0x0056AB60) loads DB type
|
||||
/// <c>0x1000000D</c> for a 0x30xxxxxx combat table id.
|
||||
/// - ACE <c>CombatManeuverTable.GetMotion</c> indexes maneuvers by
|
||||
/// stance, attack height, and attack type, returning all matching motions.
|
||||
/// - ACE <c>Player_Melee.GetSwingAnimation</c> then chooses
|
||||
/// <c>motions[1]</c> when more than one motion exists and power is below
|
||||
/// the subdivision threshold; otherwise it uses <c>motions[0]</c>.
|
||||
/// </summary>
|
||||
public static class CombatManeuverSelector
|
||||
{
|
||||
public const float DefaultSubdivision = 0.33f;
|
||||
public const float ThrustSlashSubdivision = 0.66f;
|
||||
|
||||
public static CombatManeuverSelection SelectMotion(
|
||||
CombatTable table,
|
||||
DatMotionStance stance,
|
||||
DatAttackHeight attackHeight,
|
||||
DatAttackType attackType,
|
||||
float powerLevel,
|
||||
bool isThrustSlashWeapon = false)
|
||||
{
|
||||
var motions = FindMotions(table, stance, attackHeight, attackType);
|
||||
if (motions.Count == 0)
|
||||
return CombatManeuverSelection.None;
|
||||
|
||||
float subdivision = isThrustSlashWeapon
|
||||
? ThrustSlashSubdivision
|
||||
: DefaultSubdivision;
|
||||
|
||||
var motion = motions.Count > 1 && powerLevel < subdivision
|
||||
? motions[1]
|
||||
: motions[0];
|
||||
|
||||
return new CombatManeuverSelection(
|
||||
Found: true,
|
||||
Motion: motion,
|
||||
Candidates: motions,
|
||||
EffectiveAttackType: attackType,
|
||||
Subdivision: subdivision);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<DatMotionCommand> FindMotions(
|
||||
CombatTable table,
|
||||
DatMotionStance stance,
|
||||
DatAttackHeight attackHeight,
|
||||
DatAttackType attackType)
|
||||
{
|
||||
var result = new List<DatMotionCommand>();
|
||||
|
||||
foreach (var maneuver in table.CombatManeuvers)
|
||||
{
|
||||
if (maneuver.Style == stance
|
||||
&& maneuver.AttackHeight == attackHeight
|
||||
&& maneuver.AttackType == attackType)
|
||||
{
|
||||
result.Add(maneuver.Motion);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct CombatManeuverSelection(
|
||||
bool Found,
|
||||
DatMotionCommand Motion,
|
||||
IReadOnlyList<DatMotionCommand> Candidates,
|
||||
DatAttackType EffectiveAttackType,
|
||||
float Subdivision)
|
||||
{
|
||||
public static CombatManeuverSelection None { get; } = new(
|
||||
Found: false,
|
||||
Motion: DatMotionCommand.Invalid,
|
||||
Candidates: Array.Empty<DatMotionCommand>(),
|
||||
EffectiveAttackType: DatAttackType.Undef,
|
||||
Subdivision: 0f);
|
||||
}
|
||||
|
|
@ -7,14 +7,17 @@ namespace AcDream.Core.Combat;
|
|||
// Full research: docs/research/deepdives/r02-combat-system.md
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Flags]
|
||||
public enum CombatMode
|
||||
{
|
||||
Undef = 0,
|
||||
NonCombat = 1,
|
||||
Melee = 2,
|
||||
Missile = 3,
|
||||
Magic = 4,
|
||||
Peaceful = 5,
|
||||
NonCombat = 0x01,
|
||||
Melee = 0x02,
|
||||
Missile = 0x04,
|
||||
Magic = 0x08,
|
||||
|
||||
ValidCombat = NonCombat | Melee | Missile | Magic,
|
||||
CombatCombat = Melee | Missile | Magic,
|
||||
}
|
||||
|
||||
public enum AttackHeight
|
||||
|
|
@ -24,6 +27,51 @@ public enum AttackHeight
|
|||
Low = 3,
|
||||
}
|
||||
|
||||
public enum CombatAttackAction
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail input-facing combat decisions. The heavyweight parts of the combat
|
||||
/// system remain server authoritative; this helper only maps UI intent to the
|
||||
/// mode / attack-height values sent on the wire.
|
||||
///
|
||||
/// References:
|
||||
/// named-retail ClientCombatSystem::ToggleCombatMode (0x0056C8C0),
|
||||
/// ClientCombatSystem::SetCombatMode (0x0056BE30), and
|
||||
/// ClientCombatSystem::ExecuteAttack (0x0056BB70).
|
||||
/// Cross-check: holtburger DesiredAttackProfile::to_attack_request only emits
|
||||
/// targeted attacks for Melee and Missile modes.
|
||||
/// </summary>
|
||||
public static class CombatInputPlanner
|
||||
{
|
||||
public static CombatMode ToggleMode(
|
||||
CombatMode currentMode,
|
||||
CombatMode defaultCombatMode = CombatMode.Melee)
|
||||
{
|
||||
if ((currentMode & CombatMode.CombatCombat) != 0)
|
||||
return CombatMode.NonCombat;
|
||||
|
||||
return (defaultCombatMode & CombatMode.CombatCombat) != 0
|
||||
? defaultCombatMode
|
||||
: CombatMode.Melee;
|
||||
}
|
||||
|
||||
public static bool SupportsTargetedAttack(CombatMode mode) =>
|
||||
mode == CombatMode.Melee || mode == CombatMode.Missile;
|
||||
|
||||
public static AttackHeight HeightFor(CombatAttackAction action) => action switch
|
||||
{
|
||||
CombatAttackAction.Low => AttackHeight.Low,
|
||||
CombatAttackAction.Medium => AttackHeight.Medium,
|
||||
CombatAttackAction.High => AttackHeight.High,
|
||||
_ => AttackHeight.Medium,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
||||
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
||||
|
|
@ -31,20 +79,26 @@ public enum AttackHeight
|
|||
[Flags]
|
||||
public enum AttackType : uint
|
||||
{
|
||||
None = 0,
|
||||
Punch = 0x0001,
|
||||
Kick = 0x0002,
|
||||
Thrust = 0x0004,
|
||||
Slash = 0x0008,
|
||||
DoubleSlash = 0x0010,
|
||||
TripleSlash = 0x0020,
|
||||
DoubleThrust = 0x0040,
|
||||
TripleThrust = 0x0080,
|
||||
Offhand = 0x0100,
|
||||
OffhandSlash = 0x0200,
|
||||
OffhandThrust = 0x0400,
|
||||
ThrustSlash = 0x0800,
|
||||
// more in r02 §2
|
||||
None = 0,
|
||||
Punch = 0x0001,
|
||||
Thrust = 0x0002,
|
||||
Slash = 0x0004,
|
||||
Kick = 0x0008,
|
||||
OffhandPunch = 0x0010,
|
||||
DoubleSlash = 0x0020,
|
||||
TripleSlash = 0x0040,
|
||||
DoubleThrust = 0x0080,
|
||||
TripleThrust = 0x0100,
|
||||
OffhandThrust = 0x0200,
|
||||
OffhandSlash = 0x0400,
|
||||
OffhandDoubleSlash = 0x0800,
|
||||
OffhandTripleSlash = 0x1000,
|
||||
OffhandDoubleThrust = 0x2000,
|
||||
OffhandTripleThrust = 0x4000,
|
||||
Unarmed = Punch | Kick | OffhandPunch,
|
||||
MultiStrike = DoubleSlash | TripleSlash | DoubleThrust | TripleThrust
|
||||
| OffhandDoubleSlash | OffhandTripleSlash
|
||||
| OffhandDoubleThrust | OffhandTripleThrust,
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ public sealed class CombatState
|
|||
{
|
||||
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
|
||||
|
||||
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
|
||||
|
||||
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
|
||||
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
|
||||
|
||||
|
|
@ -57,6 +59,12 @@ public sealed class CombatState
|
|||
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
|
||||
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
|
||||
|
||||
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
|
||||
public event Action? AttackCommenced;
|
||||
|
||||
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
|
||||
public event Action<CombatMode>? CombatModeChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the server confirms the player landed a killing blow
|
||||
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
|
||||
|
|
@ -94,6 +102,15 @@ public sealed class CombatState
|
|||
HealthChanged?.Invoke(targetGuid, healthPercent);
|
||||
}
|
||||
|
||||
public void SetCombatMode(CombatMode mode)
|
||||
{
|
||||
if (CurrentMode == mode)
|
||||
return;
|
||||
|
||||
CurrentMode = mode;
|
||||
CombatModeChanged?.Invoke(mode);
|
||||
}
|
||||
|
||||
public void OnVictimNotification(
|
||||
string attackerName, uint attackerGuid, uint damageType, uint damage,
|
||||
uint hitQuadrant, uint critical, uint attackType)
|
||||
|
|
@ -140,5 +157,8 @@ public sealed class CombatState
|
|||
public void OnAttackDone(uint attackSequence, uint weenieError)
|
||||
=> AttackDone?.Invoke(attackSequence, weenieError);
|
||||
|
||||
public void OnCombatCommenceAttack()
|
||||
=> AttackCommenced?.Invoke();
|
||||
|
||||
public void Clear() => _healthByGuid.Clear();
|
||||
}
|
||||
|
|
|
|||
97
src/AcDream.Core/Physics/AnimationCommandRouter.cs
Normal file
97
src/AcDream.Core/Physics/AnimationCommandRouter.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Central routing for full retail MotionCommand values after the wire's
|
||||
/// 16-bit command id has been reconstructed.
|
||||
///
|
||||
/// Retail/ACE split motion commands by class mask:
|
||||
/// - Action and ChatEmote commands play through link/action data.
|
||||
/// - Modifier commands play through modifier data.
|
||||
/// - SubState commands become the new cyclic state.
|
||||
/// - Style/UI/Toggle commands do not directly drive an animation overlay here.
|
||||
///
|
||||
/// References:
|
||||
/// CMotionTable::GetObjectSequence 0x00522860,
|
||||
/// CMotionInterp::DoInterpretedMotion 0x00528360,
|
||||
/// ACE MotionTable.GetObjectSequence, and
|
||||
/// docs/research/deepdives/r03-motion-animation.md section 3.
|
||||
/// </summary>
|
||||
public static class AnimationCommandRouter
|
||||
{
|
||||
private const uint ActionMask = 0x10000000u;
|
||||
private const uint ModifierMask = 0x20000000u;
|
||||
private const uint SubStateMask = 0x40000000u;
|
||||
private const uint ClassMask = 0xFF000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a reconstructed full MotionCommand.
|
||||
/// </summary>
|
||||
public static AnimationCommandRouteKind Classify(uint fullCommand)
|
||||
{
|
||||
if (fullCommand == 0)
|
||||
return AnimationCommandRouteKind.None;
|
||||
|
||||
uint cls = fullCommand & ClassMask;
|
||||
if (cls == 0x12000000u || cls == 0x13000000u)
|
||||
return AnimationCommandRouteKind.ChatEmote;
|
||||
|
||||
if ((fullCommand & ModifierMask) != 0)
|
||||
return AnimationCommandRouteKind.Modifier;
|
||||
|
||||
if ((fullCommand & ActionMask) != 0)
|
||||
return AnimationCommandRouteKind.Action;
|
||||
|
||||
if ((fullCommand & SubStateMask) != 0)
|
||||
return AnimationCommandRouteKind.SubState;
|
||||
|
||||
return AnimationCommandRouteKind.Ignored;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs and routes a 16-bit wire command.
|
||||
/// </summary>
|
||||
public static AnimationCommandRouteKind RouteWireCommand(
|
||||
AnimationSequencer sequencer,
|
||||
uint currentStyle,
|
||||
ushort wireCommand,
|
||||
float speedMod = 1f)
|
||||
{
|
||||
uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand);
|
||||
return RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a full MotionCommand to the matching sequencer API.
|
||||
/// </summary>
|
||||
public static AnimationCommandRouteKind RouteFullCommand(
|
||||
AnimationSequencer sequencer,
|
||||
uint currentStyle,
|
||||
uint fullCommand,
|
||||
float speedMod = 1f)
|
||||
{
|
||||
var route = Classify(fullCommand);
|
||||
switch (route)
|
||||
{
|
||||
case AnimationCommandRouteKind.Action:
|
||||
case AnimationCommandRouteKind.Modifier:
|
||||
case AnimationCommandRouteKind.ChatEmote:
|
||||
sequencer.PlayAction(fullCommand, speedMod);
|
||||
break;
|
||||
case AnimationCommandRouteKind.SubState:
|
||||
sequencer.SetCycle(currentStyle, fullCommand, speedMod);
|
||||
break;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnimationCommandRouteKind
|
||||
{
|
||||
None = 0,
|
||||
Action,
|
||||
Modifier,
|
||||
ChatEmote,
|
||||
SubState,
|
||||
Ignored,
|
||||
}
|
||||
|
|
@ -330,6 +330,33 @@ public sealed class AnimationSequencer
|
|||
/// makes the jump look delayed (legs stand still for ~100 ms while
|
||||
/// the link drains, then fold into Falling). Defaults to false to
|
||||
/// preserve normal smooth transitions for everything else.</param>
|
||||
/// <summary>
|
||||
/// Check whether the underlying MotionTable contains a cycle for the
|
||||
/// given (style, motion) pair. Useful for callers that want to fall
|
||||
/// back to a known-good motion (e.g. <c>WalkForward</c> →
|
||||
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
|
||||
/// unconditional <c>ClearCyclicTail</c> path on a missing cycle —
|
||||
/// which leaves the body without any animation tail and snaps every
|
||||
/// part to the setup-default offset (visible as "torso on the
|
||||
/// ground" since most creatures' setup-default has limbs at the
|
||||
/// torso origin).
|
||||
/// </summary>
|
||||
public bool HasCycle(uint style, uint motion)
|
||||
{
|
||||
// adjust_motion remapping (mirrors the head of SetCycle):
|
||||
// TurnLeft, SideStepLeft, WalkBackward map to their right/forward
|
||||
// mirror cycles.
|
||||
uint adjustedMotion = motion;
|
||||
switch (motion & 0xFFFFu)
|
||||
{
|
||||
case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break;
|
||||
case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break;
|
||||
case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break;
|
||||
}
|
||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||||
return _mtable.Cycles.ContainsKey(cycleKey);
|
||||
}
|
||||
|
||||
public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false)
|
||||
{
|
||||
// ── adjust_motion: remap left→right / backward→forward variants ───
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ public static class MotionCommandResolver
|
|||
result[lo] = full;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyNamedRetailOverrides(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> result)
|
||||
{
|
||||
// The generated DRW enum is shifted by three entries starting at
|
||||
// AllegianceHometownRecall. The named Sept 2013 retail command_ids
|
||||
// table is authoritative here:
|
||||
// named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658
|
||||
// and command-name table lines 1068272-1068313.
|
||||
//
|
||||
// These values cover recall, offhand, attack 4-6, and fast/slow punch
|
||||
// actions. Without the override, wire command 0x0170 reconstructs to
|
||||
// IssueSlashCommand instead of OffhandSlashHigh, so offhand swing
|
||||
// animations route as UI commands and never play.
|
||||
for (ushort lo = 0x016E; lo <= 0x0197; lo++)
|
||||
result[lo] = 0x10000000u | lo;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,12 +72,20 @@ public static class MotionCommand
|
|||
/// regular SetCycle transition.
|
||||
/// </summary>
|
||||
public const uint FallDown = 0x10000050u;
|
||||
/// <summary>0x10000057 — Dead.</summary>
|
||||
public const uint Dead = 0x10000057u;
|
||||
/// <summary>0x40000011 - persistent dead substate.</summary>
|
||||
public const uint Dead = 0x40000011u;
|
||||
/// <summary>0x10000057 - Sanctuary death-trigger action.</summary>
|
||||
public const uint Sanctuary = 0x10000057u;
|
||||
/// <summary>0x41000012 - crouching substate.</summary>
|
||||
public const uint Crouch = 0x41000012u;
|
||||
/// <summary>0x41000013 - sitting substate.</summary>
|
||||
public const uint Sitting = 0x41000013u;
|
||||
/// <summary>0x41000014 - sleeping substate.</summary>
|
||||
public const uint Sleeping = 0x41000014u;
|
||||
/// <summary>0x41000011 — Crouch lower bound for blocked-jump check.</summary>
|
||||
public const uint CrouchLowerBound = 0x41000011u;
|
||||
/// <summary>0x41000014 — upper bound of crouch/sit/sleep range.</summary>
|
||||
public const uint CrouchUpperBound = 0x41000014u;
|
||||
/// <summary>0x41000015 - exclusive upper bound of crouch/sit/sleep range.</summary>
|
||||
public const uint CrouchUpperExclusive = 0x41000015u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -819,7 +827,7 @@ public sealed class MotionInterpreter
|
|||
/// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false:
|
||||
/// return 0x49
|
||||
/// uVar1 = InterpretedState.ForwardCommand
|
||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x10000057 (Dead):
|
||||
/// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead):
|
||||
/// return 0x48
|
||||
/// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range):
|
||||
/// return 0x48
|
||||
|
|
@ -850,7 +858,7 @@ public sealed class MotionInterpreter
|
|||
return false;
|
||||
|
||||
// Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015).
|
||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperBound)
|
||||
if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive)
|
||||
return false;
|
||||
|
||||
// Need Gravity flag + Contact + OnWalkable for ground-based motion.
|
||||
|
|
|
|||
304
src/AcDream.Core/Physics/RemoteMoveToDriver.cs
Normal file
304
src/AcDream.Core/Physics/RemoteMoveToDriver.cs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tick steering for server-controlled remote creatures while a
|
||||
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
|
||||
/// is the active locomotion source.
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
|
||||
/// stabilizer. With the full MoveTo path payload now captured on
|
||||
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
|
||||
/// the body solver has the destination + heading + thresholds it needs to
|
||||
/// run the retail per-tick loop instead of waiting for sparse
|
||||
/// UpdatePosition snap corrections.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail references:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
|
||||
/// per-tick driver. Computes heading-to-target, fires an aux
|
||||
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| > 20°, snaps
|
||||
/// orientation when within tolerance, and tests arrival via
|
||||
/// <c>dist <= min_distance</c> (chase) or
|
||||
/// <c>dist >= distance_to_object</c> (flee).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
|
||||
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
|
||||
/// MoveToManager itself does NOT touch the body. The body's actual
|
||||
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
|
||||
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
|
||||
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
|
||||
/// the body's orientation.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Acdream port scope: minimum viable subset. We skip target re-tracking
|
||||
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
|
||||
/// StickTo, fail-distance progress detector, and the sphere-cylinder
|
||||
/// distance variant — all server-side concerns the local body doesn't need
|
||||
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
|
||||
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
|
||||
/// arrival detection via <c>min_distance</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// ACE divergence: ACE swaps the chase/flee arrival predicates
|
||||
/// (<c>dist <= DistanceToObject</c> vs retail's <c>dist <= MinDistance</c>).
|
||||
/// We follow retail.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RemoteMoveToDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// Heading tolerance below which we snap orientation directly to the
|
||||
/// target heading (ACE's <c>set_heading(target, true)</c>
|
||||
/// server-tic-rate fudge). Above tolerance we rotate at
|
||||
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
|
||||
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
|
||||
/// </summary>
|
||||
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Default angular rate for in-motion heading correction when delta
|
||||
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
|
||||
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
|
||||
/// when the per-creature value differs, the future port can wire it
|
||||
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
|
||||
/// </summary>
|
||||
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Float-comparison slack for the arrival predicate. With
|
||||
/// <c>min_distance == 0</c> in a chase packet, exact equality is
|
||||
/// unreachable due to integration wobble; this epsilon prevents the
|
||||
/// driver from over-shooting by a sub-meter and snap-flipping back.
|
||||
/// </summary>
|
||||
public const float ArrivalEpsilon = 0.05f;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum staleness (seconds) of the most recent MoveTo packet
|
||||
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
|
||||
/// during active chase; if no fresh packet arrives for this long,
|
||||
/// the entity has likely either left our streaming view, switched
|
||||
/// to a non-MoveTo motion the server's broadcast didn't reach us
|
||||
/// for, or had its move cancelled server-side without our seeing
|
||||
/// the cancel UM. In any of those cases, continuing to drive the
|
||||
/// body toward a stale destination produces the "monster runs in
|
||||
/// place after popping back into view" symptom (2026-04-28).
|
||||
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
|
||||
/// while still failing fast on real loss-of-state.
|
||||
/// </summary>
|
||||
public const double StaleDestinationSeconds = 1.5;
|
||||
|
||||
public enum DriveResult
|
||||
{
|
||||
/// <summary>Within arrival window — caller should zero velocity.</summary>
|
||||
Arrived,
|
||||
/// <summary>Steering active — caller should let
|
||||
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
|
||||
Steering,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steer body orientation toward <paramref name="destinationWorld"/>
|
||||
/// and report whether the body has arrived or should keep running.
|
||||
/// Pure function — emits the updated orientation via
|
||||
/// <paramref name="newOrientation"/> (the input is not mutated; the
|
||||
/// caller assigns the new value back to its body).
|
||||
/// </summary>
|
||||
/// <param name="minDistance">
|
||||
/// <c>min_distance</c> from the wire's MovementParameters block —
|
||||
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
|
||||
/// </param>
|
||||
/// <param name="distanceToObject">
|
||||
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
|
||||
/// threshold (default 0.6 m, the melee range). The actual arrival
|
||||
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
|
||||
/// when retail sends <c>min_distance</c> > 0, ACE-compatible when
|
||||
/// ACE puts the value in <c>distance_to_object</c> with
|
||||
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
|
||||
/// chase packets never arrive — the body keeps re-targeting around
|
||||
/// the player at melee range and visibly oscillates between facings,
|
||||
/// which is the user-reported "monster keeps running in different
|
||||
/// directions when it should be attacking" symptom (2026-04-28).
|
||||
/// </param>
|
||||
public static DriveResult Drive(
|
||||
Vector3 bodyPosition,
|
||||
Quaternion bodyOrientation,
|
||||
Vector3 destinationWorld,
|
||||
float minDistance,
|
||||
float distanceToObject,
|
||||
float dt,
|
||||
bool moveTowards,
|
||||
out Quaternion newOrientation)
|
||||
{
|
||||
// Horizontal distance only — server owns Z, our body Z is
|
||||
// hard-snapped to the latest UpdatePosition.
|
||||
float dx = destinationWorld.X - bodyPosition.X;
|
||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
|
||||
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
|
||||
// MoveToManager.cs:476:
|
||||
//
|
||||
// chase (MoveTowards): dist <= distance_to_object
|
||||
// flee (MoveAway): dist >= min_distance
|
||||
//
|
||||
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
|
||||
// defensive guess; cross-checked with two independent research
|
||||
// agents against the named retail decomp + ACE port + holtburger,
|
||||
// the chase threshold is unambiguously DistanceToObject —
|
||||
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
|
||||
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
|
||||
// at melee range, not run to zero.)
|
||||
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
|
||||
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Arrived;
|
||||
}
|
||||
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Arrived;
|
||||
}
|
||||
|
||||
// Degenerate — already on target horizontally; preserve heading.
|
||||
if (dist < 1e-4f)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Steering;
|
||||
}
|
||||
|
||||
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
|
||||
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
|
||||
// World forward = Transform((0,1,0), orientation). Yaw extracted
|
||||
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
|
||||
var localForward = new Vector3(0f, 1f, 0f);
|
||||
var worldForward = Vector3.Transform(localForward, bodyOrientation);
|
||||
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
|
||||
|
||||
// Desired heading: face the target. (dx, dy) is the world-space
|
||||
// offset to the target. With local-forward=+Y we want yaw such
|
||||
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
|
||||
// to yaw = atan2(-dx, dy).
|
||||
float desiredYaw = MathF.Atan2(-dx, dy);
|
||||
float delta = WrapPi(desiredYaw - currentYaw);
|
||||
|
||||
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
|
||||
{
|
||||
// ACE's set_heading(target, true) — sync to server-tic-rate.
|
||||
// We have the same sparse-UP problem ACE does, so the same
|
||||
// fudge applies.
|
||||
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
|
||||
// rotate at TurnRate clamped to dt, in the shorter direction.
|
||||
float maxStep = TurnRateRadPerSec * dt;
|
||||
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||
// Apply incremental yaw around world +Z (preserving any
|
||||
// server-supplied pitch/roll from the latest UpdatePosition).
|
||||
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
|
||||
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
|
||||
}
|
||||
|
||||
return DriveResult.Steering;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a landblock-local Origin from a MoveTo packet
|
||||
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
|
||||
/// into acdream's render world space using the same arithmetic as
|
||||
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
|
||||
/// from the live-mode center.
|
||||
/// </summary>
|
||||
public static Vector3 OriginToWorld(
|
||||
uint originCellId,
|
||||
float originX,
|
||||
float originY,
|
||||
float originZ,
|
||||
int liveCenterLandblockX,
|
||||
int liveCenterLandblockY)
|
||||
{
|
||||
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
||||
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
||||
return new Vector3(
|
||||
originX + (lbX - liveCenterLandblockX) * 192f,
|
||||
originY + (lbY - liveCenterLandblockY) * 192f,
|
||||
originZ);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cap horizontal velocity so the body lands exactly at
|
||||
/// <paramref name="arrivalThreshold"/> rather than overshooting past
|
||||
/// it during the final tick of approach. Without this clamp, a body
|
||||
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
|
||||
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
|
||||
/// 60 fps) — visible as the creature "running slightly through" the
|
||||
/// player it's about to attack (user-reported 2026-04-28).
|
||||
///
|
||||
/// <para>
|
||||
/// The clamp is a strict scale-down of the horizontal component
|
||||
/// (X/Y); the vertical component (Z) is left to gravity / terrain
|
||||
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
|
||||
/// no-op since fleeing has no overshoot risk — the body wants to
|
||||
/// move AWAY from the destination.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static Vector3 ClampApproachVelocity(
|
||||
Vector3 bodyPosition,
|
||||
Vector3 currentVelocity,
|
||||
Vector3 destinationWorld,
|
||||
float arrivalThreshold,
|
||||
float dt,
|
||||
bool moveTowards)
|
||||
{
|
||||
if (!moveTowards || dt <= 0f) return currentVelocity;
|
||||
|
||||
float dx = destinationWorld.X - bodyPosition.X;
|
||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
float remaining = MathF.Max(0f, dist - arrivalThreshold);
|
||||
|
||||
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
|
||||
+ currentVelocity.Y * currentVelocity.Y);
|
||||
if (vxy < 1e-3f) return currentVelocity;
|
||||
|
||||
float advance = vxy * dt;
|
||||
if (advance <= remaining) return currentVelocity;
|
||||
|
||||
// Already inside or right at the threshold: zero horizontal
|
||||
// velocity, keep Z. (The arrival predicate in Drive() should
|
||||
// have fired this tick, but this is the belt-and-braces guard.)
|
||||
if (remaining < 1e-3f)
|
||||
return new Vector3(0f, 0f, currentVelocity.Z);
|
||||
|
||||
float scale = remaining / advance;
|
||||
return new Vector3(
|
||||
currentVelocity.X * scale,
|
||||
currentVelocity.Y * scale,
|
||||
currentVelocity.Z);
|
||||
}
|
||||
|
||||
/// <summary>Wrap an angle in radians to [-π, π].</summary>
|
||||
private static float WrapPi(float r)
|
||||
{
|
||||
const float TwoPi = MathF.PI * 2f;
|
||||
r %= TwoPi;
|
||||
if (r > MathF.PI) r -= TwoPi;
|
||||
if (r < -MathF.PI) r += TwoPi;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
87
src/AcDream.Core/Physics/ServerControlledLocomotion.cs
Normal file
87
src/AcDream.Core/Physics/ServerControlledLocomotion.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Chooses the visible locomotion cycle for server-controlled remotes whose
|
||||
/// UpdateMotion packet is a MoveToObject/MoveToPosition union rather than an
|
||||
/// InterpretedMotionState.
|
||||
///
|
||||
/// Retail references:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>MovementManager::PerformMovement</c> (0x00524440) dispatches movement
|
||||
/// types 6/7 into <c>MoveToManager::MoveToObject/MoveToPosition</c> instead
|
||||
/// of unpacking an InterpretedMotionState.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>MovementParameters::UnPackNet</c> (0x0052AC50) shows MoveTo packets
|
||||
/// carry movement params + run rate, not a ForwardCommand field.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// ACE <c>MovementData.Write</c> uses the same movement type union; holtburger
|
||||
/// documents the matching <c>MovementType::MoveToPosition = 7</c>.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class ServerControlledLocomotion
|
||||
{
|
||||
public const float StopSpeed = 0.20f;
|
||||
public const float RunThreshold = 1.25f;
|
||||
public const float MinSpeedMod = 0.25f;
|
||||
public const float MaxSpeedMod = 3.00f;
|
||||
|
||||
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
|
||||
// (0x0052AA00) seeds forward motion before the next position update.
|
||||
public static LocomotionCycle PlanMoveToStart(
|
||||
float moveToSpeed = 1f,
|
||||
float runRate = 1f,
|
||||
bool canRun = true)
|
||||
{
|
||||
moveToSpeed = SanitizePositive(moveToSpeed);
|
||||
runRate = SanitizePositive(runRate);
|
||||
|
||||
if (!canRun)
|
||||
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
|
||||
|
||||
return new LocomotionCycle(
|
||||
MotionCommand.RunForward,
|
||||
moveToSpeed * runRate,
|
||||
true);
|
||||
}
|
||||
|
||||
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
|
||||
{
|
||||
float horizontalSpeed = MathF.Sqrt(
|
||||
worldVelocity.X * worldVelocity.X +
|
||||
worldVelocity.Y * worldVelocity.Y);
|
||||
|
||||
if (horizontalSpeed < StopSpeed)
|
||||
return new LocomotionCycle(MotionCommand.Ready, 1f, false);
|
||||
|
||||
if (horizontalSpeed < RunThreshold)
|
||||
{
|
||||
float speedMod = Math.Clamp(
|
||||
horizontalSpeed / MotionInterpreter.WalkAnimSpeed,
|
||||
MinSpeedMod,
|
||||
MaxSpeedMod);
|
||||
return new LocomotionCycle(MotionCommand.WalkForward, speedMod, true);
|
||||
}
|
||||
|
||||
return new LocomotionCycle(
|
||||
MotionCommand.RunForward,
|
||||
Math.Clamp(horizontalSpeed / MotionInterpreter.RunAnimSpeed, MinSpeedMod, MaxSpeedMod),
|
||||
true);
|
||||
}
|
||||
|
||||
public readonly record struct LocomotionCycle(
|
||||
uint Motion,
|
||||
float SpeedMod,
|
||||
bool IsMoving);
|
||||
|
||||
private static float SanitizePositive(float value)
|
||||
{
|
||||
return float.IsFinite(value) && value > 0f ? value : 1f;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue