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:
Erik 2026-04-29 10:50:59 +02:00
commit b93dfe95d8
44 changed files with 4580 additions and 301 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View file

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

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

View 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);
}

View file

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

View file

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

View 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,
}

View file

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

View file

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

View file

@ -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 &lt; uVar1 &lt; 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.

View 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| &gt; 20°, snaps
/// orientation when within tolerance, and tests arrival via
/// <c>dist &lt;= min_distance</c> (chase) or
/// <c>dist &gt;= 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 &lt;= DistanceToObject</c> vs retail's <c>dist &lt;= 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> &gt; 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;
}
}

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