Stat Calculation Pipeline

How a unit's displayed stat (HP / STR / INT / DEX / SPD / Tough / Luck / Mana) is computed at runtime.

Reverse-engineered from RoleHelper.GetRoleAttr (decompile line 88725-88828) and verified end-to-end against captured game runtime via a Mono.Cecil-injected logger that dumps stats at 6 stages of the pipeline.

TL;DR — 7-layer additive pipeline

FINAL = LegalTrim(
    BASE                                         # 5-axis sum at level 1
    + InitialMagic_MageBonus                     # +10 if Class=Mage
    + Σ Modifier.ChangeAttrFirst()              # bonds + subskills + talents + prefixes + runes (pass 1)
    + Σ Insight_Relic.ChangeAttr() (player only) # 4 XD-prefix relics in RelicsDataTable
    + LevelScaling                               # 1 stat point per axis per level, cycling GrowthType1..10
    + GearSum                                    # Σ EquipHelper.GetEquipAttr per equipped slot
    + Σ Modifier.ChangeAttrSecond()             # same modifier list, pass 2
    + (BossConsBuff% × accumulated CONS)        # boss-only HP buff
    + Σ Modifier.ChangeAttrFinal()              # same modifier list, pass 3
)

Modifier sources (collected once per GetRoleAttr call): - GetRoleBondList(role) — set-bond effects from equipped gear - GetRoleSubskillList(role) — Trait/Title/Perk passives + Race/NormalAttack passive - GetRoleTalentList(role) — equipped Talent Fruit effects - Prefix list (enemy-only) — Ancient/Infected/Frozen/etc. from PrefixDataTable - GetRoleRuneModifiers(role) — rune material resonance levels - WorldMap.GetEffects(isChallenge) — map-effect modifiers - ParagonChallengePanel selected modifiers (Paragon mode, enemy only)

Each modifier is called 3 times per stat-compute: ChangeAttrFirstChangeAttrSecondChangeAttrFinal. Different modifier types use different passes (e.g., percentage buffs typically use Final to apply on accumulated value).

Stage 1 — BASE (role.data.GetInitialAttr())

Formula (decompile line 37444-37464):

For each of the 5 axes in BasicAttrDataTable[ role.<axis> ]:
    axes = [BasicAttr[Step], BasicAttr[Class], BasicAttr[Element],
            BasicAttr[Map], BasicAttr[Type]]   # 5 lookups

CONS = Σ axes.InitialCONS
SPD  = Σ axes.initialSPD
Tough = Σ axes.initialTough
Luck = 0

STR = Σ axes.InitialSTR + (axes.initialAttack if Class.attack==力量)
                        + (axes.initialDefence if Class.defence==力量)
                        + (axes.initialWeak if Class.weakness==力量)
INT = same pattern with 精神
DEX = same pattern with 敏捷

The combat-stat dispatch uses BattleHelper.GetAttackingAttr(Class) / GetDefendingAttr / GetWeaknessAttr:

Class Attack stat Defence stat Weakness stat
Warrior (战士) 力量 STR 精神 INT 敏捷 DEX
Mage (法师) 精神 INT 敏捷 DEX 力量 STR
Archer (射手) 敏捷 DEX 力量 STR 精神 INT
Priest (牧师) 精神 INT 力量 STR 敏捷 DEX

Gotcha: the 5th axis is Type (race-family — 生灵/魔灵/器灵/亡灵/神灵), NOT the Race column (which holds the species name like 神族/龙族 in compound BD20015,神族 form). BasicAttrDataTable only has entries for the 5 race-family categories.

Stage 2 — InitialMagic Mage bonus

if (role.data.isMage) attr.InitialMagic += 10;

A flat +10 starting mana hardcoded for Mages, applied right after BASE assignment. NOT included in GetInitialAttr itself — it's added by GetRoleAttr to the accumulator after the base is loaded.

Stage 3 — LevelScaling (role.data.GetLevelAttr(level))

For each level i from 1 to role.level: - index = i % 10 - For each of the 5 axes, look up axis.GrowthType{index+1} (CN stat name) - Add 1 to that stat in the level-attr accumulator

So at level N, each axis has contributed N stat points distributed across its 10-position growth cycle.

Example for a Warrior (axis.GrowthType1..10 = HP, STR, HP, STR, HP, INT, HP, STR, Tough, INT): - Level 1: +STR (idx 1 → GrowthType2) - Level 2: +HP (idx 2 → GrowthType3) - ... - Level 10: +HP (idx 0 → GrowthType1) - Level 11: +STR (idx 1 → GrowthType2) - ...

