SROCodex
ironsro

Damage

What this site works out between a swing and the number over a monster's head — every step marked as the game's, ours, or missing.

verified: partialSR_GameServer.exe (vSRO, 2013-09-11) — every step re-read at the addresses in the appendixlib/battle/blow.ts — the code this page describes

This is the arithmetic this site runs when you fight a monster here. Almost all of it was read out of the real server's own code, step by step — but not quite all, and the point of this page is that you can see which is which.

Three marks are used here and on every page in this section:

  • Game — read out of SR_GameServer.exe and implemented as read.
  • Ours — our own decision, because the game's answer is not in anything we can read.
  • Missing — not modelled at all, and why.

What happens to one swing

In order, from the moment a blow lands:

1. The swing itself · Game. Your attack power and the skill's percentage make a base number. A skill that says 350 % is a 3.5× multiplier on what your weapon and stats already give.

2. The roll · Game. The spread is not an even one. The server rolls three times and keeps the lowest, then adds or subtracts it with even odds. That is why damage here feels steady most of the time and then occasionally is not — the same feeling the real game gives, for the same reason.

3. Buffs · Game. Flat ones are added, percentage ones multiply.

4. The cap that surprises everyone · Game. Before any defence is touched, the base is clamped to 100. Every big number you ever see is made by the multipliers after this point, never by the attack power that went in.

5. Defence · Game. The defender's armour divides, and their flat defence is subtracted. Physical and magical attacks read a crossed pair of defence stats — physical damage is divided by magical defence and vice versa. That is not a slip in our copy; it is what the server does.

6. The floor · Game, and disputed. A blow the defence all but swallowed does not become zero: it is lifted back onto a small floor of about 5 % of the swing. See "Where we might be wrong" below — this is the one step where our two readings of the binary disagree with each other.

7. Skill power, elements, mastery, attack rating, reinforce · Game. In that order. Being ahead of the target in the weapon's mastery is worth up to 30 %.

8. What comes off the top · Game. Critical hits and blocks are rolled through the server's own dice, which are not a coin: it keeps a running account per skill, so a bad streak is paid back later. Over many swings the rate is exactly the number on the tooltip; over a handful, it is kinder than a fresh roll each time.

Where our number can differ from the shard's

Named rather than smoothed over, because a formula that is right in the middle and wrong at the edges is only useful if the edges are known.

Step What is unsettled
The 5 % floor Our two readings disagree. The appendix below reads the branch as a log line that changes nothing; the fight's own code reads it as a real floor and applies one. The fight is the one that runs. Until somebody reconciles them at the addresses, this is the loudest open question on the page
The base swing The server calls its attack-power routine twice with the same arguments and multiplies the two results. Whether the second call is meant to return something else — a second hand, a weapon term — is not settled
Reinforce Used as a plain multiplier, which does not fit reading it as a percentage the way its siblings are read
Defence rating The server works it out and then throws the result away. Whether that is a bug in this build or dead code is open

What our fight leaves out, and why

Missing Why
The elemental term No elemental skill is in play in a duel with a monster here, so the term is one. Present in the code, never anything but identity
Parry between elements The real routine only reduces when attacker and defender both carry an element flag. Against an ordinary monster it returns nothing, so our fight computes without it
A player-only factor on the swing's ends Real, and identity for a character in the ordinary state. The fight computes without it and says so here rather than pretending the function is not there

For anyone opening the binary

Everything below is the research this page stands on: the constants, the stat indices, the disassembly and the exact order of operations. It is written for somebody checking our arithmetic against the real thing, not for somebody playing.

Constants

Read from .rdata; the address is where you will find each one if you open the binary yourself.

