From 6e20c7b2d18ec1cea772c5851b05313861d4fab5 Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Fri, 29 May 2026 11:06:35 +0200 Subject: [PATCH 01/12] Change combined hit flag to `combinesHitsWhenDualWielding` In PoE2 `doubleHitsWhenDualWielding` does not actually cause a combined hit from both weapons, but instead causes two separate near simultaneous hits. In order to still be able to reuse the old logic, I changed all checks for `doublenHitsWhenDualWielding` to `combinesHitsWhenDualWielding` instead. Support for the old flag will be in separate commit. --- src/Modules/CalcOffence.lua | 12 ++++++------ src/Modules/CalcTriggers.lua | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index fd2a91bc2e..5922ee2da4 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -2448,7 +2448,7 @@ function calcs.offence(env, actor, activeSkill) elseif mode == "AVERAGE" then output[stat] = ((output.MainHand[stat] or 0) + (output.OffHand[stat] or 0)) / 2 elseif mode == "CRIT" then - if skillFlags.bothWeaponAttack and skillData.doubleHitsWhenDualWielding then + if skillFlags.bothWeaponAttack and skillData.combinesHitsWhenDualWielding then output[stat] = (output.MainHand[stat] or 0) + (output.OffHand[stat] or 0) - ((output.MainHand[stat] or 0) * (output.OffHand[stat] or 0) / 100) else output[stat] = ((output.MainHand[stat] or 0) + (output.OffHand[stat] or 0)) / 2 @@ -2522,7 +2522,7 @@ function calcs.offence(env, actor, activeSkill) end elseif mode == "DPS" then output[stat] = (output.MainHand[stat] or 0) + (output.OffHand[stat] or 0) - if not skillData.doubleHitsWhenDualWielding then + if not skillData.combinesHitsWhenDualWielding then output[stat] = output[stat] / 2 end end @@ -4463,7 +4463,7 @@ function calcs.offence(env, actor, activeSkill) if breakdown then breakdown.AverageDamage = { } t_insert(breakdown.AverageDamage, "Both weapons:") - if skillData.doubleHitsWhenDualWielding then + if skillData.combinesHitsWhenDualWielding then t_insert(breakdown.AverageDamage, s_format("%.1f + %.1f ^8(skill hits with both weapons at once)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) else t_insert(breakdown.AverageDamage, s_format("(%.1f + %.1f) / 2 ^8(skill alternates weapons)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) @@ -4472,7 +4472,7 @@ function calcs.offence(env, actor, activeSkill) if skillFlags.isPvP then breakdown.PvpAverageDamage = { } t_insert(breakdown.PvpAverageDamage, "Both weapons:") - if skillData.doubleHitsWhenDualWielding then + if skillData.combinesHitsWhenDualWielding then t_insert(breakdown.PvpAverageDamage, s_format("%.1f + %.1f ^8(skill hits with both weapons at once)", output.MainHand.PvpAverageDamage, output.OffHand.PvpAverageDamage)) else t_insert(breakdown.PvpAverageDamage, s_format("(%.1f + %.1f) / 2 ^8(skill alternates weapons)", output.MainHand.PvpAverageDamage, output.OffHand.PvpAverageDamage)) @@ -5938,7 +5938,7 @@ function calcs.offence(env, actor, activeSkill) end if skillFlags.impale then local mainHandImpaleDPS, offHandImpaleDPS - if skillFlags.attack and skillData.doubleHitsWhenDualWielding and skillFlags.bothWeaponAttack then + if skillFlags.attack and skillData.combinesHitsWhenDualWielding and skillFlags.bothWeaponAttack then -- separately combine mainHandImpaleDPS = output.MainHand.impaleStoredHitAvg * ((output.MainHand.ImpaleModifier or 1) - 1) * output.MainHand.HitChance / 100 * skillData.dpsMultiplier offHandImpaleDPS = output.OffHand.impaleStoredHitAvg * ((output.OffHand.ImpaleModifier or 1) - 1) * output.OffHand.HitChance / 100 * skillData.dpsMultiplier @@ -5960,7 +5960,7 @@ function calcs.offence(env, actor, activeSkill) output.CombinedDPS = output.CombinedDPS + output.ImpaleDPS if breakdown then breakdown.ImpaleDPS = {} - if skillFlags.attack and skillData.doubleHitsWhenDualWielding and skillFlags.bothWeaponAttack then + if skillFlags.attack and skillData.combinesHitsWhenDualWielding and skillFlags.bothWeaponAttack then t_insert(breakdown.ImpaleDPS, s_format("Main Hand:")) t_insert(breakdown.ImpaleDPS, s_format("%.2f ^8(MH average physical hit before mitigation)", output.MainHand.impaleStoredHitAvg)) t_insert(breakdown.ImpaleDPS, s_format("x %.2f ^8(MH chance to hit)", output.MainHand.HitChance / 100)) diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index d861968b9e..2ee4867f78 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -429,7 +429,7 @@ local function defaultTriggerHandler(env, config) end -- Dual wield triggers - if trigRate and source and env.player.weaponData1.type and env.player.weaponData2.type and not source.skillData.doubleHitsWhenDualWielding and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and actor.mainSkill.triggeredBy and actor.mainSkill.triggeredBy.grantedEffect.support and actor.mainSkill.triggeredBy.grantedEffect.fromItem then + if trigRate and source and env.player.weaponData1.type and env.player.weaponData2.type and not source.skillData.combinesHitsWhenDualWielding and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and actor.mainSkill.triggeredBy and actor.mainSkill.triggeredBy.grantedEffect.support and actor.mainSkill.triggeredBy.grantedEffect.fromItem then trigRate = trigRate / 2 if breakdown then t_insert(breakdown.EffectiveSourceRate, 2, s_format("/ 2 ^8(due to dual wielding)")) @@ -722,7 +722,7 @@ local function defaultTriggerHandler(env, config) local sourceHitChance = GlobalCache.cachedData[env.mode][uuid].HitChance or 0 if sourceHitChance ~= 100 then -- Some skills hit with both weapons at the same time. Each weapon rolls accuracy and crit independently - if source and env.player.weaponData1.type and env.player.weaponData2.type and source.skillData.doubleHitsWhenDualWielding then + if source and env.player.weaponData1.type and env.player.weaponData2.type and source.skillData.combinesHitsWhenDualWielding then local mainHandHit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.MainHand.HitChance local offHandHit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.OffHand.HitChance local bothHit = mainHandHit * offHandHit / 100 @@ -747,7 +747,7 @@ local function defaultTriggerHandler(env, config) local sourceCritChance = GlobalCache.cachedData[env.mode][uuid].CritChance or 0 if sourceCritChance ~= 100 then -- Some skills hit with both weapons at the same time. Each weapon rolls accuracy and crit independently - if source and env.player.weaponData1.type and env.player.weaponData2.type and source.skillData.doubleHitsWhenDualWielding then + if source and env.player.weaponData1.type and env.player.weaponData2.type and source.skillData.combinesHitsWhenDualWielding then local mainHandCrit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.MainHand.CritChance local offHandCrit = GlobalCache.cachedData[env.mode][uuid].Env.player.output.OffHand.CritChance local bothHit = mainHandCrit * offHandCrit / 100 From 8c7b74ca5e0813134b74fba9aad78119a5f93f0c Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Fri, 29 May 2026 11:20:18 +0200 Subject: [PATCH 02/12] Make `combineStat("HARMONICMEAN")` use double hits --- src/Modules/CalcOffence.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 5922ee2da4..ab0a4abe01 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -2458,6 +2458,9 @@ function calcs.offence(env, actor, activeSkill) output[stat] = 0 else output[stat] = 2 / ((1 / output.MainHand[stat]) + (1 / output.OffHand[stat])) + if skillData.doubleHitsWhenDualWielding then + output[stat] = output[stat] * 2 + end end elseif mode == "CHANCE" then if output.MainHand[stat] and output.OffHand[stat] then From d299f283ac0758c9297c2b2770f942aa59d50b1e Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Fri, 29 May 2026 14:45:38 +0200 Subject: [PATCH 03/12] Update the `Speed` breakdown Now correctly reflects behavior of the three possible dual wield hitRate scenarios: 1. One combined hit from both weapons (Harmonic Mean) 2. Two separate simultaneous hits from each weapon (2x Harmonic Mean) 3. Alternating between Mainhand and Offhand (Harmonic Mean) --- src/Modules/CalcOffence.lua | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index ab0a4abe01..972dbb384e 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3040,11 +3040,26 @@ function calcs.offence(env, actor, activeSkill) end elseif skillFlags.bothWeaponAttack then if breakdown then - breakdown.Speed = { - "Both weapons:", - s_format("2 / (1 / %.2f + 1 / %.2f)", output.MainHand.Speed, output.OffHand.Speed), - s_format("= %.2f", output.Speed), - } + if skillData.combinesHitsWhenDualWielding then + breakdown.Speed = { + "Combined hit from both weapons:", + s_format("1 / (1 / %.2f + 1 / %.2f)", output.MainHand.Speed, output.OffHand.Speed), + s_format("= %.2f", output.Speed), + } + elseif skillData.doubleHitsWhenDualWielding then + breakdown.Speed = { + "Simultaneous hits from each weapon:", + s_format("2 / (1 / %.2f + 1 / %.2f)", output.MainHand.Speed, output.OffHand.Speed), + s_format("%.2f * 2 ^8(hits twice per attack)", output.Speed / 2), + s_format("= %.2f", output.Speed), + } + else + breakdown.Speed = { + "Alternating both weapons:", + s_format("2 / (1 / %.2f + 1 / %.2f)", output.MainHand.Speed, output.OffHand.Speed), + s_format("= %.2f", output.Speed), + } + end end end if skillData.channelTimeMultiplier then From 9e477901ada9426472369e7f73cad6d3d653f2fe Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Fri, 29 May 2026 17:40:49 +0200 Subject: [PATCH 04/12] Use "DPS" mod instead of directly changing `Speed` This is still not ideal imo, but it's mostly due to the fact that we don't have a good breakdown for "DPS" mods --- src/Modules/CalcOffence.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 972dbb384e..33ceec1c84 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -2458,9 +2458,6 @@ function calcs.offence(env, actor, activeSkill) output[stat] = 0 else output[stat] = 2 / ((1 / output.MainHand[stat]) + (1 / output.OffHand[stat])) - if skillData.doubleHitsWhenDualWielding then - output[stat] = output[stat] * 2 - end end elseif mode == "CHANCE" then if output.MainHand[stat] and output.OffHand[stat] then @@ -3050,7 +3047,7 @@ function calcs.offence(env, actor, activeSkill) breakdown.Speed = { "Simultaneous hits from each weapon:", s_format("2 / (1 / %.2f + 1 / %.2f)", output.MainHand.Speed, output.OffHand.Speed), - s_format("%.2f * 2 ^8(hits twice per attack)", output.Speed / 2), + s_format("%.2f * 2 ^8(hits twice per attack)", output.Speed), s_format("= %.2f", output.Speed), } else @@ -3745,6 +3742,12 @@ function calcs.offence(env, actor, activeSkill) output.DoubleDamageEffect = output.DoubleDamageChance / 100 output.ScaledDamageEffect = output.ScaledDamageEffect * (1 + output.DoubleDamageEffect + output.TripleDamageEffect) + -- Dual wield DPS multiplier + -- NOTE: This solution is a bit "hacky", but ensures that the hit rate multiplier for dual wielding isn't applied multiple times + if skillFlags.bothWeaponAttack and skillData.doubleHitsWhenDualWielding and pass.label == "Off Hand" then + skillModList:NewMod("DPS", "MORE", 100, "Hits with both weapons") + end + skillData.dpsMultiplier = ( skillData.dpsMultiplier or 1 ) * calcLib.mod(skillModList, skillCfg, "DPS") local hitRate = output.HitChance / 100 * (globalOutput.HitSpeed or globalOutput.Speed) * skillData.dpsMultiplier @@ -4483,6 +4486,8 @@ function calcs.offence(env, actor, activeSkill) t_insert(breakdown.AverageDamage, "Both weapons:") if skillData.combinesHitsWhenDualWielding then t_insert(breakdown.AverageDamage, s_format("%.1f + %.1f ^8(skill hits with both weapons at once)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) + elseif skillData.doubleHitsWhenDualWielding then + t_insert(breakdown.AverageDamage, s_format("%.1f + %.1f ^8(skill hits once with each weapon)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) else t_insert(breakdown.AverageDamage, s_format("(%.1f + %.1f) / 2 ^8(skill alternates weapons)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) end From 70416e51585c02e1f92fd217ad3e04f5fba0bf83 Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:02:39 +0200 Subject: [PATCH 05/12] Add nil check for `gemName` in CalcSetup Found this bug when expanding `PasteSocketGroup()`, which probably wasn't a problem until now because it could only occur with `testInput` --- src/Modules/CalcSetup.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 1568e93b78..f7dc2fb3d6 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -1894,7 +1894,7 @@ function calcs.initEnv(build, mode, override, specEnv) if grantedEffect.name:match("^Companion:") or grantedEffect.name:match("^Spectre:") then group.displayLabel = (group.displayLabel and group.displayLabel..", " or "") .. gemInstance.nameSpec else - group.displayLabel = (group.displayLabel and group.displayLabel..", " or "") .. gemName or grantedEffect.name + group.displayLabel = gemName and ((group.displayLabel and group.displayLabel..", " or "") .. gemName) or grantedEffect.name end end end From 308b1ef0dad3da886bebc1bc5bfeb57ae234b145 Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:04:53 +0200 Subject: [PATCH 06/12] Add option specify `skillId` to `PasteSocketGroup` Also added emmylua annotations for easier understanding of the `testInput` structure --- src/Classes/SkillsTab.lua | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Classes/SkillsTab.lua b/src/Classes/SkillsTab.lua index 833b60116b..3a813ace16 100644 --- a/src/Classes/SkillsTab.lua +++ b/src/Classes/SkillsTab.lua @@ -629,6 +629,21 @@ function SkillsTabClass:CopySocketGroup(socketGroup) Copy(skillText) end +--- Parses pasted socketGroup or custom test string to generate new socketGroup +--- @param testInput string? optional string input mainly used for tests +--- +--- **Expected `testInput` Format:** +--- ```text +--- [Label: ] (Optional) +--- [Slot: ] (Optional) +--- [skillId:] / [STATE] [C] [+/-CorruptLevel] +--- ``` +--- `skillId` is only needed if skill name is not unique +--- `STATE` only used to disable gem via `DISABLED` +--- +--- **Example lines:** +--- * `"skillId:LightningSpearPlayer Lightning Spear 20/7 2 C +1"` (Level 20, 7 Quality Lightning Spear, with +1 Level Corruption and Count set to 2 ) +--- * `"RhoaMountPlayer Rhoa Mount 10/0 DISABLED 1"` (Level 10, 0 quality Rhoa Mount with count 1 that has been disabled) function SkillsTabClass:PasteSocketGroup(testInput) local skillText = sanitiseText(Paste() or testInput) if skillText then @@ -642,8 +657,21 @@ function SkillsTabClass:PasteSocketGroup(testInput) newGroup.slot = slot end for line in skillText:gmatch("([^\r\n]+)") do + local currentLine = line -- reassignment to local var to avoid modifying iter var + + -- Check if specific skillId was provided via `testInput` + local skillId = currentLine:match("^skillId:(%w+) ") + if skillId then + currentLine = currentLine:gsub("^skillId:%w+ ", "") + end + local nameSpec, level, quality, state, count, cFlag, cLevel = - line:match("^([ %a':]+) (%d+)/(%d+)%s*(%u*)%s+([%d%.]+)%s*(C?)([+%-]?%d*)%s*$") + currentLine:match("^([ %a':]+) (%d+)/(%d+)%s*(%u*)%s+([%d%.]+)%s*(C?)([+%-]?%d*)%s*$") + + -- Ignore invalid or mismatched skillId + if skillId and not (self.build.data.skills[skillId] and self.build.data.skills[skillId].baseTypeName == nameSpec) then + skillId = nil + end if nameSpec then local skillMinion = nil local skillMinionCalcs = nil @@ -685,7 +713,8 @@ function SkillsTabClass:PasteSocketGroup(testInput) enableGlobal1 = true, enableGlobal2 = true, skillMinion = skillMinion, - skillMinionCalcs = skillMinionCalcs + skillMinionCalcs = skillMinionCalcs, + skillId = skillId }) end end From 80a0f13d62154cf034c8ec9143b01101788bf768 Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:22:05 +0200 Subject: [PATCH 07/12] Add `dpsMultiplier` to `output` to avoid doubling Since `skillData.dpsMultiplier` was multiplied with itself in each pass, any modifiers would be applied twice for dual wield skills. Instead it is now added to `output.DpsMultiplier` and combined with `combineStat()` --- src/Modules/CalcOffence.lua | 54 +++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 566deca8dd..f678cc3511 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -1123,6 +1123,11 @@ function calcs.offence(env, actor, activeSkill) modDB:NewMod("DPS", "MORE", detonateTwice, "Grenade Activate Twice") end + -- Dual wield DPS multiplier + if skillFlags.bothWeaponAttack and skillData.doubleHitsWhenDualWielding then + skillModList:NewMod("DPS", "MORE", 100, "Hits with both weapons") + end + if skillModList:Flag(nil, "HasSeals") and not skillModList:Flag(nil, "NoRepeatBonuses") then -- Applies seal bonuses based on seal count local totalCastSpeed = 1 / activeSkill.activeEffect.grantedEffect.castTime * calcLib.mod(skillModList, skillCfg, "Speed") @@ -3874,15 +3879,11 @@ function calcs.offence(env, actor, activeSkill) output.DoubleDamageEffect = output.DoubleDamageChance / 100 output.ScaledDamageEffect = output.ScaledDamageEffect * (1 + output.DoubleDamageEffect + output.TripleDamageEffect) - -- Dual wield DPS multiplier - -- NOTE: This solution is a bit "hacky", but ensures that the hit rate multiplier for dual wielding isn't applied multiple times - if skillFlags.bothWeaponAttack and skillData.doubleHitsWhenDualWielding and pass.label == "Off Hand" then - skillModList:NewMod("DPS", "MORE", 100, "Hits with both weapons") - end - skillData.dpsMultiplier = ( skillData.dpsMultiplier or 1 ) * calcLib.mod(skillModList, skillCfg, "DPS") - local hitRate = output.HitChance / 100 * (globalOutput.HitSpeed or globalOutput.Speed) * skillData.dpsMultiplier + output.DpsMultiplier = ( skillData.dpsMultiplier or 1 ) * calcLib.mod(skillModList, skillCfg, "DPS") + + local hitRate = output.HitChance / 100 * (globalOutput.HitSpeed or globalOutput.Speed) * output.DpsMultiplier local enemyRarity = (enemyDB:Flag(nil, "Condition:Unique") and "Unique" or (enemyDB:Flag(nil, "Condition:RareOrUnique") and "Rare" or "Normal")) -- Calculate culling DPS @@ -4424,7 +4425,7 @@ function calcs.offence(env, actor, activeSkill) local repeatPenalty = skillModList:Flag(nil, "HasSeals") and skillModList:Flag(nil, "DamageSeal") and not skillModList:Flag(nil, "NoRepeatBonuses") and calcLib.mod(skillModList, skillCfg, "SealRepeatPenalty") or 1 globalOutput.AverageBurstDamage = output.AverageDamage + output.AverageDamage * (globalOutput.AverageBurstHits - 1) * repeatPenalty or 0 globalOutput.ShowBurst = globalOutput.AverageBurstHits > 1 - output.TotalDPS = output.AverageDamage * (globalOutput.HitSpeed or globalOutput.Speed) * skillData.dpsMultiplier * quantityMultiplier + output.TotalDPS = output.AverageDamage * (globalOutput.HitSpeed or globalOutput.Speed) * output.DpsMultiplier * quantityMultiplier if breakdown then if output.CritEffect ~= 1 then breakdown.AverageHit = { } @@ -4516,7 +4517,7 @@ function calcs.offence(env, actor, activeSkill) local portionElemental = (output.AverageHit / PvpTvalue / PvpElemental2 ) ^ PvpElemental1 * PvpTvalue * PvpElemental2 * percentageElemental output.PvpAverageHit = (portionNonElemental + portionElemental) * PvpMultiplier output.PvpAverageDamage = output.PvpAverageHit * output.HitChance / 100 - output.PvpTotalDPS = output.PvpAverageDamage * (globalOutput.HitSpeed or globalOutput.Speed) * skillData.dpsMultiplier + output.PvpTotalDPS = output.PvpAverageDamage * (globalOutput.HitSpeed or globalOutput.Speed) * output.DpsMultiplier -- fix for these being nan if output.PvpAverageHit ~= output.PvpAverageHit then @@ -4578,6 +4579,7 @@ function calcs.offence(env, actor, activeSkill) combineStat("CritBifurcates", "AVERAGE") combineStat("AverageDamage", "DPS") combineStat("PvpAverageDamage", "DPS") + combineStat("DpsMultiplier", "DPS") combineStat("TotalDPS", "DPS") combineStat("PvpTotalDPS", "DPS") combineStat("LifeLeechDuration", "DPS") @@ -4692,8 +4694,8 @@ function calcs.offence(env, actor, activeSkill) output.HitSpeed and s_format("x %.2f ^8(hit rate)", output.HitSpeed) or s_format("x %.2f ^8(cast rate)", output.Speed), } end - if skillData.dpsMultiplier ~= 1 then - t_insert(breakdown.TotalDPS, s_format("x %g ^8(DPS multiplier for this skill)", skillData.dpsMultiplier)) + if output.DpsMultiplier ~= 1 then + t_insert(breakdown.TotalDPS, s_format("x %g ^8(DPS multiplier for this skill)", output.DpsMultiplier)) end if quantityMultiplier > 1 then t_insert(breakdown.TotalDPS, s_format("x %g ^8(quantity multiplier for this skill)", quantityMultiplier)) @@ -4710,8 +4712,8 @@ function calcs.offence(env, actor, activeSkill) s_format("%.1f ^8(average pvp hit)", output.PvpAverageDamage), output.HitSpeed and s_format("x %.2f ^8(hit rate)", output.HitSpeed) or s_format("x %.2f ^8(%s rate)", output.Speed, rateType), } - if skillData.dpsMultiplier ~= 1 then - t_insert(breakdown.PvpTotalDPS, s_format("x %g ^8(DPS multiplier for this skill)", skillData.dpsMultiplier)) + if output.DpsMultiplier ~= 1 then + t_insert(breakdown.PvpTotalDPS, s_format("x %g ^8(DPS multiplier for this skill)", output.DpsMultiplier)) end if quantityMultiplier > 1 then t_insert(breakdown.PvpTotalDPS, s_format("x %g ^8(quantity multiplier for this skill)", quantityMultiplier)) @@ -4721,7 +4723,7 @@ function calcs.offence(env, actor, activeSkill) end if skillFlags.minion then - skillData.summonSpeed = output.SummonedMinionsPerCast * (output.HitSpeed or output.Speed) * skillData.dpsMultiplier + skillData.summonSpeed = output.SummonedMinionsPerCast * (output.HitSpeed or output.Speed) * output.DpsMultiplier end -- Calculate leech rates @@ -4767,7 +4769,7 @@ function calcs.offence(env, actor, activeSkill) output.ManaLeechGainRate = output.ManaLeechRate + output.ManaOnHitRate end if breakdown then - local hitRate = output.HitChance / 100 * (globalOutput.HitSpeed or globalOutput.Speed) * skillData.dpsMultiplier + local hitRate = output.HitChance / 100 * (globalOutput.HitSpeed or globalOutput.Speed) * output.DpsMultiplier if skillFlags.leechLife then breakdown.LifeLeech = breakdown.leech(output.LifeLeechInstant, output.LifeLeechInstantRate, output.LifeLeechInstances, output.Life, "LifeLeechRate", output.MaxLifeLeechRate, output.LifeLeechDuration, output.LifeLeechInstantProportion, hitRate) end @@ -5065,7 +5067,7 @@ function calcs.offence(env, actor, activeSkill) local ailmentChance = output[ailment .. "ChanceOnHit"] / 100 * (1 - output.CritChance / 100) + output[ailment .. "ChanceOnCrit"] / 100 * output.CritChance / 100 -- The average number of ailment that will be active on the enemy at once - local ailmentStacks = output.HitChance / 100 * ailmentChance * skillData.dpsMultiplier + local ailmentStacks = output.HitChance / 100 * ailmentChance * output.DpsMultiplier local configStacks = enemyDB:Sum("BASE", nil, "Multiplier:" .. ailment .. "Stacks") if not skillData.triggeredOnDeath then if output.Cooldown then @@ -5107,8 +5109,8 @@ function calcs.offence(env, actor, activeSkill) elseif (globalOutput.HitSpeed or globalOutput.Speed) > 0 then t_insert(globalBreakdown[ailment .. "StackPotential"], s_format("* (%.2f / %.2f) ^8(Duration / Attack Time)", globalOutput[ailment .. "Duration"], (globalOutput.HitTime or output.Time))) end - if skillData.dpsMultiplier ~= 1 then - t_insert(globalBreakdown[ailment .. "StackPotential"], s_format("* %g ^8(DPS multiplier for this skill)", skillData.dpsMultiplier)) + if output.DpsMultiplier ~= 1 then + t_insert(globalBreakdown[ailment .. "StackPotential"], s_format("* %g ^8(DPS multiplier for this skill)", output.DpsMultiplier)) end end t_insert(globalBreakdown[ailment .. "StackPotential"], s_format("/ %d ^8(max number of stacks)", maxStacks)) @@ -5950,7 +5952,7 @@ function calcs.offence(env, actor, activeSkill) elseif band(dotCfg.keywordFlags, KeywordFlag.Trap) ~= 0 then speed = output.TrapThrowingSpeed end - output.TotalDot = m_min(output.TotalDotInstance * speed * output.Duration * skillData.dpsMultiplier * quantityMultiplier, data.misc.DotDpsCap) + output.TotalDot = m_min(output.TotalDotInstance * speed * output.Duration * output.DpsMultiplier * quantityMultiplier, data.misc.DotDpsCap) output.TotalDotCalcSection = output.TotalDot if breakdown then breakdown.TotalDot = { @@ -5958,8 +5960,8 @@ function calcs.offence(env, actor, activeSkill) s_format("x %.2f ^8(hits per second)", speed), s_format("x %.2f ^8(skill duration)", output.Duration), } - if skillData.dpsMultiplier ~= 1 then - t_insert(breakdown.TotalDot, s_format("x %g ^8(DPS multiplier for this skill)", skillData.dpsMultiplier)) + if output.DpsMultiplier ~= 1 then + t_insert(breakdown.TotalDot, s_format("x %g ^8(DPS multiplier for this skill)", output.DpsMultiplier)) end if quantityMultiplier > 1 then t_insert(breakdown.TotalDot, s_format("x %g ^8(quantity multiplier for this skill)", quantityMultiplier)) @@ -6145,11 +6147,11 @@ function calcs.offence(env, actor, activeSkill) local mainHandImpaleDPS, offHandImpaleDPS if skillFlags.attack and skillData.combinesHitsWhenDualWielding and skillFlags.bothWeaponAttack then -- separately combine - mainHandImpaleDPS = output.MainHand.impaleStoredHitAvg * ((output.MainHand.ImpaleModifier or 1) - 1) * output.MainHand.HitChance / 100 * skillData.dpsMultiplier - offHandImpaleDPS = output.OffHand.impaleStoredHitAvg * ((output.OffHand.ImpaleModifier or 1) - 1) * output.OffHand.HitChance / 100 * skillData.dpsMultiplier + mainHandImpaleDPS = output.MainHand.impaleStoredHitAvg * ((output.MainHand.ImpaleModifier or 1) - 1) * output.MainHand.HitChance / 100 * output.DpsMultiplier + offHandImpaleDPS = output.OffHand.impaleStoredHitAvg * ((output.OffHand.ImpaleModifier or 1) - 1) * output.OffHand.HitChance / 100 * output.DpsMultiplier output.ImpaleDPS = mainHandImpaleDPS + offHandImpaleDPS else - output.ImpaleDPS = output.PhysicalStoredCombinedAvg * ((output.ImpaleModifier or 1) - 1) * output.HitChance / 100 * skillData.dpsMultiplier + output.ImpaleDPS = output.PhysicalStoredCombinedAvg * ((output.ImpaleModifier or 1) - 1) * output.HitChance / 100 * output.DpsMultiplier end if skillData.showAverage then output.WithImpaleDPS = output.AverageDamage + output.ImpaleDPS @@ -6186,8 +6188,8 @@ function calcs.offence(env, actor, activeSkill) if skillFlags.notAverage then t_insert(breakdown.ImpaleDPS, output.HitSpeed and s_format("x %.2f ^8(hit rate)", output.HitSpeed) or s_format("x %.2f ^8(%s rate)", output.Speed, skillFlags.attack and "attack" or "cast")) end - if skillData.dpsMultiplier ~= 1 then - t_insert(breakdown.ImpaleDPS, s_format("x %g ^8(dps multiplier for this skill)", skillData.dpsMultiplier)) + if output.DpsMultiplier ~= 1 then + t_insert(breakdown.ImpaleDPS, s_format("x %g ^8(dps multiplier for this skill)", output.DpsMultiplier)) end if quantityMultiplier > 1 then t_insert(breakdown.ImpaleDPS, s_format("x %g ^8(quantity multiplier for this skill)", quantityMultiplier)) From 64b8123b0ae999e1924f4c9366891c5ca467639a Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:55:44 +0200 Subject: [PATCH 08/12] Add stat for future combined hit dual wield skills --- src/Data/SkillStatMap.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Data/SkillStatMap.lua b/src/Data/SkillStatMap.lua index f7260a6a82..caa0fe5b5b 100644 --- a/src/Data/SkillStatMap.lua +++ b/src/Data/SkillStatMap.lua @@ -278,6 +278,9 @@ return { ["skill_double_hits_when_dual_wielding"] = { skill("doubleHitsWhenDualWielding", true), }, +["skill_combines_hits_when_dual_wielding"] = { -- NOTE: This is before PoE2 has "combined hit" dual wield skills, so stat will have to be updated in the future + skill("combinesHitsWhenDualWielding", true), +}, ["support_spell_echo_number_of_echo_cascades"] = { mod("RepeatCount", "BASE", nil, 0, 0, {type = "SkillType", skillType = SkillType.Cascadable }), }, From bd4879f2f3e533778706371102f03b0f726410ed Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:56:20 +0200 Subject: [PATCH 09/12] Add tests for dual wield stats --- spec/System/TestAttacks_spec.lua | 162 +++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index c79e00738e..e8bf9f0a40 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -282,4 +282,166 @@ describe("TestAttacks", function() assert.is_true(math.abs(bifurcateChance - build.calcsTab.mainOutput.MainHand.CritBifurcates) < 0.000001) assert.are.equals(1 + critBonusMultiplier, build.calcsTab.mainOutput.MainHand.AverageHit) end) + + -- Dual Wield tests + local setupDualWieldTestConditions = function() + local slowHighDmgMace = [[ + Slow High Crit High Damage Mace + Marauding Mace + Quality: 0 + 200% increased physical damage + 100% increased critical hit chance + -25% increased attack speed + ]] + + local fastLowDmgMace = [[ + Fast Low Crit Low Damage Mace + Marauding Mace + Quality: 0 + -50% increased physical damage + -100% increased critical hit chance + 50% increased attack speed + ]] + + build.itemsTab:CreateDisplayItemFromRaw(slowHighDmgMace) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.itemsTab.slots["Weapon 1"]:SetSelItemId(build.itemsTab.items[1].id) + + build.itemsTab:CreateDisplayItemFromRaw(fastLowDmgMace) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + build.itemsTab.slots["Weapon 2"]:SetSelItemId(build.itemsTab.items[2].id) + + build.configTab.input.customMods = [[ + nearby enemies have 100% less armour + hits can't be evaded + ]] + build.configTab:BuildModList() + runCallback("OnFrame") + end + + local function harmonicMean(a, b) + return 2 / (1/a + 1/b) + end + + it("correctly calculates dual wield DPS for double hits", function() + setupDualWieldTestConditions() + build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1") + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- Attack Speed + local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed + local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed + local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed) + assert.are.equals(combinedSpeed, build.calcsTab.mainOutput.Speed) + + -- Average Hit + local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage + local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage + local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage + assert.are.equals((mainHandAvgDmg + offHandAvgDmg) / 2, combinedAvgDmg) + + -- DPS (hits twice per attack) + local combinedDPS = build.calcsTab.mainOutput.TotalDPS + assert.are.equals(combinedAvgDmg * combinedSpeed * 2, combinedDPS) + end) + + it("correctly calculates dual wield crit chance for double hits", function() + setupDualWieldTestConditions() + build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayer Mace Strike 20/0 1") + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- Double hits roll crit individually per weapon, so should be average + local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance + local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance + local combinedCritChance = (mainHandCritChance + offHandCritChance) / 2 + assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance) + end) + + it("correctly calculates dual wield DPS for alternating hits", function() + setupDualWieldTestConditions() + build.skillsTab:PasteSocketGroup("Armour Breaker 20/0 1") + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- Attack Speed + local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed + local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed + local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed) + assert.are.equals(combinedSpeed, build.calcsTab.mainOutput.Speed) + + -- Average Hit + local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage + local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage + local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage + assert.are.equals((mainHandAvgDmg + offHandAvgDmg) / 2, combinedAvgDmg) + + -- DPS (hits once per attack) + local combinedDPS = build.calcsTab.mainOutput.TotalDPS + assert.are.equals(combinedAvgDmg * combinedSpeed, combinedDPS) + end) + + it("correctly calculates dual wield crit chance for alternating hits", function() + setupDualWieldTestConditions() + build.skillsTab:PasteSocketGroup("Armour Breaker 20/0 1") + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- Alternating hits roll crit individually per weapon, so should be average + local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance + local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance + local combinedCritChance = (mainHandCritChance + offHandCritChance) / 2 + assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance) + end) + + --[[ + NOTE: the following section contains tests for "combined hits", which PoE2 doesn't have as of 2026-06-02, + which means the tests were written for a temporary test skill that will not be committed. + The test can be updated by simply replacing `"skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1"` + with actual skill data once available + ]] + --[[ it("correctly calculates dual wield DPS for combined hits", function() + setupDualWieldTestConditions() + build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1") + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- Attack Speed + local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed + local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed + local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed) + assert.are.equals(combinedSpeed, build.calcsTab.mainOutput.Speed) + + -- Average Hit + local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage + local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage + local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage + assert.are.equals((mainHandAvgDmg + offHandAvgDmg), combinedAvgDmg) + + -- DPS (hits twice per attack) + local combinedDPS = build.calcsTab.mainOutput.TotalDPS + assert.are.equals(combinedAvgDmg * combinedSpeed, combinedDPS) + end) + + it("correctly calculates dual wield crit chance for combined hits", function() + setupDualWieldTestConditions() + build.skillsTab:PasteSocketGroup("skillId:MeleeMaceMacePlayerCombinedTEST Mace Strike TEST 20/0 1") + runCallback("OnFrame") + build.calcsTab:BuildOutput() + runCallback("OnFrame") + + -- combined hits count whole attack as crit, as long as one hand rolls crit) + local mainHandCritChance = build.calcsTab.mainOutput.MainHand.CritChance + local offHandCritChance = build.calcsTab.mainOutput.OffHand.CritChance + local combinedCritChance = mainHandCritChance + offHandCritChance - (mainHandCritChance * offHandCritChance / 100) + assert.are.equals(combinedCritChance, build.calcsTab.mainOutput.CritChance) + end) ]] end) From 3b53b6f92d11ec923758f348f05a05680bbc0980 Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:09:56 +0200 Subject: [PATCH 10/12] small fix for test conditions --- spec/System/TestAttacks_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index e8bf9f0a40..269623bc02 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -315,7 +315,7 @@ describe("TestAttacks", function() build.configTab.input.customMods = [[ nearby enemies have 100% less armour - hits can't be evaded + your hits can't be evaded ]] build.configTab:BuildModList() runCallback("OnFrame") From fe510171b5f062620229ec6d5262db72af74481c Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:37:40 +0200 Subject: [PATCH 11/12] Add rounding to tests Previously got a failed test because DPS value were not equal after 10 decimals ... --- spec/System/TestAttacks_spec.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/System/TestAttacks_spec.lua b/spec/System/TestAttacks_spec.lua index 269623bc02..63f88687aa 100644 --- a/spec/System/TestAttacks_spec.lua +++ b/spec/System/TestAttacks_spec.lua @@ -336,17 +336,17 @@ describe("TestAttacks", function() local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed) - assert.are.equals(combinedSpeed, build.calcsTab.mainOutput.Speed) + assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4)) -- Average Hit local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage - assert.are.equals((mainHandAvgDmg + offHandAvgDmg) / 2, combinedAvgDmg) + assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg) / 2, 4), round(combinedAvgDmg, 4)) -- DPS (hits twice per attack) local combinedDPS = build.calcsTab.mainOutput.TotalDPS - assert.are.equals(combinedAvgDmg * combinedSpeed * 2, combinedDPS) + assert.are.equals(round(combinedAvgDmg * combinedSpeed * 2,4), round(combinedDPS,4)) end) it("correctly calculates dual wield crit chance for double hits", function() @@ -374,17 +374,17 @@ describe("TestAttacks", function() local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed) - assert.are.equals(combinedSpeed, build.calcsTab.mainOutput.Speed) + assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4)) -- Average Hit local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage - assert.are.equals((mainHandAvgDmg + offHandAvgDmg) / 2, combinedAvgDmg) + assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg) / 2, 4), round(combinedAvgDmg, 4)) -- DPS (hits once per attack) local combinedDPS = build.calcsTab.mainOutput.TotalDPS - assert.are.equals(combinedAvgDmg * combinedSpeed, combinedDPS) + assert.are.equals(round(combinedAvgDmg * combinedSpeed, 4), round(combinedDPS, 4)) end) it("correctly calculates dual wield crit chance for alternating hits", function() @@ -418,17 +418,17 @@ describe("TestAttacks", function() local mainHandSpeed = build.calcsTab.mainOutput.MainHand.Speed local offHandSpeed = build.calcsTab.mainOutput.OffHand.Speed local combinedSpeed = harmonicMean(mainHandSpeed, offHandSpeed) - assert.are.equals(combinedSpeed, build.calcsTab.mainOutput.Speed) + assert.are.equals(round(combinedSpeed, 4), round(build.calcsTab.mainOutput.Speed, 4)) -- Average Hit local mainHandAvgDmg = build.calcsTab.mainOutput.MainHand.AverageDamage local offHandAvgDmg = build.calcsTab.mainOutput.OffHand.AverageDamage local combinedAvgDmg = build.calcsTab.mainOutput.AverageDamage - assert.are.equals((mainHandAvgDmg + offHandAvgDmg), combinedAvgDmg) + assert.are.equals(round((mainHandAvgDmg + offHandAvgDmg), 4), round(combinedAvgDmg, 4)) -- DPS (hits twice per attack) local combinedDPS = build.calcsTab.mainOutput.TotalDPS - assert.are.equals(combinedAvgDmg * combinedSpeed, combinedDPS) + assert.are.equals(round(combinedAvgDmg * combinedSpeed, 4), round(combinedDPS,4)) end) it("correctly calculates dual wield crit chance for combined hits", function() From 55818dc69fbba296a32ae95ccbaa9954e8ba1b93 Mon Sep 17 00:00:00 2001 From: majochem <77203255+majochem@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:40:34 +0200 Subject: [PATCH 12/12] Fix breakdown for average hit & attack speed --- src/Modules/CalcOffence.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index f678cc3511..20250af901 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -3058,7 +3058,7 @@ function calcs.offence(env, actor, activeSkill) breakdown.Speed = { "Simultaneous hits from each weapon:", s_format("2 / (1 / %.2f + 1 / %.2f)", output.MainHand.Speed, output.OffHand.Speed), - s_format("%.2f * 2 ^8(hits twice per attack)", output.Speed), + s_format("%.2f ^8(hits twice per attack)", output.Speed), s_format("= %.2f", output.Speed), } else @@ -4652,7 +4652,7 @@ function calcs.offence(env, actor, activeSkill) if skillData.combinesHitsWhenDualWielding then t_insert(breakdown.AverageDamage, s_format("%.1f + %.1f ^8(skill hits with both weapons at once)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) elseif skillData.doubleHitsWhenDualWielding then - t_insert(breakdown.AverageDamage, s_format("%.1f + %.1f ^8(skill hits once with each weapon)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) + t_insert(breakdown.AverageDamage, s_format("(%.1f + %.1f) / 2 ^8(skill hits once with each weapon)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) else t_insert(breakdown.AverageDamage, s_format("(%.1f + %.1f) / 2 ^8(skill alternates weapons)", output.MainHand.AverageDamage, output.OffHand.AverageDamage)) end