→ Over 10 levels: +5 HP, +3 STR, +1 INT, +1 Tough from the Class axis alone. Multiply by 5 axes for the full level-scaling rate.

This is the "soft" stat growth — at level 100, a unit's CONS modifier is roughly 50 from level alone.

Stage 4 — GearSum (Σ EquipHelper.GetEquipAttr(equip))

For each gear slot (10 total — Main / Off / Head / Body / Gloves / Belt / Shoes / Necklace / Ring / Badge):

attr.CONS  = equip.data.CONS
attr.STR   = equip.data.STR
attr.INT   = equip.data.INT
attr.DEX   = equip.data.DEX
attr.SPD   = equip.data.SPD
attr.Luck  = equip.data.Luck
attr.Tough = equip.data.Tough
attr.InitialMagic += equip.data.InitialMagic

# Per-star round-robin distribution:
n = equipUpgTimes[Step]   # C=1, B=1, A=2, S=4, SS=8
upg_list = equip.data.UpgradeAttr.split(',')
for i in range(equip.star * n):
    stat_cn = upg_list[i % len(upg_list)]
    attr[stat_cn] += 1

equipUpgTimes = [1, 1, 2, 4, 8] for [C, B, A, S, SS]. At ★3 max: - C gear: +3 points distributed across UpgradeAttr stats - B gear: +3 points - A gear: +6 points (2 per star × 3 stars) - S gear: +12 points - SS gear: +24 points

Detailed example in starup.

Stage 5 — Modifier passes 1/2/3

Modifiers (bonds, subskills, talents, runes, prefixes, map effects, paragon modifiers) collectively form a List<BattleModifier>. The whole list is iterated 3 times per stat compute, with different methods called each pass:

Pass Method When Typical use
First ChangeAttrFirst(role, attr) Right after BASE, BEFORE level+gear Flat additive (e.g., +50 HP) on the BASE accumulator
Insight Relic relic.ChangeAttr(role, attr) Player-only, between First and level+gear Insight unlocks (XD11001_, XD12001_, XD13001_, XD14001_)
Second ChangeAttrSecond(role, attr, attr2) After level+gear is added Conditional/state-dependent (+X if has Y status) — accumulates into separate attr2
BossConsBuff (built-in) Enemy-only, if isBoss attr2.CONS += accumulated_CONS × (BossConsBuff% - 1)
Final ChangeAttrFinal(role, attr4) After attr4 = attr + attr2, before trim Percentage scaling (× 1.5) on near-final value

Why 3 passes? Order matters for stacking — e.g., a "+50% HP" talent (Final) should multiply on top of "+100 HP from bond" (First), not the other way. The pass system gives the team designer explicit ordering control without requiring runtime priority resolution.

Stage 6 — LegalTrim (attr4.LegalTrim())

Decompiled exactly (Attr.cs line 83376-83394):

public void LegalTrim() {
    if (CONS <= 0)        CONS = 1;          // HP floor 1 (NOT 0)
    if (MaxMagic <= 0)    MaxMagic = 1;      // Mana cap floor 1
    if (InitialMagic < 0) InitialMagic = 0;  // Starting mana floor 0
    if (Tough <= 0)       Tough = 1;         // Toughness floor 1
}

Only 4 stats are clamped. Notable consequences:

This explains the M21204 boss case in our capture — its SPD-84 modifier wasn't clamped because SPD isn't in the LegalTrim list.

Verification — captured runtime vs computed

Patched MainScripts.dll via Mono.Cecil to inject IL log calls at 6 points in GetRoleAttr:

Stage Inject after Captures L? Tag
1 stloc.2 attr (initial assignment) L2 STAT_BASE
2 foreach ChangeAttrFirst loop exit L2 STAT_AFTER_FIRST
3 foreach Insight Relic loop exit L2 STAT_AFTER_RELIC
4 stloc.s V_7 (gear loop completed) L7 standalone STAT_GEAR
5 foreach ChangeAttrSecond loop exit L2 STAT_AFTER_SECOND
6 callvirt LegalTrim (final) L8 STAT_FINAL

Output format per line:

STAT_<STAGE> <role.IDs> {"CONS":N,"STR":N,"INT":N,...}

Captured (Apr 2026, 1.1.1.0 game version, stat_breakdown_run4.log):

