From 3f3e1fd4c5526d8753b364691e3b0c372c620f49 Mon Sep 17 00:00:00 2001 From: Kimmo Parviainen-Jalanko Date: Sun, 4 Oct 2026 14:18:06 +0300 Subject: [PATCH 1/3] Skip unneeded calc passes in node power Node power only reads the selected power stat, but every node calc ran the full multi-skill Full DPS pass in the tree view (the TREE override in the misc calculator), plus the EHP estimation and, for Full DPS, a main-skill pass nobody reads. Node power now goes through the fast path and asks only for what its stat needs; stats set by the EHP pass are flagged needsEHP in data.powerStatList. The per-skill Full DPS cache only diffs skill-level inputs, so it is now used only while the tree and items are unchanged (it would report 0 power for every node otherwise). Each fast-path options table gets its own reusable env, so the gem sorter and node power cannot accelerate on each other's overrides. On a 3-group Full DPS Deadeye in tree view: Offence/Defence 30 s -> 5 s, Life 91 s -> 18 s, Full DPS 89 s -> 64 s, with identical power for all 3732 nodes. --- spec/System/TestNodePower_spec.lua | 90 ++++++++++++++++++++++++++++++ src/Classes/CalcsTab.lua | 12 ++-- src/Modules/Calcs.lua | 15 +++-- src/Modules/Data.lua | 15 ++--- 4 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 spec/System/TestNodePower_spec.lua diff --git a/spec/System/TestNodePower_spec.lua b/spec/System/TestNodePower_spec.lua new file mode 100644 index 0000000000..64f1cbb318 --- /dev/null +++ b/spec/System/TestNodePower_spec.lua @@ -0,0 +1,90 @@ +describe("TestNodePower", function() + before_each(function() + newBuild() + end) + + -- Start as the given class with Spark in Full DPS, so nodes near the class start matter + local function setUp(className) + build.spec:SelectClass(build.spec.tree.classNameMap[className]) + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + runCallback("OnFrame") + local group = build.skillsTab.socketGroupList[1] + group.mainActiveSkill = 1 + group.includeInFullDPS = true + build.mainSocketGroup = 1 + -- The tree view forces Full DPS in the slow path, which node power must not depend on + build.viewMode = "TREE" + build.buildFlag = true + runCallback("OnFrame") + end + + local function findStat(stat) + for _, powerStat in ipairs(data.powerStatList) do + if powerStat.stat == stat then + return powerStat + end + end + end + + -- Run the node power builder to completion for the nodes closest to the allocated tree + local function buildPower(powerStat) + local calcsTab = build.calcsTab + calcsTab.powerStat = powerStat + calcsTab.nodePowerMaxDepth = 5 + calcsTab.powerBuildFlag = true + repeat + calcsTab:BuildPower() + until not calcsTab.powerBuilder + end + + -- Node power must equal the stat difference from a full, unaccelerated calculation + local function assertMatchesSlowPath(powerStat) + buildPower(powerStat) + local calcsTab = build.calcsTab + local calcFunc, calcBase = calcsTab:GetMiscCalculator() + local checked, nonZero = 0, 0 + for _, node in pairs(build.spec.nodes) do + if not node.alloc and node.power.singleStat and node.power.distance <= 5 then + local expected = calcsTab:CalculatePowerStat(powerStat, calcFunc({ addNodes = { [node] = true } }), calcBase) + local scale = math.max(math.abs(expected), 1) + assert.is_true(math.abs(expected - node.power.singleStat) <= scale * 1e-9, + powerStat.stat .. " power of " .. node.dn .. ": expected " .. expected .. ", got " .. node.power.singleStat) + checked = checked + 1 + if expected ~= 0 then + nonZero = nonZero + 1 + end + end + end + assert.is_true(checked > 0, "no nodes checked") + return nonZero + end + + it("Full DPS power reflects the node, not cached skill results", function() + setUp("Sorceress") + assert.is_true(assertMatchesSlowPath(findStat("FullDPS")) > 0, "expected some node to change Full DPS") + end) + + it("Life power matches the full calculation", function() + setUp("Warrior") + assert.is_true(assertMatchesSlowPath(findStat("Life")) > 0, "expected some node to change Life") + end) + + it("EHP power matches the full calculation", function() + setUp("Warrior") + assert.is_true(assertMatchesSlowPath(findStat("TotalEHP")) > 0, "expected some node to change EHP") + end) + + it("does not calculate Full DPS for other stats", function() + setUp("Warrior") + local calcs = build.calcsTab.calcs + local realCalcFullDPS = calcs.calcFullDPS + local fullDPSCalls = 0 + calcs.calcFullDPS = function(...) + fullDPSCalls = fullDPSCalls + 1 + return realCalcFullDPS(...) + end + buildPower(findStat("Life")) + calcs.calcFullDPS = realCalcFullDPS + assert.are.equal(0, fullDPSCalls) + end) +end) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 2c39f156cb..64e5ac9531 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -551,6 +551,8 @@ end function CalcsTabClass:PowerBuilder() -- local timer_start = GetTime() local useFullDPS = self.powerStat and self.powerStat.stat == "FullDPS" + -- Only the selected power stat is read from each output, so skip the passes it does not need + local fastCalcOptions = { fullDPSOnly = useFullDPS, skipEHP = not (self.powerStat and self.powerStat.needsEHP) } local calcFunc, calcBase = self:GetMiscCalculator() local cache = { } local distanceMap = { } @@ -622,7 +624,7 @@ function CalcsTabClass:PowerBuilder() for nodeId, node in pairs(nodes) do if not node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[nodeId] then if not cache[node.modKey] then - cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS) + cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS, fastCalcOptions) end local output = cache[node.modKey] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then @@ -635,7 +637,7 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if distance > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ addNodes = pathNodes }, useFullDPS), calcBase) + node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ addNodes = pathNodes }, useFullDPS, fastCalcOptions), calcBase) end end elseif not self.powerStat or not self.powerStat.ignoreForNodes then @@ -650,7 +652,7 @@ function CalcsTabClass:PowerBuilder() end elseif node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[nodeId] then if not cache[node.modKey.."_remove"] then - cache[node.modKey.."_remove"] = calcFunc({ removeNodes = { [node] = true } }, useFullDPS) + cache[node.modKey.."_remove"] = calcFunc({ removeNodes = { [node] = true } }, useFullDPS, fastCalcOptions) end local output = cache[node.modKey.."_remove"] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then @@ -662,7 +664,7 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if #node.depends > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ removeNodes = pathNodes }, useFullDPS), calcBase) + node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ removeNodes = pathNodes }, useFullDPS, fastCalcOptions), calcBase) end end end @@ -687,7 +689,7 @@ function CalcsTabClass:PowerBuilder() wipeTable(node.power) if not node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[nodeId] then if not cache[node.modKey] then - cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS) + cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS, fastCalcOptions) end local output = cache[node.modKey] if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 0a4032a5e3..65ed313984 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -103,25 +103,32 @@ function calcs.getMiscCalculator(build) env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS end - local fastEnv + -- One reusable environment per options table, so that interleaved hot loops (gem sorting, + -- node power) never accelerate on state carried over from each other's overrides + local fastEnvs = setmetatable({ }, { __mode = "k" }) return function(override, useFullDPS, fastCalcOptions) if fastCalcOptions then if fastCalcOptions.fullDPSOnly and usedFullDPS and useFullDPS then -- The caller only reads the FullDPS roll-up (e.g. sorting gems by Full DPS), and -- calcFullDPS builds its own environments, so the main-skill pass can be skipped entirely. - -- The base-pass cache store lets skills with unchanged inputs reuse their captured results - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, fullDPSCache = { store = fullDPSStore } }) + -- The base-pass cache store lets skills with unchanged inputs reuse their captured results; + -- it only diffs skill-level inputs, so it is valid only while the tree and items are unchanged + local fullDPSCache = fastCalcOptions.nodeAlloc and fastCalcOptions.requirementsItems and { store = fullDPSStore } or nil + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, fullDPSCache = fullDPSCache }) return { SkillDPS = fullDPS.skills, FullDPS = fullDPS.combinedDPS, FullDotDPS = fullDPS.TotalDotDPS } end -- Accelerated pass for hot loops (e.g. gem dropdown DPS sorting): reuse the cached -- DBs and environment so unchanged state (tree, items, requirements - per the -- accelerate flags) is carried over instead of being rebuilt for every call. -- The first call builds the reusable environment from scratch, like calcFullDPS does. + local fastEnv = fastEnvs[fastCalcOptions] local accelerate = fastEnv and fastCalcOptions or nil fastEnv = calcs.initEnv(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fastEnv, accelerate = accelerate }) + fastEnvs[fastCalcOptions] = fastEnv fastEnv.override = override calcs.perform(fastEnv, fastCalcOptions.skipEHP) - if (useFullDPS ~= false or build.viewMode == "TREE") and usedFullDPS then + -- Fast-path callers read only the stat they asked for, so Full DPS is computed only on request + if useFullDPS and usedFullDPS then local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) fastEnv.player.output.SkillDPS = fullDPS.skills fastEnv.player.output.FullDPS = fullDPS.combinedDPS diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index d94d05a320..81f796622a 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -126,6 +126,7 @@ end ---@field ignoreForItems? boolean ---@field reverseSort? boolean ---@field itemField string? +---@field needsEHP? boolean The stat is only set by the EHP estimation pass ---@type PowerStat[] data.powerStatList = { @@ -162,13 +163,13 @@ data.powerStatList = { { stat="MeleeAvoidChance", label="Melee avoid chance" }, { stat="SpellAvoidChance", label="Spell avoid chance" }, { stat="ProjectileAvoidChance", label="Projectile avoid chance" }, - { stat="TotalEHP", label="Effective Hit Pool" }, - { stat="SecondMinimalMaximumHitTaken", label="Eff. Maximum Hit Taken" }, - { stat="PhysicalTakenHit", label="Taken Phys dmg", transform=function(value) return -value end }, - { stat="LightningTakenHit", label="Taken Lightning dmg", transform=function(value) return -value end }, - { stat="ColdTakenHit", label="Taken Cold dmg", transform=function(value) return -value end }, - { stat="FireTakenHit", label="Taken Fire dmg", transform=function(value) return -value end }, - { stat="ChaosTakenHit", label="Taken Chaos dmg", transform=function(value) return -value end }, + { stat="TotalEHP", label="Effective Hit Pool", needsEHP=true }, + { stat="SecondMinimalMaximumHitTaken", label="Eff. Maximum Hit Taken", needsEHP=true }, + { stat="PhysicalTakenHit", label="Taken Phys dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="LightningTakenHit", label="Taken Lightning dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="ColdTakenHit", label="Taken Cold dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="FireTakenHit", label="Taken Fire dmg", transform=function(value) return -value end, needsEHP=true }, + { stat="ChaosTakenHit", label="Taken Chaos dmg", transform=function(value) return -value end, needsEHP=true }, { stat="CritChance", label="Crit Chance" }, { stat="CritMultiplier", label="Crit Multiplier" }, { stat="BleedChance", label="Bleed Chance" }, From 3eb3fd16fd3eadde931e07c4ec283406c3700ea7 Mon Sep 17 00:00:00 2001 From: Kimmo Parviainen-Jalanko Date: Sun, 4 Oct 2026 14:40:00 +0300 Subject: [PATCH 2/3] Show the node power heat map before path power Path power needs its own calc per path (about two thirds of all node power calcs) but only the power report uses it. Every node's own power is now calculated first and powerMax published, so the heat map is complete while path power is still being filled in for the report. Progress counts both passes. On a 3-group Full DPS Deadeye the heat map is ready after 19 s instead of 61 s (Life: 7 s instead of 18 s), with identical power for all nodes. Follows up 3f3e1fd4c. --- src/Classes/CalcsTab.lua | 46 ++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 64e5ac9531..cb9e17ca54 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -571,9 +571,20 @@ function CalcsTabClass:PowerBuilder() coroutine.yield() end + local singleStat = self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes local start = GetTime() local nodeIndex = 0 local total = 0 + local function step() + nodeIndex = nodeIndex + 1 + if coroutine.running() and GetTime() - start > 100 then + if self.build.powerBuilderProgressCallback then + self.build.powerBuilderProgressCallback(m_floor(nodeIndex/total*100)) + end + coroutine.yield() + start = GetTime() + end + end for nodeId, node in pairs(self.build.spec.nodes) do wipeTable(node.power) @@ -600,6 +611,10 @@ function CalcsTabClass:PowerBuilder() node.power.distance = dist if (not self.nodePowerMaxDepth) or dist <= self.nodePowerMaxDepth then total = total + 1 + -- Path power is calculated in a second pass, so count it towards progress too + if singleStat and not node.ascendancyName and (node.alloc and #(node.depends or { }) > 1 or not node.alloc and node.path and dist > 1) then + total = total + 1 + end end end end @@ -616,6 +631,9 @@ function CalcsTabClass:PowerBuilder() end end + -- Path power needs a calculation per path, so it is deferred until every node's own + -- power (all the heat map shows) is known + local pathJobs = { } for _, data in ipairs(distanceList) do local distance, nodes = data[1], data[2] if self.nodePowerMaxDepth and self.nodePowerMaxDepth < distance then @@ -637,7 +655,7 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if distance > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ addNodes = pathNodes }, useFullDPS, fastCalcOptions), calcBase) + t_insert(pathJobs, { node = node, override = { addNodes = pathNodes } }) end end elseif not self.powerStat or not self.powerStat.ignoreForNodes then @@ -664,19 +682,12 @@ function CalcsTabClass:PowerBuilder() pathNodes[node] = true end if #node.depends > 1 then - node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc({ removeNodes = pathNodes }, useFullDPS, fastCalcOptions), calcBase) + t_insert(pathJobs, { node = node, override = { removeNodes = pathNodes } }) end end end end - nodeIndex = nodeIndex + 1 - if coroutine.running() and GetTime() - start > 100 then - if self.build.powerBuilderProgressCallback then - self.build.powerBuilderProgressCallback(m_floor(nodeIndex/total*100)) - end - coroutine.yield() - start = GetTime() - end + step() end end @@ -695,17 +706,16 @@ function CalcsTabClass:PowerBuilder() if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then node.power.singleStat = self:CalculatePowerStat(self.powerStat, output, calcBase) end - nodeIndex = nodeIndex + 1 - if coroutine.running() and GetTime() - start > 100 then - if self.build.powerBuilderProgressCallback then - self.build.powerBuilderProgressCallback(m_floor(nodeIndex/total*100)) - end - coroutine.yield() - start = GetTime() - end + step() end end + -- The heat map is complete from here on self.powerMax = newPowerMax + + for _, job in ipairs(pathJobs) do + job.node.power.pathPower = self:CalculatePowerStat(self.powerStat, calcFunc(job.override, useFullDPS, fastCalcOptions), calcBase) + step() + end self.powerBuilderInitialized = true -- ConPrintf("Power Build time: %d ms", GetTime() - timer_start) end From 79bf41c0ba5115b0a2ca080a9397c75d9fa88fc2 Mon Sep 17 00:00:00 2001 From: Kimmo Parviainen-Jalanko Date: Sun, 4 Oct 2026 14:40:00 +0300 Subject: [PATCH 3/3] Reuse base results for Full DPS skills that deal no damage Meta gems and mirage spawners are Full DPS passes that can never add damage, yet node power recalculated each of them for every node. The base capture now marks a skill inert when it has no hit, DoT, minion or mirage and its pass added nothing (all DPS 0, CullMultiplier <= 1). When the tree or items change, only inert skills reuse their captured pass. On a 3-group Full DPS Deadeye each node needs 3 passes instead of 7: Full DPS power 61 s -> 36 s, heat map 19 s -> 13 s, identical power for all 3732 nodes. --- spec/System/TestNodePower_spec.lua | 22 +++++++++++++++++ src/Modules/Calcs.lua | 39 ++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/spec/System/TestNodePower_spec.lua b/spec/System/TestNodePower_spec.lua index 64f1cbb318..8aa59a9b1b 100644 --- a/spec/System/TestNodePower_spec.lua +++ b/spec/System/TestNodePower_spec.lua @@ -64,6 +64,28 @@ describe("TestNodePower", function() assert.is_true(assertMatchesSlowPath(findStat("FullDPS")) > 0, "expected some node to change Full DPS") end) + it("does not recalculate Full DPS skills that cannot deal damage", function() + setUp("Sorceress") + build.skillsTab:PasteSocketGroup("Wind Dancer 20/0 1") + runCallback("OnFrame") + build.skillsTab.socketGroupList[2].includeInFullDPS = true + build.buildFlag = true + runCallback("OnFrame") + local calcs = build.calcsTab.calcs + local realPerform = calcs.perform + local performs = { } + calcs.perform = function(env, ...) + realPerform(env, ...) + local name = env.player.mainSkill.activeEffect.grantedEffect.name + performs[name] = (performs[name] or 0) + 1 + end + buildPower(findStat("FullDPS")) + calcs.perform = realPerform + assert.is_true((performs["Spark"] or 0) > 0, "Spark should be recalculated") + assert.is_nil(performs["Wind Dancer"], "Wind Dancer cannot deal damage and should be served from the base pass") + assertMatchesSlowPath(findStat("FullDPS")) + end) + it("Life power matches the full calculation", function() setUp("Warrior") assert.is_true(assertMatchesSlowPath(findStat("Life")) > 0, "expected some node to change Life") diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 65ed313984..2ed4bd61f4 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -112,8 +112,10 @@ function calcs.getMiscCalculator(build) -- The caller only reads the FullDPS roll-up (e.g. sorting gems by Full DPS), and -- calcFullDPS builds its own environments, so the main-skill pass can be skipped entirely. -- The base-pass cache store lets skills with unchanged inputs reuse their captured results; - -- it only diffs skill-level inputs, so it is valid only while the tree and items are unchanged - local fullDPSCache = fastCalcOptions.nodeAlloc and fastCalcOptions.requirementsItems and { store = fullDPSStore } or nil + -- it only diffs skill-level inputs, so it is valid only while the tree and items are unchanged. + -- Otherwise only skills that cannot deal damage reuse theirs + local treeAndItemsUnchanged = fastCalcOptions.nodeAlloc and fastCalcOptions.requirementsItems + local fullDPSCache = { store = fullDPSStore, inertOnly = not treeAndItemsUnchanged } local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, fullDPSCache = fullDPSCache }) return { SkillDPS = fullDPS.skills, FullDPS = fullDPS.combinedDPS, FullDotDPS = fullDPS.TotalDotDPS } end @@ -265,6 +267,28 @@ local function getSocketGroupOverride(build, override, socketGroup) return skillOverride end +-- Returns whether a captured pass contributed nothing to Full DPS from a skill that cannot deal +-- damage itself (e.g. meta gems and mirage spawners), so no passive or item change can alter it +local function isInertPass(activeSkill, pass) + local skillFlags = activeSkill.activeEffect.statSet.skillFlags + if skillFlags.hit or skillFlags.dot or activeSkill.minion or activeSkill.mirage then + return false + end + for _, actor in ipairs(pass.actors) do + for _, field in ipairs(harvestFields) do + local value = actor.out[field] + if field == "CullMultiplier" then + if value and value > 1 then + return false + end + elseif value and value ~= 0 then + return false + end + end + end + return true +end + local function findActiveSkillInEnv(env, sourceSkill) for _, candidate in ipairs(env.player.activeSkillList) do if candidate.socketGroup == sourceSkill.socketGroup and candidate.activeEffect.srcInstance == sourceSkill.activeEffect.srcInstance @@ -280,15 +304,17 @@ function calcs.calcFullDPS(build, mode, override, specEnv) -- Optional per-skill result cache driven by input diffing (specEnv.fullDPSCache): -- with capture set, each skill's harvested results are stored in the cache store along -- with its input references (own mod list + the env's coupling surface); on later calls, - -- a skill whose references are unchanged merges its cached results instead of recalculating + -- a skill whose references are unchanged merges its cached results instead of recalculating. + -- With inertOnly set, only skills that cannot deal damage reuse their cached results. local fullDPSCache = specEnv and specEnv.fullDPSCache local cacheStore = fullDPSCache and fullDPSCache.store local surfaceSame = false - if cacheStore then + if cacheStore and not fullDPSCache.inertOnly then local curSurface = captureCouplingSurface(fullEnv) if fullDPSCache.capture then cacheStore.snapshots = { } cacheStore.refs = { } + cacheStore.inert = { } cacheStore.surface = curSurface else surfaceSame = cacheStore.surface ~= nil and surfacesEqual(cacheStore.surface, curSurface) @@ -332,7 +358,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local uuid = cacheStore and cacheSkillUUID(activeSkill, skillEnv) local canCacheSkill = not crossSetSkill and not (activeSkill.triggeredBy or activeSkill.skillData.triggered) local cachedPasses - if canCacheSkill and surfaceSame and activeSkill.baseSkillModList then + if canCacheSkill and cacheStore and fullDPSCache.inertOnly then + cachedPasses = cacheStore.inert[uuid] and cacheStore.snapshots[uuid] + elseif canCacheSkill and surfaceSame and activeSkill.baseSkillModList then local ref = cacheStore.refs[uuid] if ref and cacheStore.snapshots[uuid] and modListsEqual(ref, activeSkill.baseSkillModList) then cachedPasses = cacheStore.snapshots[uuid] @@ -413,6 +441,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) if cacheStore and fullDPSCache.capture and ownRef then cacheStore.snapshots[uuid] = { pass } cacheStore.refs[uuid] = ownRef + cacheStore.inert[uuid] = isInertPass(activeSkill, pass) end local nextSkill