RVA Value Type Used for
0xB45AC0 1.0 double the + 1 in def / 100 + 1
0xB45F30 2.0 double magical balance bonus
0xB45F60 0.05 double the 5 % minimum-damage check
0xB45AF0 100.0 double percentage divisor (350 % / 100 = 3.5×)
0xB45AF8 32767.0 double INT16_MAX, the random divisor
0xB45E10 100.0 float base damage ceiling
0xB45B44 10.0 float magical balance divisor
0xB45B00 5.0 double attack rating coefficient
0xB45D98 40.0 double attack rating constant
0xB45BF0 0.8 double attack rating balance (80 %)
0xB45DC8 0.03 double 3 % per mastery level of difference
0xB45B8C 0.3 float mastery bonus ceiling (30 %)
0xB45BA4 4294967296.0 float 2³², used to correct negative integers

Stat indices

GetStat(n) is vtable[0x104]. The indices matter more than they look: physical and magical attacks read different defence stats, and they are crossed over.

On the attacker

Index Meaning
2 feeds GetAttackRating — level, on the evidence of how it is used
128 (0x80) fire attack, magical
129 (0x81) ice attack, magical
130 (0x82) fire attack, physical
131 (0x83) ice attack, physical
179 (0xB3) reinforce bonus
189 (0xBD) magical balance
259 (0x103) immunity pierce

On the defender

Index Read by a physical attack Read by a magical attack
5 — magical defence
6 physical defence —
7 — physical defence
8 magical defence —

The crossover is the single most surprising thing in the routine: a physical hit divides by the defender's magical defence and then subtracts the physical one; a magical hit does the reverse.

1. Base damage

Both formulas start in the same routine, which takes the attacker, the skill, the defender and one extra parameter off the stack.

attackPower1 = GetAttackPower(attacker, skill)      ; call 0x40DC10
skillMul     = skillParam / 100 + 1                 ; skillParam is the 5th arg
attackPower2 = GetAttackPower(attacker, skill)      ; call 0x40DC10 again
baseDamage   = attackPower1 * skillMul * attackPower2

The x87 sequence, for anyone checking against the disassembly:

call   0x40dc10          ; f(attacker, skill) -> float
fstps  [esp+0x14]        ; atk1
movzbl 0x2c(%esp),%eax   ; skill parameter, 5th argument
fildl  [esp+0x30]        ; int -> float
fdivl  [100.0]
faddl  [1.0]
fstl   [esp+0x1c]        ; skillMul
fmuls  [esp+0x18]
fstps  [esp+0x18]
call   0x40dc10          ; atk2
fmull  [esp+0x18]
fstps  [esp+0x18]        ; baseDamage

Randomness: three rolls, lowest wins

float offset = 100.0f;
for (int i = 0; i < 3; i++) {              // rand() at 0x9DD338
    float roll = (float)rand() / 32767.0f * 100.0f;
    if (roll < offset) offset = roll;
}
int sign = (abs(rand()) & 0x80000001) + 1; // 1 or 2
damage = (sign == 1) ? damage - offset : damage + offset;

Taking the minimum of three rolls and then adding or subtracting it with even odds is not a symmetric spread: the offset itself is biased low, so most hits sit near the middle and the tails are thin. This is the part players describe as "damage feels consistent until it isn't".

Buffs

Ten skill offsets carry buff ids. Two are flat, eight are percentages.

Offset Kind
+0x520 flat
+0x4E0 flat
+0x4EC, +0x4F0, +0x4F4, +0x4F8, +0x528, +0x530, +0x540, +0x560 percentage
// flat
if (skill->buff != null && (v = FindBuffValue(attacker, buffId)) != null)
    damage += (float)v;

// percentage
if (skill->buffPercent != null && (v = FindBuffValue(attacker, buffId)) != null)
    damage *= (float)v / 100.0f + 1.0f;

The clamp that changes everything

if (damage < 0.0)   damage = 0.0;
if (damage > 100.0) damage = 100.0;   // ← base damage never exceeds 100

Base damage is capped at 100 before defence is applied. Every large number a player ever sees is produced by the multipliers after this point, not by the attack power that went in.

2. Physical damage — 0x40E830

phyDef = defender.GetStat(6);
magDef = defender.GetStat(8);

damage = baseDamage / (magDef / 100 + 1) - phyDef;
damage = max(0, damage);

