User:Trang Oul/Utils
Jump to navigation
Jump to search
Calculating skill params formulae and values per level
This tutorial's goal is to obtain two results:
- a formula for a given skill's property (damage, range, duration and so on)
- values for that property at different levels (1-60, as on skill pages).
1. Obtaining data
First we need to find the formulae. They are stored in data files, in skills.txt
and in skillcalc.txt
.
They are ordinary TSV files, and while there are specialized tools for modding Diablo 2, plain old MS Excel suffices, with features like freezing headers, filtering or searching.
This part is based on Phrozen Keep.
File: skills.txt
Columns
Formulae
Formulae are stored in the following columns (this list may not be exhaustive):
calcX
- general skill formulae - seecalcX desc
for descriptionprgcalcX
- Assassin finishers; also Shock Web, Blade Furyauralencalc
- length of aura/curse/buff/debuff, in framesaurarangecalc
- range of aura/curse/buff/debuff; also used by other skillsaurastatcalcX
- stats of aura/curse/buff/debuff - seeaurastatX
for stat namepassivecalcX
- passive skills - seepassivestatX
for stat namepetmax
- max number of petsDmgSymPerCalc
- synergy to physical damageEDmgSymPerCalc
- synergy to elemental damageELenSymPerCalc
- synergy to the duration of elemental damage- client-side calculations - not used for our purposecltcalcX
Params
ParamX
- numeric param used in formula - seeParamX Description
for description- other columns (including, but not limited to
ELen...
,EMin...
,EMax...
) - referenced by formulae below.
Formula details
- hardcoded numbers
- basic math operators and functions: arithmetic,
min
,max
- conditional operator:
condition ? value_if_true : value_if_false
- functions and params as defined in
skillcalc.txt
:
lnXY
,dmXY
- linear and diminishing (rational) functions. We will be the most interested in these formulae.
- All of them use skill level and additional params.
- Currently all those functions are the same per category.
parX
- parameter (columnParamX
)lvl
- soft skill level (i.e. with bonuses)blvl
- base skill level (i.e. only hard points); used mostly for synergiesulvl
- character level; used by Druid summons only- references to other column or columns - used mostly for progression based on level ranges
- Examples:
edns
- "Elemental Damage Min (256ths)"
- Used by Prayer to calculate life regen.
- As per Phrozen Keep:
- EMin: and EMax: Controls the basic minimum and maximum elemental damage dealt by this skill at sLvl 1.
- EMinLev1: to EMinLev5: and EMaxLevDam1: to EMaxLevDam5: Controls the additional elemental damage added to the skills minimum and maximum damage for every additional sLvl.
- LevDam1 is used for sLvls 2-8, LevDam2 for 9-16, LevDam3 for 17-22, LevDam4 for 23-28 and LevDam5 for sLvls above 28.
- So, Prayer starts with 2 (col
EMin
) life points at level 1, increasing by 1 per level at levels 2-8 (EMinLev1
), again by 1 at levels 9-16 (EMinLev2
) and so on, ending by +3 per each level above 28.
edmn
- "Elemental Damage"
- Used for Energy Shield's' Damage % Absorption (
min(edmn,95)
) - It starts at 20% (
EMin
), increased by 5 percentage points per skill (EMinLev1
), then by 2 p.p. (EMinLev2
), then by 1 p.p. (EMinLev3
,EMinLev4
,EMinLev5
), capped at 95%.
- Notice that although these functions refer to "Elemental Damage" columns, for those skills these columns just hold the values, used for other properties than damage.
edmx
- "Elemental Damage Max"
- Used by Dire Wolf's max cold damage (in D2R); cols
EMax...
.
skill('Skill Name'.fun)
- reference to another skill - used for synergies:- Examples:
skill('Golem Mastery'.ln12)
-ln12
formula calculated using Golem Mastery params.skill('Summon Fenris'.par2)
-Param2
of Summon Fenris (i.e. Summon Dire Wolf).skill('Summon Fenris'.lvl)
- (soft) skill level of Summon Fenris.
stat('stat_name'.fun)
- used mostly to make pets inherit character stats- Examples:
stat('item_pierce_ELE_immunity'.accr)
- Sunderstat('passive_ELE_pierce'.accr)
- Enemy Resistancestat('passive_ELE_mastery'.accr)
- Skill Damage
2. Calculation
Examples:
Skill Name | Column description if present |
Formula | Params (besides lvl ) |
Simplified Formula | Values (sample) | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Jab | calc1 : Damage % |
ln34 |
Param3=-15, Param4=3 | 3*lvl - 18 |
| ||||||||||||
Battle Cry | auralencalc |
ln12 |
Param1=300, Param2=75 Since this is duration, we convert it to seconds by dividing by 25 and allowing fractional values. |
12*lvl/5 + 48/5 or 2.4*lvl + 9.6 |
| ||||||||||||
Clay Golem | aurastatcalc1 : item_slow |
dm34 |
Param3=0, Param4=75 | 165*lvl/(2*lvl + 12) |
| ||||||||||||
Blaze | auralencalc |
dm12 |
Param3=50, Param4=500 Since this is duration, we convert it to seconds by dividing by 25 and allowing fractional values. |
(109*lvl + 60)/(5*lvl + 30) |
| ||||||||||||
Chain Lightning | calc1 : # of target jump hits |
ln34/par5 |
Param3=26, Param4=1, Param5=5 | lvl/5 + 5 |
| ||||||||||||
Strafe | aurastatcalc1 : Minimum # of Missiles created |
min(par3 + lvl - 1, par4) |
Param3=0, Param4=75 | Min(10, lvl + 3) |
| ||||||||||||
Raise Skeleton | petmax |
(lvl < 4) ?lvl:(2+lvl/3) |
(none) | Piecewise((lvl, lvl < 4), (lvl/3 + 2, True)) |
| ||||||||||||
Prayer | aurastatcalc1 : hitpoints |
edns |
EMin...: 2, 1, 1, 2, 2, 3 | Piecewise((lvl + 1, (lvl <= 8) | (lvl <= 16)), (2*lvl - 15, (lvl <= 22) | (lvl <= 28)), (3*lvl - 43, True)) |
| ||||||||||||
Energy Shield | aurastatcalc1 : Damage % Absorption |
min(edmn,95) |
EMin...: 20, 5, 2, 1, 1, 1 | Min(95, Piecewise((5*lvl + 15, lvl <= 8), (2*lvl + 39, lvl <= 16), (lvl + 55, True))) |
|
The following Python script can be used to simplify the formula and calculate values per level.
#coding: utf-8 import math import sympy FRAMES_PER_SECOND:int = 25 lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev1, lev2, lev3, lev4, lev5 = sympy.symbols('lvl, par1, par2, par3, par4, par5, par6, par7, par8, base, lev1, lev2, lev3, lev4, lev5', integer=True) # the formulae are the same per category, but for ease of use they are defined with names from skillcalc.txt ln12_sympy: sympy.core.expr.Expr = par1 + (lvl - 1) * par2 ln34_sympy: sympy.core.expr.Expr = par3 + (lvl - 1) * par4 ln56_sympy: sympy.core.expr.Expr = par5 + (lvl - 1) * par6 ln78_sympy: sympy.core.expr.Expr = par7 + (lvl - 1) * par8 # dm12_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par2 - par1)) / (100 * (lvl + 6))) + par1 #floor because of the integer division # dm34_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par4 - par3)) / (100 * (lvl + 6))) + par3 # dm56_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par6 - par5)) / (100 * (lvl + 6))) + par5 # dm78_sympy: sympy.core.expr.Expr = sympy.floor(((110 * lvl) * (par8 - par7)) / (100 * (lvl + 6))) + par7 # A formula different than in skillcalc.txt, but the results match those in the game (credit: Onderduiker). dm12_sympy = sympy.Min(par1 + sympy.floor((par2 - par1) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par2) dm34_sympy = sympy.Min(par3 + sympy.floor((par4 - par3) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par4) dm56_sympy = sympy.Min(par5 + sympy.floor((par6 - par5) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par6) dm78_sympy = sympy.Min(par7 + sympy.floor((par8 - par7) * sympy.floor((110 * lvl) / (lvl + 6)) / 100), par8) levelwise3_sympy: sympy.core.expr.Expr = sympy.Piecewise( (base, lvl == 1), (base + lev1 * (lvl-1), lvl <= 8), (base + lev1 * 7 + lev2 * (lvl-8), lvl <= 16), (base + lev1 * 7 + lev2 * 8 + lev3 * (lvl-16), True) ) levelwise5_sympy: sympy.core.expr.Expr= sympy.Piecewise( (base, lvl == 1), (base + lev1 * (lvl-1), lvl <= 8), (base + lev1 * 7 + lev2 * (lvl-8), lvl <= 16), (base + lev1 * 7 + lev2 * 8 + lev3 * (lvl-16), lvl <= 22), (base + lev1 * 7 + lev2 * 8 + lev3 * 6 + lev4 * (lvl-22), lvl <= 28), (base + lev1 * 7 + lev2 * 8 + lev3 * 6 + lev4 * 6 + lev5 * (lvl-28), True), ) def make_formula(formula:str) -> sympy.core.expr.Expr: FORMULA_PARTS: dict[str, sympy.core.expr.Expr] = { 'lvl' : lvl, 'par1' : par1, 'par2' : par2, 'par3' : par3, 'par4' : par4, 'par5' : par5, 'par6' : par6, 'par7' : par7, 'par8' : par8, 'base' : base, 'lev1' : lev1, 'lev2' : lev2, 'lev3' : lev3, 'lev4' : lev4, 'lev5' : lev5, 'ln12' : ln12_sympy, 'ln34' : ln34_sympy, 'ln56' : ln56_sympy, 'ln78' : ln78_sympy, 'dm12' : dm12_sympy, 'dm34' : dm34_sympy, 'dm56' : dm56_sympy, 'dm78' : dm78_sympy, 'levelwise3': levelwise3_sympy, 'levelwise5': levelwise5_sympy, } return sympy.sympify(formula, FORMULA_PARTS) # the formulae are the same per category, but for ease of use they are defined with names from skillcalc.txt def ln12_py(lvl:int, par1:int, par2:int) -> int: return par1 + (lvl - 1) * par2 def ln34_py(lvl:int, par3:int, par4:int) -> int: return par3 + (lvl - 1) * par4 def ln56_py(lvl:int, par5:int, par6:int) -> int: return par5 + (lvl - 1) * par6 def ln78_py(lvl:int, par7:int, par8:int) -> int: return par7 + (lvl - 1) * par9 # def dm12_py(lvl:int, par1:int, par2:int) -> int: return ((110 * lvl) * (par2 - par1)) // (100 * (lvl + 6)) + par1 # def dm34_py(lvl:int, par3:int, par4:int) -> int: return ((110 * lvl) * (par4 - par3)) // (100 * (lvl + 6)) + par3 # def dm56_py(lvl:int, par5:int, par6:int) -> int: return ((110 * lvl) * (par6 - par5)) // (100 * (lvl + 6)) + par5 # def dm78_py(lvl:int, par7:int, par8:int) -> int: return ((110 * lvl) * (par8 - par7)) // (100 * (lvl + 6)) + par7 # A formula different than in skillcalc.txt, but the results match those in the game (credit: Onderduiker). def dm12_py(lvl:int, par1:int, par2:int) -> int: return min(par1 + math.floor((par2 - par1) * math.floor((110 * lvl) / (lvl + 6)) / 100), par2) def dm34_py(lvl:int, par3:int, par4:int) -> int: return min(par3 + math.floor((par4 - par3) * math.floor((110 * lvl) / (lvl + 6)) / 100), par4) def dm56_py(lvl:int, par5:int, par6:int) -> int: return min(par5 + math.floor((par6 - par5) * math.floor((110 * lvl) / (lvl + 6)) / 100), par6) def dm78_py(lvl:int, par7:int, par8:int) -> int: return min(par7 + math.floor((par8 - par7) * math.floor((110 * lvl) / (lvl + 6)) / 100), par8) def levelwise3_py(lvl:int, base:int, lev1:int, lev2:int, lev3:int) -> int: """ Formula based on level ranges (with 5 ranges). Base and values per level range are defined in columns, for example: EMin, EMinLev1, EMinLev2, EMinLev3, EMinLev4, EMinLev5 ELen, ELevLen1, ELevLen2, ELevLen3 Level ranges are hardcoded, as described in https://d2mods.info/forum/viewtopic.php?t=41556 """ if lvl == 1: return base if lvl <= 8: return base + lev1 * (lvl-1) if lvl <= 16: return base + lev1 * 7 + lev2 * (lvl-8) else: return base + lev1 * 7 + lev2 * 8 + lev3 * (lvl-16) def levelwise5_py(lvl:int, base:int, lev1:int, lev2:int, lev3:int, lev4:int, lev5:int) -> int: """ Formula based on level ranges (with 3 ranges). Base and values per level range are defined in columns, for example: ELen, ELevLen1, ELevLen2, ELevLen3 Level ranges are hardcoded, as described in https://d2mods.info/forum/viewtopic.php?t=41556 """ if lvl == 1: return base if lvl <= 8: return base + lev1 * (lvl-1) if lvl <= 16: return base + lev1 * 7 + lev2 * (lvl-8) if lvl <= 22: return base + lev1 * 7 + lev2 * 8 + lev3 * (lvl-16) if lvl <= 28: return base + lev1 * 7 + lev2 * 8 + lev3 * 6 + lev4 * (lvl-22) else: return base + lev1 * 7 + lev2 * 8 + lev3 * 6 + lev4 * 6 + lev5 * (lvl-28) class Stat: def __init__(self, desc:str, formula, params:dict|None=None, seconds:bool=False) -> None: if params is None: params = {} self.desc = desc self.params = params self.formula = self.make_formula(formula, seconds) if isinstance(self.formula, sympy.core.expr.Expr): print(self.desc) self.print_expr(seconds) @classmethod def make_formula(cls, formula, seconds:bool=False): if isinstance(formula, str): formula = make_formula(formula) if seconds: if isinstance(formula, sympy.core.expr.Expr): return formula / FRAMES_PER_SECOND else: return lambda **params: formula(**params) / FRAMES_PER_SECOND return formula @classmethod def round(cls, val) -> int|float: ival: int = int(val) fval: float = float(val) return ival if ival == fval else fval def _eval_sympy(self, slvl:int) -> int|float: return self.round((self.formula.subs({lvl: slvl} | self.params))) def _eval_py(self, slvl:int) -> int|float: params: dict[str, int] = {param.name: value for param, value in self.params.items()} return self.round(self.formula(lvl=slvl, **params)) def eval(self, slvl:int) -> int|float: if isinstance(self.formula, sympy.core.expr.Expr): return self._eval_sympy(slvl) else: return self._eval_py(slvl) def print_expr(self, seconds:bool=False) -> None: expr: sympy.core.expr.Expr = self.formula.subs(self.params) expr = expr.simplify() print(expr) if seconds: print(expr.evalf()) def build_table(stats, maxlvl:int=60, splitlvl:int=10): ''' Builds a wiki table of given stats for given level range. ''' return '\n\n'.join( f'''{{{{Levels {lvl_group+1}-{lvl_group+splitlvl}}}}} {'\n'.join(f'|-\n!align=left|{stat.desc}\n|{"||".join(str(stat.eval(lvl_group+lvl_unit)) for lvl_unit in range(1, splitlvl+1))}' for stat in stats)} |}}''' for lvl_group in range(0, maxlvl, splitlvl) ) """ USAGE Make a list of stats to be included in the table of values (levels 1-60). Formulae can be defined in three ways: 1. Pure Python: use Python functions - either defined here (fun_py) or other named or lambda functions, with lvl as the first param in addition to formula params. 2. SymPy: use SymPy expressions - either defined here (fun_sympy) or other expressions, using appropriate SymPy functions (for exampe sympy.Min instead of min). 3. Formula as string directly from the data file. Allowed formula parts: lvl, parameters (parX), functions (lnXY and dmXY) and some math functions (min, max). Remove synergies to calculate values without them. See the examples below. Use only one formula per stat; the repeated stats below are to show how to define formulae. For level-based values, use "levelwise" formulae, either for 3 or 5 columns. To convert time values from frames to seconds, set the "seconds" flag. For SymPy (or string) formulae simplified formulae are also printed. For time values formulae with integer and decimal coefficients are printed. """ stats: tuple[Stat] = ( Stat(desc='Inner Sight Duration (Seconds)', formula=ln34_py, params={par3: 300, par4: 150}, seconds=True), Stat(desc='Inner Sight Duration (Seconds)', formula=ln34_sympy, params={par3: 300, par4: 150}, seconds=True), Stat(desc='Inner Sight Duration (Seconds)', formula='ln34', params={par3: 300, par4: 150}, seconds=True), Stat(desc='Slow Missiles [[Missile]] Speed %', formula=lambda lvl,par1,par2: 100-dm12_py(lvl, par1, par2), params={par1: 25, par2: 75}), Stat(desc='Slow Missiles [[Missile]] Speed %', formula= 100-dm12_sympy, params={par1: 25, par2: 75}), Stat(desc='Slow Missiles [[Missile]] Speed %', formula= '100-dm12', params={par1: 25, par2: 75}), Stat(desc='Impale Durability loss chance %', formula=lambda lvl,par3,par4,par6: par6-dm34_py(lvl, par3, par4), params={par3: 0, par4: 30, par6: 50}), Stat(desc='Impale Durability loss chance %', formula= par6-dm34_sympy, params={par3: 0, par4: 30, par6: 50}), Stat(desc='Impale Durability loss chance %', formula= 'par6-dm34', params={par3: 0, par4: 30, par6: 50}), Stat(desc='Thunderstorm perdelay', formula=lambda lvl,par3,par4,par5,par6: (100-dm56_py(lvl, par5, par6)) * par4/100 + par3, params={par3: 25, par4: 100, par5: 0, par6: 100}, seconds=True), Stat(desc='Thunderstorm perdelay', formula= (100-dm56_sympy) * par4/100 + par3, params={par3: 25, par4: 100, par5: 0, par6: 100}, seconds=True), Stat(desc='Thunderstorm perdelay', formula= '(100-dm56) * par4/100 + par3', params={par3: 25, par4: 100, par5: 0, par6: 100}, seconds=True), Stat(desc='Battle Cry duration (Seconds)', formula= ln12_py, params={par1: 300, par2: 60}, seconds=True), Stat(desc='Battle Cry duration (Seconds)', formula= ln12_sympy, params={par1: 300, par2: 60}, seconds=True), Stat(desc='Battle Cry duration (Seconds)', formula='ln12', params={par1: 300, par2: 60}, seconds=True), Stat(desc='Chain Lightning hits', formula=lambda lvl, par3, par4, par5: ln34_py(lvl, par3, par4) / par5, params={par3: 26, par4: 1, par5: 5}), Stat(desc='Chain Lightning hits', formula= ln34_sympy / par5, params={par3: 26, par4: 1, par5:5}), Stat(desc='Chain Lightning hits', formula= 'ln34/par5', params={par3: 26, par4: 1, par5:5}), Stat(desc='Strafe min hits', formula=lambda lvl, par3, par4: min(par3 + lvl - 1, par4), params={par3: 4, par4: 10}), Stat(desc='Strafe min hits', formula= sympy.Min(par3 + lvl - 1, par4), params={par3: 4, par4: 10}), Stat(desc='Strafe min hits', formula= 'min(par3 + lvl - 1, par4)', params={par3: 4, par4: 10}), Stat(desc='Prayer healing', formula=levelwise5_py, params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}), Stat(desc='Prayer healing', formula=levelwise5_sympy, params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}), Stat(desc='Prayer healing', formula='levelwise5', params={base: 2, lev1: 1, lev2: 1, lev3: 2, lev4: 2, lev5: 3}), Stat( desc='Energy shield absorption', formula=lambda lvl, base, lev1, lev2, lev3, lev4, lev5: min(levelwise5_py(lvl, base, lev1, lev2, lev3, lev4, lev5), 95), params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1} ), Stat(desc='Energy shield absorption', formula=sympy.Min(levelwise5_sympy, 95), params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1} ), Stat(desc='Energy shield absorption', formula='min(levelwise5, 95)', params={base: 20, lev1: 5, lev2: 2, lev3: 1, lev4: 1, lev5: 1} ), Stat(desc='Spirit Wolf coldlength', formula=levelwise3_py, params={base: 25, lev1: 25, lev2: 25, lev3: 25}, seconds=True), Stat(desc='Spirit Wolf coldlength', formula=levelwise3_sympy, params={base: 25, lev1: 25, lev2: 25, lev3: 25}, seconds=True), Stat(desc='Spirit Wolf coldlength', formula='levelwise3', params={base: 25, lev1: 25, lev2: 25, lev3: 25}, seconds=True), Stat(desc='Chilling Armor cold length', formula=levelwise3_py, params={base: 100, lev1: 0, lev2: 25, lev3: 25}, seconds=True), Stat(desc='Chilling Armor cold length', formula=levelwise3_sympy, params={base: 100, lev1: 0, lev2: 25, lev3: 25}, seconds=True), Stat(desc='Chilling Armor cold length', formula='levelwise3', params={base: 100, lev1: 0, lev2: 25, lev3: 25}, seconds=True), Stat(desc='Clay Golem slow', formula=dm34_py, params={par3: 0, par4: 75}), Stat(desc='Clay Golem slow', formula=dm34_sympy, params={par3: 0, par4: 75}), Stat(desc='Clay Golem slow', formula='dm34', params={par3: 0, par4: 75}), Stat(desc='Blaze length', formula=dm12_py, params={par1: 50, par2: 500}, seconds=True), Stat(desc='Blaze length', formula=dm12_sympy, params={par1: 50, par2: 500}, seconds=True), Stat(desc='Blaze length', formula='dm12', params={par1: 50, par2: 500}, seconds=True), ) print(build_table(stats)) stats = ( #build your own table ) #print(build_table(stats))