M11302 Shadow Dragoness (Warrior, Dark, Forest, level=?):
  STAT_BASE   : CONS=18, STR=22, INT=6,  DEX=2,   SPD=14, Tough=22, Luck=0,  InitialMagic=0
  STAT_GEAR   : CONS=1,  STR=17, INT=13, DEX=1,   SPD=2,  Tough=2,  Luck=0,  InitialMagic=5
  STAT_FINAL  : CONS=207,STR=137,INT=121,DEX=105, SPD=30, Tough=53, Luck=4,  InitialMagic=5

Cross-validated STAT_FINAL against the in-game battleLog.txt ("生命:207, 力量:137, ...") — exact match.

Python re-compute (BASE only)

data-session/tools/replay_stats.py re-implements GetInitialAttr in pure Python and verifies against captured BASE for every unit in the log.

Result on captured 8 units (3 player + 3 enemy + 2 misc):

M00000  Dummy           ✅ MATCH   (16/24/8/2/12/22/0/0)
M11302  Shadow Dragoness ✅ MATCH  (18/22/6/2/14/22/0/0)
M51303_000 Imp           ✅ MATCH  (20/18/12/0/12/22/0/0)
M53301_001 Astral Fiend  ✅ MATCH  (11/4/29/2/16/22/0/0)
M41301  Ghost Captain    ✅ MATCH  (18/26/4/0/14/22/0/0)
M12301_001 Harpy Princess ✅ MATCH (10/2/0/28/22/22/0/0)
M54301  Forest Nymph     ✅ MATCH  (16/4/24/2/18/20/0/0)
M13305_000 Grand Magician ✅ MATCH (11/0/29/8/16/20/0/0)

8/8 unit exact match → formula re-implementation is correct.

Insights from the data

General

  1. Modifier system dominates at mid/late game. Shadow Dragoness gear contributes +17 STR / +13 INT — the modifier system adds +98 STR / +102 INT (5-7× more). Talents + bonds matter more than gear ★ when you're past mid-game.

  2. Gear contribution is small compared to BASE for tank stats. Shadow Dragoness gear gives +1 CONS — that's because gear primarily scales offensive stats. CONS comes mostly from BASE + level + Insight relic.

  3. Insight Relic adds a modest spread of stats (typical: ~30-50 total points across 4-6 stats): - CONS +0..+16, INT +2..+20, DEX 0..+6, SPD 0..+16, Tough +4..+8, Luck +4..+12. - Notable: Luck/SPD/Tough are stats poorly served by bonds/talents — Insight is the main player-side source for these.

Verified at runtime — Pass 1 / 2 / 3 separation (stat_breakdown_run5.log)

Captured 6 units across player + enemy after extending the patcher to 6 stages. Key proven mechanics:

BossConsBuff from DifficultySpec lộ ra cụ thể. Enemy unit M21204 had Pass3+Trim adding +2772 CONS on top of accumulated 924 CONS. Ratio = exactly 3.0 → BossConsBuff = 4.0× (formula attr2.CONS += accumulated × (bossConsBuff - 1)). Confirms decompile line 88816-88820 + DifficultySpecDataTable.BossConsBuff column.