if (skill.hasMagicParam)                     // e.g. 350 means 350 %
    damage = damage * skill.magicParam / 100;

elemAtk = skill.isFire ? attacker.GetStat(130)
        : skill.isIce  ? attacker.GetStat(131) : 0;

if (defender.isImmune && defender.immunityType == 3)
    immunityDamage = damage * attacker.GetStat(259) / 100;

parry = ParryCalc(attackerFlags, defenderFlags, defender);
if (parry > 0) damage *= parry;

damage *= elemAtk / 100 + 1;
damage += immunityDamage;

if (skill.isMagicalType)
    damage *= attacker.GetStat(189) + 2.0;

if (attacker.mastery > defender.mastery)
    damage *= 1.0 + min(0.3, (attacker.mastery - defender.mastery) * 0.03);

damage *= GetAttackRating(attacker);

if (damage < attackPower * 0.05)
    Log("ResultDamage is under PhyAttackPoint's 5%%");   // warning only

if (attacker.GetStat(179) > 0)
    damage *= attacker.GetStat(179);

GetAttackRating

float GetAttackRating(entity) {
    if (!entity->vtable[0x1C]()) return 1.0f;
    byte  v    = entity->vtable[0x128]();
    float stat = entity->GetStat(2);
    return max(0.0f, stat / (((v - 1) * 5 + 40) * 0.8f));
}
// which simplifies to  stat / (v * 4 + 28)

GetDefenseRating(defender) is called on the line after and its result is discarded. That is what the binary does; whether it is a bug in this build or dead code left from an earlier one is open.

Parry

ParryCalc only returns a reduction when the attacker's element flag meets the defender's element flag — fire against fire, fire against ice, ice against fire, ice against ice. Anything else returns 0.0, which means no reduction at all.

The 5 % check does nothing

if (damage < attackPoint * 0.05)
    Log("ResultDamage is under PhyAttackPoint's 5%");

The string is real and the branch is real, but damage is not modified. There is no 5 % floor in this build, only a log line — which is worth knowing, because the assumption that one exists is common.

3. Magical damage — 0x40E450

Identical except in five places.

  1. Defence stats are the other pair. magDef = GetStat(5), phyDef = GetStat(7), and the division uses physical defence: damage = baseDamage / (phyDef / 100 + 1) - magDef.
  2. Element stats are 128 / 129, not 130 / 131.
  3. One extra buff is added before defence. If the skill carries a buff at offset 0x524 and flag bit 0x08 is set, its value is added to base damage before the reduction — so it is multiplied by everything downstream.
  4. Magical balance is always applied. In the physical path it is conditional; here damage *= GetStat(189) + 2.0 runs unconditionally.
  5. The skill flag checked is 0x04, not 0x08.

4. Order of operations

Physical:

  1. baseDamage = atk1 × (skillParam / 100 + 1) × atk2
  2. ± min(rand₁, rand₂, rand₃) / 32767 × 100
  3. + flat buffs, × percentage buffs
  4. clamp(0, 100)
  5. ÷ (defender.MD / 100 + 1) − defender.PD
  6. max(0, …)
  7. × skill power / 100
  8. × (element attack / 100 + 1)
  9. + immunity pierce damage
  10. × parry result, when it fired
  11. × (magical balance + 2.0), magical-type skills only
  12. × (1 + mastery bonus), attacker ahead only
  13. × attack rating
  14. × reinforce bonus, when above zero

Magical is the same list with steps 5 and 8 reading the other stats, and step 11 unconditional.

What is still open

  • GetAttackPower (0x40DC10) is called twice with the same arguments and its two results are multiplied. Whether the second call is meant to return something different — a weapon term, a second hand — is not settled.
  • GetStat(179) is used as a plain multiplier, which does not fit a percentage reading. Either the stat is stored differently from its siblings, or this build changed it.
  • GetDefenseRating is called and discarded.

These three are exactly why the page says partial at the top. A formula that reproduces the server for common cases and is wrong at the edges is still worth having — but only if the edges are named.