SpookStory Server Implementation Guide
This document provides comprehensive documentation of the Archer combat system, covering damage formulas, the 7-random heartbeat RNG model, Final Attack mechanics, Mortal Blow, and the RNG recovery system. All formulas and logic are IDA-verified against the v40 client binary.
- Overview
- Attack Types and Classifications
- Damage Formulas
- The 7-Random Heartbeat Model
- Final Attack Mechanics
- Mortal Blow Mechanics
- Dispatcher Decision Flow
- RNG Recovery Mechanism
- Implementation Architecture
- Skill ID Reference
Archer combat in MapleStory v40 is uniquely complex compared to other job classes. Unlike Warrior or Mage attacks which follow straightforward damage paths, Archers have multiple damage formulas that branch based on:
- Distance to target (ranged vs. bonk)
- Attack method (skill vs. basic attack)
- Display byte (determines animation and formula path)
- Packet opcode (0x16 melee vs. 0x17 ranged)
Additionally, Archers are the only class with Final Attack passive procs, which require dispatcher-level RNG consumption separate from the per-hit damage buffer.
| Mechanic | Complexity |
|---|---|
| Two-tier RNG consumption (Dispatcher + Heartbeat) | High |
| Display byte ≠ attack type (22 can be ranged OR bonk) | High |
| Mortal Blow shares dispatcher path with FA | Medium |
| AOE skills skip dispatcher FA check | Medium |
| Manual swings use different formula than bonks | Medium |
The display byte from attack packets encodes both animation type and facing direction:
Display Byte Structure:
┌─────────┬─────────────────────┐
│ Bit 7 │ Bits 0-6 │
│ Facing │ Internal Attack Type│
│ (0x80) │ (0x7F mask) │
└─────────┴─────────────────────┘
- Facing: Bit 7 = 1 means facing LEFT
- Internal Type: Bits 0-6 determine the formula branch
| Internal Type | Name | Description |
|---|---|---|
| 9, 11 | Manual Swing | Out of arrows / point-blank physical swing |
| 22-27 | Bow Animations | Standard ranged, bonk, or skills |
| 51 | Bonk Variant | Close-range basic attack variant |
Critical: Display byte 22 is ambiguous! The same animation is used for:
- Regular ranged shots (skill or basic)
- Close-range "bonk" melee attacks
The Packet Opcode is the authoritative source:
| Opcode | Path | Formula |
|---|---|---|
| 0x16 (Melee) | Bonk Path | STR + (DEX × 3.4) |
| 0x17 (Ranged) | Standard Path | DEX × 3.4 + STR |
A True Bonk occurs when ALL of these conditions are met:
- Display byte is 22-27
- Skill ID is 0 (basic attack)
- Packet opcode is 0x16 (processed as melee)
True bonks:
- Skip defense reduction entirely
- Skip critical hit checks
- Use a specialized damage formula
- Clamp minimum damage to 1
Used for regular ranged attacks, skills, and non-true-bonk animations:
BaseDamage = (DEX_interp × WeaponMult + STR) × WATK × 0.01
Where:
- DEX_interp =
DEX_min + (DEX_max - DEX_min) × RNG_factor - DEX_min =
DEX × mastery_factor - DEX_max =
DEX - WeaponMult = 3.4 (Bow) or 3.6 (Crossbow)
- WATK = Weapon Attack + Projectile Attack
mastery_factor = (mastery_property × 5.0 + 10.0) × 0.009
| Mastery Level | Property | Factor |
|---|---|---|
| 0 (none) | 0 | 0.09 (9%) |
| 3 | 15 | 0.225 (22.5%) |
| 10 | 20 | 0.99 (99%) |
Note: Use the skill's
masteryproperty value, not the skill level directly.
Used when display 22-27 + skillId = 0 + opcode 0x16:
BaseDamage = (STR + DEX_interp × WeaponMult) × WATK × 0.01
Key differences from Standard:
- STR is primary (added first, not interpolated)
- No defense reduction (damage is not reduced by mob PDD)
- No critical hits (crit RNG is skipped)
- Minimum damage = 1 (clamped at 0x514FA0)
Used for out-of-arrows attacks or point-blank melee swings:
BaseDamage = (STR + DEX_interp) × WATK × 0.005
Key characteristics:
- Uses 0.005 coefficient (half of standard 0.01)
- Uses default mastery (0.09 = 9%)
- Applies defense (unlike bonks)
- No critical hits
- WATK excludes projectile attack (no arrows)
Standard attacks apply physical defense reduction:
# Ranged attacks (empirically verified for v40 parity):
Reduction = (PDD × 0.6) - (PDD × 0.1) × RNG_factor
# Result: Higher RNG = LESS reduction (inverted from melee)
Note: True bonks (type 22-27 with skillId=0) skip defense entirely.
Critical bonus is additive, not multiplicative:
critBonus = preSkillBase × (critDamage - 100) × 0.01
finalDamage = (preSkillBase × skillPct × 0.01) + critBonus
Critical Rate = 1 + (skillLevel × 2) (e.g., level 10 = 21%)
At the start of CalcDamagePerHit (0x513AAA), the client fills a local 7-value buffer:
┌────────────────────────────────────────────────┐
│ CalcDamagePerHit Entry (0x513AD4) │
│ │
│ for (int i = 0; i < 7; i++) { │
│ buffer[i] = CRand32::Random(); │
│ } │
│ │
│ // Buffer is now filled with 7 values │
│ // Indexed via: index = (hitIndex × steps + │
│ // offset) % 7 │
└────────────────────────────────────────────────┘
The buffer is accessed using cyclic indexing:
index = (hitIndex × stepsPerHit + offset) % 7
| Offset | Purpose | Used By |
|---|---|---|
| 0 | Accuracy | All attacks |
| 1 | Damage Interpolation | All attacks |
| 2 | Defense Reduction | Non-bonk attacks |
| 3 | Critical Hit | Attacks with crit skill |
| Attack Type | Acc | Dmg | Def | Crit | Steps |
|---|---|---|---|---|---|
| Magic | ✓ | ✓ | ✓ | ✗ | 3 |
| Manual Swing (9/11) | ✓ | ✓ | ✓ | ✗ | 3 |
| Ranged (with crit) | ✓ | ✓ | ✓ | ✓ | 4 |
| Ranged (no crit) | ✓ | ✓ | ✓ | ✗ | 3 |
| True Bonk | ✓ | ✓ | ✗ | ✗ | 3 |
Total RNG = 7 × NumberOfMobs
Each mob gets its own fresh 7-value buffer. Multi-hit skills hitting the same mob reuse the same buffer with different indices.
Example scenarios:
- 1 mob hit 1 time = 7 RNG values
- 3 mobs hit 1 time = 21 RNG values (7 × 3)
- 1 mob hit 4 times (Strafe) = 7 RNG values (single buffer, 4 hits)
Final Attack (FA) is a 2nd job Archer passive skill that triggers additional attacks after regular attacks:
- Hunter FA: Skill ID 3101002 (enables proc)
- Crossbowman FA: Skill ID 3201002 (enables proc)
- FA Attack Packet: Skill ID 3100001 (Bow) / 3200001 (Crossbow)
FA proc checks occur in the attack dispatcher (sub_59446E), BEFORE damage calculation:
┌──────────────────────────────────────────────────────┐
│ Attack Dispatcher (sub_59446E) │
│ │
│ 1. Receive attack packet │
│ 2. Check job = Archer? │
│ 3. Check FA skill leveled? │
│ 4. Check collision: ANY mob in range? │
│ 5. If (2) && (3) && (4): │
│ └─► Consume 1 RNG for proc roll │
│ 6. Continue to CalcDamagePerHit │
└──────────────────────────────────────────────────────┘
When the dispatcher determines a check is needed:
// IDA pseudocode at 0x594BB2
rng = CRand32::Random();
remainder = rng % 100;
probability = GetFASkillProbability(skillLevel); // Level-based!
if (remainder < probability) {
// FA PROCS - dispatch FA skill packet
goto sub_595864; // FA attack dispatcher
} else {
// NO PROC
goto loc_594890; // Normal path
}The FA proc probability is determined by the skill's prop property, which scales with level:
| FA Skill Level | Proc Rate |
|---|---|
| 1 | ~10% |
| 5 | ~30% |
| 10 | ~40% |
| 15 | ~50% |
| 20 | ~60% |
Implementation Note: Use
GetSkillProbSuccess(skillId, skillLevel)to retrieve the actualpropvalue from skill data.
The client uses hitbox collision to determine if FA should be checked:
- Hitbox: Always uses
swingT1action hitbox - Afterimage: Determined by weapon type
- Orientation: From display byte bit 7
// Server implementation in FinalAttackDispatcher.cs
bool anyMobInRange = _collisionService.HasMobInAttackRange(
playerX, playerY, facingLeft,
afterimage: "swordTS",
action: "swingT1",
allMobs,
margin: 40 // 40px safety margin
);
if (anyMobInRange) {
uint procRng = character.damageRng.Random(); // Consume FA RNG
}The dispatcher skips FA consumption for:
| Condition | Skip FA? | Reason |
|---|---|---|
| Display 22-27 with skillId=0 (True Bonk) | ✓ | Bonks don't trigger FA |
| FA Skill packet (3100001/3200001) | ✓ | FA can't trigger itself |
| AOE Skills (Arrow Rain/Bomb) | ✓ | FA in per-mob buffer |
| Mortal Blow | ✗ | Uses dispatcher for SUCCESS RATE |
Mortal Blow is a 3rd job Hunter/Sniper skill that replaces basic bonk attacks:
- Ranger Mortal Blow: Skill ID 3110001
- Sniper Mortal Blow: Skill ID 3210001
CRITICAL: Mortal Blow uses the same dispatcher path as FA, but the RNG roll determines the skill's success rate, NOT an FA proc. This must be handled separately!
| MB Skill Level | Success Rate |
|---|---|
| 1 | ~5% |
| 5 | ~15% |
| 10 | ~25% |
| 15 | ~32% |
| 20 | ~40% |
Note: When successful, Mortal Blow deals 150% damage + has a chance for instant kill if mob HP < 26%.
┌──────────────────────────────────────────────────────┐
│ Mortal Blow Dispatcher Path │
│ │
│ 1. Receives packet with skillId = 3110001/3210001 │
│ 2. Uses display 22 (bonk animation) │
│ 3. ALWAYS consumes 1 RNG at dispatcher level │
│ 4. This roll is for SUCCESS RATE (40%) │
│ 5. If success: High damage or instant kill │
│ 6. If fail: Normal bonk damage │
│ │
│ NOTE: Even 0-hit whiffs consume this RNG! │
└──────────────────────────────────────────────────────┘
// In FinalAttackDispatcher.Dispatch()
if (decision.isMortalBlow)
{
uint propRng = character.damageRng.Random();
tracer.TraceDispatcherRng("MortalBlowProp", propRng);
}| Aspect | Final Attack | Mortal Blow |
|---|---|---|
| Skill Type | Passive proc | Active skill |
| Dispatcher RNG | FA proc check | Success rate check |
| Display Byte | Varies | Always 22 |
| Skip for Bonk Display | Yes | No (exception) |
| Triggers on 0-hit | Only if mob in range | Always |
┌─────────────────────────────────────────────────────────────┐
│ Attack Packet Received │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Extract display byte & skillId │
│ internalType = display & 0x7F │
│ isBonkDisplay = internalType >= 22 && <= 27 │
└──────────────────────────────┬──────────────────────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ skillId = 3110001? │ │ skillId = 3100001? │
│ (Mortal Blow) │ │ (FA Skill Packet) │
└──────────┬──────────┘ └──────────┬──────────┘
│ YES │ YES
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Consume 1 RNG for │ │ SKIP dispatcher FA │
│ success rate check │ │ (prevents recursion)│
└─────────────────────┘ └─────────────────────┘
│ NO │ NO
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Is AOE Arrow Skill? │
│ (Arrow Rain / Arrow Bomb) │
└──────────────────────────┬──────────────────────────────┘
│
┌────────────────┴────────────────┐
│ YES │ NO
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────┐
│ SKIP dispatcher FA │ │ Is Bonk Display && not MB? │
│ (FA in per-mob buf) │ │ (display 22-27 && !mortal) │
└─────────────────────┘ └──────────────┬──────────────┘
│
┌───────────────┴───────────────┐
│ YES │ NO
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ SKIP dispatcher FA │ │ Has FA Skill? │
│ (bonks skip FA) │ │ Mob in range? │
└─────────────────────┘ └──────────┬──────────┘
│ YES to both
▼
┌─────────────────────┐
│ Consume 1 RNG for │
│ FA proc roll │
└─────────────────────┘
The server encapsulates this logic in a record:
public record DispatcherDecision(
bool hasFinalAttack, // Should check FA collision
bool isBonkDisplay, // Display 22-27
bool isTrueBonk, // Bonk + skillId=0
bool isMortalBlow, // 3110001/3210001
bool isFinalAttackSkill, // 3100001/3200001
bool isAoeArrowSkill, // Arrow Rain/Bomb
bool shouldSkipFAForBonkDisplay,
bool isMeleeSwing, // Type 9/11
int internalAttackType,
int weaponType
);Server and client can desync due to:
- Position drift: Server's mob positions ≠ client's mob positions
- FA collision mismatch: Server thinks mob is in range, client doesn't (or vice versa)
- Off-by-one errors: Missing a whiff packet
┌─────────────────────────────────────────────────────────────┐
│ RNG Recovery Flow │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Save RNG state BEFORE FA decision │
│ stateBeforeFA = character.damageRng.GetState() │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Make FA decision (consume or skip based on collision) │
│ faConsumed = CheckCollisionAndConsume() │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Calculate damage for first hit │
│ serverDmg = CalculateDamage(...) │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Compare with client damage │
│ diff = abs(serverDmg - clientDmg) │
└──────────────────────────────┬──────────────────────────────┘
│
┌────────────────┴────────────────┐
│ diff > 5 │ diff <= 5
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────┐
│ 5. RESTORE RNG state │ │ Continue normally │
│ SetState(stateBeforeFA) │ │ │
└──────────────────┬──────────┘ └─────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 6. TOGGLE FA consumption │
│ if (faConsumed) { skip it } else { consume it } │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Retry damage calculation │
│ (Should now match client) │
└─────────────────────────────────────────────────────────────┘
// RngRecoveryService.cs
public async Task ProcessWithRecoveryAsync(
RngRecoveryContext context,
IDamageTracer tracer,
Func<Task<bool>> processMobs)
{
bool needsRetry;
int retryCount = 0;
bool currentFaConsumed = context.faDispatchResult.faConsumed;
do
{
needsRetry = await processMobs();
if (needsRetry && retryCount < MaxRetries)
{
retryCount++;
// Restore RNG state to BEFORE FA decision
context.character.damageRng.SetState(
context.faDispatchResult.stateBeforeFA);
// Toggle FA consumption
if (currentFaConsumed)
{
currentFaConsumed = false;
// Don't consume RNG this time
}
else
{
currentFaConsumed = true;
context.character.damageRng.Random(); // Consume
}
}
else
{
needsRetry = false;
}
} while (needsRetry);
}┌─────────────────────────────────────────────────────────────┐
│ AttackHandlerBase │
│ - Receives attack packets │
│ - Orchestrates damage calculation │
│ - Sends damage packets to mobs │
└──────────────────────────────┬──────────────────────────────┘
│ uses
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ IDispatcherDecision│ │ IFinalAttack │ │ IRngRecovery │
│ Service │ │ Dispatcher │ │ Service │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ Analyze() │ │ Dispatch() │ │ ProcessWith │
│ └─► Decision │ │ └─► RNG consume │ │ RecoveryAsync() │
│ record │ │ + state │ │ └─► retry loop │
└───────────────────┘ └───────────────────┘ └───────────────────┘
MapleWorld/
├── Services/Combat/Dispatching/
│ ├── DispatcherDecision.cs # Decision record
│ ├── IDispatcherDecisionService.cs
│ ├── DispatcherDecisionService.cs # Analyze attacks
│ ├── FinalAttackDispatchResult.cs # FA result record
│ ├── IFinalAttackDispatcher.cs
│ ├── FinalAttackDispatcher.cs # FA collision + RNG
│ ├── RngRecoveryContext.cs # Recovery records
│ ├── IRngRecoveryService.cs
│ └── RngRecoveryService.cs # Retry mechanism
│
├── Handlers/Network/Attacks/
│ ├── AttackHandlerBase.cs # Base attack handler
│ ├── MapleRangedAttackHandler.cs # 0x17 ranged
│ ├── MapleMeleeAttackHandler.cs # 0x16 melee
│ └── MapleMagicAttackHandler.cs # 0x18 magic
│
└── Services/Skills/Calculators/
├── DamageConstants.cs # Skill IDs, formulas
├── DamageOrchestrator.cs # Pipeline orchestration
└── Providers/
└── BowDamageCalculator.cs # Bow-specific formulas
| Skill ID | Name | Effect |
|---|---|---|
| 3000001 | Critical Shot | Enables critical hits |
| 3101002 | Hunter Final Attack | Enables FA procs (Bow) |
| 3201002 | Crossbowman Final Attack | Enables FA procs (Xbow) |
| Skill ID | Name | Description |
|---|---|---|
| 3100001 | Final Attack (Bow) | Actual FA attack packet |
| 3200001 | Final Attack (Crossbow) | Actual FA attack packet |
| Skill ID | Name | Notes |
|---|---|---|
| 3101005 | Arrow Bomb | AOE, FA in per-mob buffer |
| 3111004 | Arrow Rain | AOE, FA in per-mob buffer |
| 3110001 | Ranger Mortal Blow | Dispatcher RNG = success rate |
| 3210001 | Sniper Mortal Blow | Dispatcher RNG = success rate |
// DamageConstants.cs
public const int HunterFinalAttackSkillId = 3101002;
public const int CrossbowmanFinalAttackSkillId = 3201002;
public const int FinalAttackBowSkillId = 3100001;
public const int FinalAttackCrossbowSkillId = 3200001;
public const int RangerMortalBlowSkillId = 3110001;
public const int SniperMortalBlowSkillId = 3210001;
public const int ArrowBombSkillId = 3101005;
public const int ArrowRainSkillId = 3111004;| Attack Type | Dispatcher RNG | Heartbeat RNG | Total |
|---|---|---|---|
| Standard Ranged + FA | 1 (if mob in range) | 7 | 7-8 |
| True Bonk (skillId=0) | 0 | 7 | 7 |
| Manual Swing (9/11) | 1 (FA check) | 7 | 8 |
| Mortal Blow | 1 (success rate) | 7 | 8 |
| Arrow Rain/Bomb | 0 | 7 per mob | 7×n |
| FA Skill Packet | 0 | 7 | 7 |
Standard Ranged: (DEX_interp × Mult + STR) × WATK × 0.01 − Defense
True Bonk: (STR + DEX_interp × Mult) × WATK × 0.01 (no defense)
Manual Swing: (STR + DEX_interp) × WATK × 0.005 − Defense
Last updated: 2026-01-20 Based on IDA analysis of MapleStory v40 client binary