DifficultySpec.Extra* fields applied between gear-add and Pass2 for enemies: - ExtraCONS ≈ 600 in level+Pass2 layer for current difficulty - ExtraATK ≈ 370 (routed to Class's attack stat — INT for Mage M23201) - ExtraDEF ≈ 200 (routed to defense stat — DEX for Mage) - ExtraWEK ≈ 150 (routed to weakness stat — STR for Mage) - These are flat per-tier scaling, not multiplicative.

Map/Prefix debuff visible — both captured enemies show identical -84 SPD in Pass1, regardless of unit type. Likely a map-effect modifier applied to all enemies on this map (or a shared Sluggish-type prefix).

LegalTrim is selective. M21204 ended with SPD = -52 (negative!) despite the call to LegalTrim — clamping seems to apply only to CONS/Tough/MaxMagic, not SPD/STR/INT/DEX. M23201 ended SPD = 0 (clamped from -24) suggesting context-dependent floor application. Worth deeper investigation.

Pass 3 (ChangeAttrFinal) modifiers are rare but strong. Only 1 of 4 captured player units had any Pass3 contribution (Luck +24). Most talents/bonds register in Pass 1 or 2. For enemies, Pass 3 is dominated by BossConsBuff.

Edge cases

Dummy tutorial unit has +1002 CONS modifier from somewhere. Captured Dummy showed BASE=16 CONS but FINAL=1018. Some hidden tutorial-only bond/relic gives Dummy effectively infinite HP — "tutorial scaling", not a normal modifier source.

Enemy unit max-mana cap exceeds the 30 hardcoded limit. Stellar Archer (M52301 as enemy at difficulty 190) showed MaxMagic 270 — this is set by DifficultySpec scaling, not the per-unit hardcoded cap (which only applies to player roles).

Tools

Open questions (future research)

A. Per-modifier attribution (STAT_AFTER_FIRST shows SUM, not per-modifier)

Currently STAT_AFTER_FIRST captures L2 after ALL First-pass modifiers applied. To know which bond/talent/relic contributed how much, inject log INSIDE the foreach loop.

Methodology (designed, not yet executed):

In RoleHelper.GetRoleAttr at IL_0280 (the callvirt ChangeAttrFirst), inject AFTER the call:

;; The Enumerator local V_15 still holds the current modifier (MoveNext not yet called)
ldloca.s V_15
call instance !0 Enumerator`1<BattleModifier>::get_Current()    ;; -> BattleModifier
callvirt instance string BattleModifier::get_IDs()              ;; -> "BD40326_001" etc
;; ... combine with attr state JSON ...
;; AppendAllText "MOD_FIRST <role.IDs> <modifier.IDs> {attr-json}\n"

Complication: Cecil method-reference for the generic Enumerator<BattleModifier>::get_Current() requires importing a generic method instantiation, which is non-trivial via the pythonnet binding. Easier path: reuse the existing get_Current MethodReference from the IL_0279 instruction (which Cecil already has a working ref for).

Output format (designed):

MOD_FIRST <role.IDs> <modifier.IDs> {attr after this single modifier's First pass}

Diff between consecutive MOD_FIRST lines = that specific modifier's First-pass contribution. With ~10-30 modifiers per unit per battle, log ~6-fold larger than current STAT_BASE/AFTER_* breakdown — manageable.

B. attr2 (L3) standalone capture

ChangeAttrSecond signature is (role, attr, attr2) — mutates both L2 and L3. We currently capture L2 post-loop but not L3. Adding a 7th inject point right after the foreach completion to log L3 would expose: which modifiers add to attr2 vs attr (and thus what gets the BossConsBuff multiplier).

Trivial extension to patch_stat_logger.py: same pattern as STAT_AFTER_SECOND but loading L3 instead of L2. ~13 IL instructions, no new method references needed.

C. Per-rune resonance contribution isolation

Rune modifiers route through GetRoleRuneModifiers(role) (decompile line 88830-88904). Each equipped rune contributes: - Its own RuneBase modifier (based on RuneDataTable[runeId].IDs.Split('_')[0] class name) - Plus the material's RuneMaterialBase modifier (resonance bonus)

The resonanceLevel (line 88894) depends on total runes of same material across all 10 slots — so isolating a single rune's contribution requires equipping ONLY that rune at a time.

Methodology (designed, not yet executed — requires user cycles): 1. Patch v6 deployed (stat logger active) 2. Equip 1 specific rune of material X at star Y → capture STAT_AFTER_FIRST 3. Unequip all runes → capture STAT_AFTER_FIRST 4. Diff = that single rune's contribution at its current resonance level (alone = level 1) 5. Re-equip with 2nd identical rune → diff captures bump at resonance level 2 6. ... repeat for level 3 7. Repeat per material × per star → ~36 captures (12 materials × 3 stars)

Total time-cost: 30-60 min user interaction. Defer for a focused session dedicated to rune mechanics.

D. Modifier ordering within passes

Each pass iterates modifiers in a sorted order (list.Sort at line 88777). The comparer is string.CompareOrdinal(a.IDs, b.IDs) — pure alphabetical by ID string. Implication: same modifier set ALWAYS applies in same order regardless of equip-order, so the breakdown is deterministic. No combinatorial ordering issues to worry about.

E. Replay tool: modifier contribution

replay_stats.py currently implements BASE + GEAR + LEVEL accurately. To implement MODIFIER contribution in pure Python would require: - Parsing 805 BondDataTable rows × Effect/Value0..5 for piece-count thresholds - Parsing 619 TalentDataTable rows × ditto - Parsing the Insight relic subset of RelicsDataTable (~166 XD-prefix entries) - Implementing the 3-pass execution order - Implementing conditional effects (e.g., "+20 INT IF teammate has Frostbound element")

This is essentially reimplementing the game's combat math in Python. Scope: 1-2 weeks of focused dev work. For now, easier to capture FINAL stats live via the patcher rather than predict them analytically.

References