Short answer
The weapon's displayed base attack speed is probably not the cause. A bow and a crossbow can both have an attack speed of 40 while still using different character actions, animation tracks, weapon slots, frame counts, and projectile timing.
The most likely visual jump occurs when the client receives an attack action while the character is moving. The action handler stops movement and snaps the rendered model to the center of a logical map tile before changing the animation. The crossbow animation makes this correction much more visible than the bow animation.
Version note: the code below uses a Season 6 client as an example. The same principle applies to other generations, but function names, packet layouts, action constants, structures, calling conventions, and addresses may differ.
Why equal attack speed does not mean equal behavior
In the Season 6 example, bows and crossbows select separate actions:
Code:
PLAYER_ATTACK_BOW
PLAYER_ATTACK_CROSSBOW
They may also have separate flying, mounted, Fenrir, and raised-shot actions. The item attack-speed value controls timing calculations, but it does not make the two animation tracks identical.
Consequently, rounding attack speed to an even value or changing the server attack delay is not a proper correction. It may hide the symptom at one speed while introducing a real mismatch between visual attacks and server-side damage timing.
Primary suspect: position snapping in the action handler
A typical client action handler contains logic similar to this:
Code:
c->Object.Angle[2] = ((float)(Data->Angle) - 1.0f) * 45.0f;
c->Movement = false;
c->Object.Position[0] =
c->TargetX * TERRAIN_SCALE + TERRAIN_SCALE * 0.5f;
c->Object.Position[1] =
c->TargetY * TERRAIN_SCALE + TERRAIN_SCALE * 0.5f;
While moving, the rendered character can be between two logical tiles. When the normal-attack confirmation is processed, the code above moves the model immediately to the center of TargetX/TargetY. That produces the visible jump.
Focused source-code correction
Do not disable all server position corrections. Suppress this particular render-position snap only for the local moving Elf starting a normal crossbow attack:
Code:
const bool isNormalAttack =
Data->Action == AT_ATTACK1 ||
Data->Action == AT_ATTACK2;
const bool isLocalCrossbowAttack =
c == Hero &&
isNormalAttack &&
gCharacterManager.GetBaseClass(c->Class) == CLASS_ELF &&
gCharacterManager.GetEquipedBowType(c) == BOWTYPE_CROSSBOW;
const bool wasMoving = (c->Movement != false);
c->Object.Angle[2] = ((float)(Data->Angle) - 1.0f) * 45.0f;
c->Movement = false;
// Preserve the interpolated visual position during the
// local move-to-crossbow-attack transition.
if (!(isLocalCrossbowAttack && wasMoving))
{
c->Object.Position[0] =
c->TargetX * TERRAIN_SCALE + TERRAIN_SCALE * 0.5f;
c->Object.Position[1] =
c->TargetY * TERRAIN_SCALE + TERRAIN_SCALE * 0.5f;
}
switch (Data->Action)
{
case AT_ATTACK1:
case AT_ATTACK2:
SetPlayerAttack(c);
c->AttackTime = 1;
c->Object.AnimationFrame = 0.0f;
break;
}
Keep the correction narrowly scoped at first. If it fixes the local Elf but remote observers still see a jump, investigate the move/action packet order and the coordinates sent by the GameServer.
Secondary Season 6 issue: bow speed used for crossbow animation
Some Season 6 Main sources use the bow action's playback speed for both bow and crossbow weapon animation:
Code:
if (o->CurrentAction == PLAYER_ATTACK_BOW ||
o->CurrentAction == PLAYER_ATTACK_CROSSBOW ||
o->CurrentAction == PLAYER_ATTACK_FLY_BOW ||
o->CurrentAction == PLAYER_ATTACK_FLY_CROSSBOW)
{
w->PlaySpeed =
Models[MODEL_PLAYER].Actions[PLAYER_ATTACK_BOW].PlaySpeed;
}
The crossbow branch is incorrectly synchronized to PLAYER_ATTACK_BOW. Use the current player action instead:
Code:
if (o->CurrentAction == PLAYER_ATTACK_BOW ||
o->CurrentAction == PLAYER_ATTACK_CROSSBOW ||
o->CurrentAction == PLAYER_ATTACK_FLY_BOW ||
o->CurrentAction == PLAYER_ATTACK_FLY_CROSSBOW)
{
w->PlaySpeed =
Models[MODEL_PLAYER].Actions[o->CurrentAction].PlaySpeed;
}
If the particular client does not allow direct use of CurrentAction here, use an explicit bow/crossbow action mapping. This is a secondary correction: it synchronizes the weapon and character animation, but the position-snap fix may still be required.
If the Main source is unavailable
The issue can still be corrected by installing a hook from .
dll into the relevant function inside
main.exe. The faulty function remains in
Main; the
DLL acts as the patch carrier.
A low-risk approach is to save the interpolated position, call the original action handler, and restore only the local visual X/Y position when the exact problematic transition occurs:
Code:
using ReceiveAction_t = void(__cdecl*)(BYTE* buffer, int size);
ReceiveAction_t OriginalReceiveAction = nullptr;
void __cdecl HookedReceiveAction(BYTE* buffer, int size)
{
LPPRECEIVE_ACTION packet =
reinterpret_cast<LPPRECEIVE_ACTION>(buffer);
CHARACTER* character = FindCharacterFromActionPacket(packet);
bool preservePosition = false;
float savedX = 0.0f;
float savedY = 0.0f;
if (character != nullptr)
{
const bool normalAttack =
packet->Action == AT_ATTACK1 ||
packet->Action == AT_ATTACK2;
preservePosition =
character == Hero &&
character->Movement &&
normalAttack &&
GetBaseClass(character->Class) == CLASS_ELF &&
GetEquipedBowType(character) == BOWTYPE_CROSSBOW;
if (preservePosition)
{
savedX = character->Object.Position[0];
savedY = character->Object.Position[1];
}
}
OriginalReceiveAction(buffer, size);
if (preservePosition)
{
character->Object.Position[0] = savedX;
character->Object.Position[1] = savedY;
}
}
The hook can be installed using
MinHook,
Detours, or the project's existing
hook/
trampoline system:
Code:
MH_CreateHook(
reinterpret_cast<void*>(receiveActionAddress),
&HookedReceiveAction,
reinterpret_cast<void**>(&OriginalReceiveAction));
MH_EnableHook(
reinterpret_cast<void*>(receiveActionAddress));
Do not copy an address from another Main. Even two executables with the same visible version can have different offsets because of compiler settings, protection, regional builds, or custom modifications.
Locating the function in IDA or Ghidra
Without symbols, identify the action handler by its behavior rather than by an absolute address:
- Look for a packet handler containing a switch for stand, attack, sit, pose, greeting, and similar actions.
- Look for calls equivalent to SetPlayerAttack.
- Look for a write of zero to the character's animation frame.
- Look for conversion from tile coordinates to world coordinates, commonly equivalent to TargetX * 100.0f + 50.0f.
- Confirm the calling convention and packet structure in the exact executable before installing the hook.
The weapon playback-speed issue can be located by following references to the bow and crossbow attack-action constants and finding the code that assigns PlaySpeed to the equipped weapon part.
How to confirm the real cause
- Test normal attacks while completely stationary.
- Test normal attacks while moving between two tiles.
- Compare a bow and crossbow with the same displayed attack speed.
- Log the action, animation frame, movement flag, target tile, and rendered position immediately before and after the action packet.
- Observe the character from a second client.
- Repeat with wings and mounts because they use different actions.
If only the local player sees the jump, the client-side interpolation/action transition is the main suspect. If other players see the same jump at the same moment, inspect the GameServer's move/action packet sequence and transmitted coordinates as well.
Recommended order of work
- Do not change the server attack-speed formula.
- Instrument the action handler and confirm that the position changes when the normal crossbow attack starts.
- Apply the narrowly scoped local-position preservation fix.
- Correct the crossbow weapon animation's playback-speed source.
- Test local and remote rendering, then wings and mounts.