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: ChangeAttrFirst → ChangeAttrSecond → ChangeAttrFinal. 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:
- STR / INT / DEX / SPD / Luck / ActionMagic / HitMagic can go arbitrarily negative. Captured M21204 ended with
SPD = -52— game proceeds normally with negative SPD (likely interpreted as "very slow / acts last" in turn-order code). - CONS / Tough clamp to 1, not 0. The game's combat logic separately tracks "alive" — a unit can never be brought to 0 HP via Attr math alone; combat damage must explicitly reduce HP via
LoseLife. - MaxMagic = 1 floor prevents division by zero in mana-percent formulas.
- InitialMagic = 0 floor means a unit can never start a battle with negative mana, but can end up with 0 (already does for non-Mages).
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
-
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.
-
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.
-
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
- Patcher:
data-session/tools/patch_stat_logger.py— Mono.Cecil IL injection onRoleHelper.GetRoleAttr. Outputs toMainScripts_patched.dll(padded to original 4,174,848 bytes for YooAsset Middle-level verify). - Replay:
data-session/tools/replay_stats.py— verifies BASE formula against captured logs. - Sister patchers (stackable on same DLL):
patch_battle_logger.py— 201 API request endpoint loggerspatch_battle_logflag.py— force-enable built-inbattleLog.txt+log.json- Captures (in
data-session/intermediate/): stat_breakdown_run4.log— 33 GetRoleAttr calls × 6 stages = 99 lines, 8 unique unitsbattlelog_run4.json— structured BattleLog (54 actions × 6 fighters × 5 phases)battleLog_run4.txt— CN verbose battle narrative
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
- Decompile source:
data-session/intermediate/decompiled_full_mainscripts.cs RoleHelper.GetRoleAttrat line 88725RoleDataItem.GetInitialAttrat line 37444RoleDataItem.GetLevelAttrat line 37466EquipHelper.GetEquipAttrat line 86946 (+equipUpgTimesat 86944)Attrclass at line 83207 (11 int fields)BasicAttrDataItem.GetUpgAttrNameat line 34342- Source data:
data-session/mgg_datamine/{Role,BasicAttr,Equip}DataTable.md - Related mechanics: starup, legacy