From e2d06de3519038c31fe5157ed026b611ab6de55d Mon Sep 17 00:00:00 2001 From: Leonel Togniolli Date: Fri, 12 Jun 2026 19:33:26 -0300 Subject: [PATCH 1/3] Speed up gem dropdown DPS sorting and hover tooltips The dropdown hover tooltip was cleared and rebuilt every frame, and each rebuild runs a full build calculation - hovering one gem cost ~60 full calcs per second. It now rebuilds only when the hovered gem, the build's outputRevision, or the relevant default settings change (tooltip:CheckForUpdate), turning the per-frame cost into a one-time cost per hovered gem. This also implements the fastCalcOptions path that the DPS sort loop was already calling into: UpdateSortCache defines the options and CalcOutputWithThisGem forwards them to the misc calculator, which now accepts them as an optional third argument: - Accelerated environment reuse: per-gem calcs carry over the cached player/enemy/minion DBs and a persistent environment with the existing accelerate flags (nodeAlloc, requirementsItems, requirementsGems), instead of rebuilding unchanged state from scratch for every gem. - skipEHP: defence estimations (EHP/max hit) are skipped during sorting unless sorting by Effective Hit Pool. - fullDPSOnly: when sorting by Full DPS, only the calcFullDPS roll-up is computed; the main-skill pass whose output was discarded is skipped. All other GetMiscCalculator callers pass two arguments and are unchanged. Verified by comparing outputs of the fast and unaccelerated paths for every supportable gem on test builds: identical results across all sort fields. --- src/Classes/GemSelectControl.lua | 57 ++++++++++++++++++-------------- src/Modules/Calcs.lua | 26 ++++++++++++++- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/Classes/GemSelectControl.lua b/src/Classes/GemSelectControl.lua index 4d6e43c95f..1a67f6d55b 100644 --- a/src/Classes/GemSelectControl.lua +++ b/src/Classes/GemSelectControl.lua @@ -47,7 +47,7 @@ local GemSelectClass = newClass("GemSelectControl", "EditControl", function(self end end) -function GemSelectClass:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) +function GemSelectClass:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS, fastCalcOptions) local gemList = self.skillsTab.displayGroup.gemList local displayGemList = self.skillsTab.displayGroup.displayGemList local oldGem @@ -75,7 +75,7 @@ function GemSelectClass:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS) gemInstance.gemData = gemData gemInstance.displayEffect = nil -- Calculate the impact of using this gem - local output = calcFunc(nil, useFullDPS) + local output = calcFunc(nil, useFullDPS, fastCalcOptions) -- Put the original gem back into the list if oldGem then gemInstance.gemData = oldGem.gemData @@ -309,6 +309,10 @@ function GemSelectClass:UpdateSortCache() local dpsField = self.skillsTab.sortGemsByDPSField local useFullDPS = dpsField == "FullDPS" + -- Between iterations of the sort loop only the gem in this slot changes, so tree + -- allocations and item/gem requirements can be carried over between calcs; EHP + -- estimation is only needed when sorting by it + local fastCalcOptions = { nodeAlloc = true, requirementsItems = true, requirementsGems = true, skipEHP = dpsField ~= "TotalEHP", fullDPSOnly = useFullDPS } local calcFunc, calcBase = self.skillsTab.build.calcsTab:GetMiscCalculator(self.build) -- Check for nil because some fields may not be populated, default to 0 local baseDPS = (dpsField == "FullDPS" and calcBase[dpsField] ~= nil and calcBase[dpsField]) or (calcBase.Minion and calcBase.Minion.CombinedDPS) or (calcBase[dpsField] ~= nil and calcBase[dpsField]) or 0 @@ -317,7 +321,7 @@ function GemSelectClass:UpdateSortCache() sortCache.dps[gemId] = baseDPS -- Ignore gems that don't support the active skill if sortCache.canSupport[gemId] or (gemData.grantedEffect.hasGlobalEffect and not gemData.grantedEffect.support) then - local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS, fastCalcOptions, calcBase) + local output = self:CalcOutputWithThisGem(calcFunc, gemData, useFullDPS, fastCalcOptions) -- Check for nil because some fields may not be populated, default to 0 sortCache.dps[gemId] = (dpsField == "FullDPS" and output[dpsField] ~= nil and output[dpsField]) or (output.Minion and output.Minion.CombinedDPS) or (output[dpsField] ~= nil and output[dpsField]) or 0 end @@ -462,27 +466,31 @@ function GemSelectClass:Draw(viewPort, noTooltip) if self.hoverSel then local calcFunc, calcBase = self.skillsTab.build.calcsTab:GetMiscCalculator(self.build) if calcFunc then - self.tooltip:Clear() local gemData = self.gems[self.list[self.hoverSel]] - local output = self:CalcOutputWithThisGem(calcFunc, gemData, self.skillsTab.sortGemsByDPSField == "FullDPS", nil, calcBase) - local gemInstance = { - level = self.skillsTab:ProcessGemLevel(gemData), - quality = self.skillsTab.defaultGemQuality or 0, - count = 1, - enabled = true, - enableGlobal1 = true, - enableGlobal2 = true, - gemId = gemData.id, - nameSpec = gemData.name, - skillId = gemData.grantedEffectId, - displayEffect = nil, - gemData = gemData, - corruptLevel = self.skillsTab.defaultCorruptionLevel, - corrupted = self.skillsTab.defaultCorruptionState == true, - } - self:AddGemTooltip(gemInstance) - self.tooltip:AddSeparator(10) - self.skillsTab.build:AddStatComparesToTooltip(self.tooltip, calcBase, output, "^7Selecting this gem will give you:") + -- Rebuilding this tooltip runs a full build calculation, so only rebuild when the hovered gem or the underlying build changes + if self.tooltip:CheckForUpdate(gemData, self.skillsTab.build.outputRevision, self.skillsTab.displayGroup, self.skillsTab.sortGemsByDPSField, + self.skillsTab.defaultGemLevel, self.skillsTab.defaultGemQuality, self.skillsTab.defaultCorruptionLevel, self.skillsTab.defaultCorruptionState) then + -- No fastCalcOptions here: the tooltip's stat compare shows defensive stats too, so it needs the full (unaccelerated) calc + local output = self:CalcOutputWithThisGem(calcFunc, gemData, self.skillsTab.sortGemsByDPSField == "FullDPS") + local gemInstance = { + level = self.skillsTab:ProcessGemLevel(gemData), + quality = self.skillsTab.defaultGemQuality or 0, + count = 1, + enabled = true, + enableGlobal1 = true, + enableGlobal2 = true, + gemId = gemData.id, + nameSpec = gemData.name, + skillId = gemData.grantedEffectId, + displayEffect = nil, + gemData = gemData, + corruptLevel = self.skillsTab.defaultCorruptionLevel, + corrupted = self.skillsTab.defaultCorruptionState == true, + } + self:AddGemTooltip(gemInstance) + self.tooltip:AddSeparator(10) + self.skillsTab.build:AddStatComparesToTooltip(self.tooltip, calcBase, output, "^7Selecting this gem will give you:") + end self.tooltip:Draw(x, y + height + 2 + (self.hoverSel - 1) * (height - 4) - scrollBar.offset, width, height - 4, viewPort) end end @@ -507,7 +515,8 @@ function GemSelectClass:Draw(viewPort, noTooltip) if mOver and (not self.skillsTab.selControl or self.skillsTab.selControl._className ~= "GemSelectControl" or not self.skillsTab.selControl.dropped) and (not noTooltip or self.forceTooltip) then local gemInstance = self.skillsTab.displayGroup.gemList[self.index] local cursorX, cursorY = GetCursorPos() - self.tooltip:Clear() + -- Clear the update params too, so the dropdown hover tooltip above knows to rebuild + self.tooltip:Clear(true) if gemInstance and gemInstance.gemData then self:AddGemTooltip(gemInstance) else diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index bcaa8d8852..5e083d6dfd 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -131,7 +131,31 @@ function calcs.getMiscCalculator(build) env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS end - return function(override, useFullDPS) + local fastEnv + 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 + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + 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 accelerate = fastEnv and fastCalcOptions or nil + fastEnv = calcs.initEnv(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fastEnv, accelerate = accelerate }) + fastEnv.override = override + calcs.perform(fastEnv, fastCalcOptions.skipEHP) + if (useFullDPS ~= false or build.viewMode == "TREE") 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 + fastEnv.player.output.FullDotDPS = fullDPS.TotalDotDPS + end + return fastEnv.player.output + end local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override) -- we need to preserve the override somewhere for use by possible trigger-based build-outs with overrides env.override = override From db81b8461703f5450d6379c2ba6f80e51ff9f8cd Mon Sep 17 00:00:00 2001 From: Leonel Togniolli Date: Fri, 12 Jun 2026 19:33:26 -0300 Subject: [PATCH 2/3] Cache per-skill Full DPS results during gem dropdown sorting When sorting the gem dropdown by Full DPS, every candidate gem triggered a full calcFullDPS pass: one perform per Full-DPS-included skill, even though a support socketed into one group cannot affect most other skills. On builds with many included skills (e.g. several companions), this multiplied the cost of every dropdown open by the skill count. calcFullDPS now supports an optional per-skill result cache (specEnv.fullDPSCache), driven by diffing each skill's calculation inputs rather than by guessing what the candidate gem does: - During the base pass, each skill's harvested outputs are captured into a plain snapshot, together with its input references: the skill's own modifier list and the environment's "coupling surface" - the buffs, auras and curses every skill provides (buffList) and the exposure it can inflict, which are the channels through which one skill's gems can influence another skill's results. - On later calls, a skill whose own modifier list and the coupling surface are both unchanged merges its cached snapshot instead of recalculating. Anything that touches the surface (auras, exposure, buff-granting supports) conservatively recalculates every skill. - Modifier lists are compared by table identity, with a depth-limited structural fallback for the few modifiers that are reconstructed on every initEnv, which would otherwise defeat the diff. The harvest section of calcFullDPS is restructured into capture-then- merge to make snapshots replayable; merge order and semantics (sum vs maximum fields, Absolution count fix, entry naming) are preserved exactly. The cache is only used by the gem sort's fullDPSOnly path and is rebuilt with the calculator on every build change, so all other calcFullDPS callers and any stale-data concerns are unaffected. Adds TestFullDPSCache covering: cached results matching fresh calculations, local supports reusing other skills' results, exposure and aura supports forcing recalculation, the reconstructed-modifier corner case, candidate gem levels, and cache invalidation on build changes. --- spec/System/TestFullDPSCache_spec.lua | 170 ++++++++++ src/Modules/Calcs.lua | 427 +++++++++++++++++++------- 2 files changed, 484 insertions(+), 113 deletions(-) create mode 100644 spec/System/TestFullDPSCache_spec.lua diff --git a/spec/System/TestFullDPSCache_spec.lua b/spec/System/TestFullDPSCache_spec.lua new file mode 100644 index 0000000000..3d3afc4ace --- /dev/null +++ b/spec/System/TestFullDPSCache_spec.lua @@ -0,0 +1,170 @@ +describe("TestFullDPSCache", function() + local calcsModule + + before_each(function() + newBuild() + calcsModule = LoadModule("Modules/Calcs") + end) + + -- Two single-skill groups, both included in Full DPS + local function buildTwoGroups() + build.skillsTab:PasteSocketGroup("Spark 20/0 1") + runCallback("OnFrame") + build.skillsTab:PasteSocketGroup("Fireball 20/0 1") + runCallback("OnFrame") + local sparkGroup = build.skillsTab.socketGroupList[1] + local fireballGroup = build.skillsTab.socketGroupList[2] + sparkGroup.mainActiveSkill = 1 + fireballGroup.mainActiveSkill = 1 + sparkGroup.includeInFullDPS = true + fireballGroup.includeInFullDPS = true + build.mainSocketGroup = 1 + build.buildFlag = true + runCallback("OnFrame") + return sparkGroup, fireballGroup + end + + local function findGem(name) + for _, gemData in pairs(build.data.gems) do + if gemData.name == name then + return gemData + end + end + end + + local function makeGemInstance(gemData, level) + return { + level = level or gemData.naturalMaxLevel, quality = 0, + count = 1, enabled = true, enableGlobal1 = true, enableGlobal2 = true, + gemId = gemData.id, nameSpec = gemData.name, skillId = gemData.grantedEffectId, + gemData = gemData, + } + end + + -- Run calcFullDPS, optionally with a cache, counting perform passes per skill name + local function runFullDPS(cache, counts) + local realPerform = calcsModule.perform + if counts then + calcsModule.perform = function(env, ...) + realPerform(env, ...) + if env.player and env.player.mainSkill then + local name = env.player.mainSkill.activeEffect.grantedEffect.name + counts[name] = (counts[name] or 0) + 1 + end + end + end + local result = calcsModule.calcFullDPS(build, "CALCULATOR", {}, cache and { fullDPSCache = cache } or {}) + calcsModule.perform = realPerform + return result + end + + -- Capture a base cache, then evaluate one candidate gem socketed into the group, + -- returning the cached-path result, per-skill perform counts, and a fresh result + local function evaluateGem(group, gemData, level) + local store = { } + runFullDPS({ store = store, capture = true }) + local slotIndex = #group.gemList + 1 + group.gemList[slotIndex] = makeGemInstance(gemData, level) + local counts = { } + local cachedResult = runFullDPS({ store = store }, counts) + local freshResult = runFullDPS(nil) + group.gemList[slotIndex] = nil + return cachedResult, counts, freshResult + end + + local function assertClose(expected, actual, label) + local diff = math.abs((expected or 0) - (actual or 0)) + local scale = math.max(math.abs(expected or 0), math.abs(actual or 0), 1) + assert.is_true(diff <= scale * 1e-9, (label or "value") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) + end + + it("returns identical results from the cached path and a fresh calculation", function() + local sparkGroup = buildTwoGroups() + for _, gemName in ipairs({ "Controlled Destruction", "Fire Exposure", "Purity of Fire", "Bleed II" }) do + local gemData = findGem(gemName) + assert.is_true(gemData ~= nil, "gem not found: " .. gemName) + local cachedResult, _, freshResult = evaluateGem(sparkGroup, gemData) + assertClose(freshResult.combinedDPS, cachedResult.combinedDPS, gemName .. " combinedDPS") + assertClose(freshResult.TotalDotDPS, cachedResult.TotalDotDPS, gemName .. " TotalDotDPS") + end + end) + + it("reuses the other skill's result for a local support", function() + local sparkGroup = buildTwoGroups() + local _, counts = evaluateGem(sparkGroup, findGem("Controlled Destruction")) + assert.is_true((counts["Spark"] or 0) >= 1, "Spark should be recalculated") + assert.is_true(counts["Fireball"] == nil, "Fireball should be served from the cache") + end) + + it("recalculates other skills when the support can inflict exposure", function() + local sparkGroup = buildTwoGroups() + local _, counts = evaluateGem(sparkGroup, findGem("Fire Exposure")) + assert.is_true((counts["Spark"] or 0) >= 1, "Spark should be recalculated") + assert.is_true((counts["Fireball"] or 0) >= 1, "Fireball must be recalculated when exposure enters the build") + end) + + it("recalculates other skills when the support changes the buff surface", function() + local sparkGroup = buildTwoGroups() + local _, counts = evaluateGem(sparkGroup, findGem("Purity of Fire")) + assert.is_true((counts["Fireball"] or 0) >= 1, "Fireball must be recalculated when a buff/aura is granted") + end) + + it("caches skills whose supports rebuild level-scaled mods each pass (Minion Mastery)", function() + local sparkGroup = buildTwoGroups() + build.skillsTab:PasteSocketGroup("Skeletal Sniper 20/0 1") + runCallback("OnFrame") + local sniperGroup = build.skillsTab.socketGroupList[3] + sniperGroup.mainActiveSkill = 1 + sniperGroup.includeInFullDPS = true + local masteryData = findGem("Minion Mastery") + assert.is_true(masteryData ~= nil, "Minion Mastery gem not found") + table.insert(sniperGroup.gemList, makeGemInstance(masteryData)) + build.buildFlag = true + runCallback("OnFrame") + -- The GemSupportLevel mod granted by Minion Mastery is reconstructed every initEnv; + -- the structural comparator must still recognise the sniper's inputs as unchanged + local cachedResult, counts, freshResult = evaluateGem(sparkGroup, findGem("Controlled Destruction")) + assert.is_true(counts["Skeletal Sniper"] == nil, "Skeletal Sniper should be served from the cache despite Minion Mastery") + assertClose(freshResult.combinedDPS, cachedResult.combinedDPS, "combinedDPS with Minion Mastery in build") + end) + + it("candidate gem level changes the cached-path result like a fresh one", function() + local sparkGroup = buildTwoGroups() + local gemData = findGem("Controlled Destruction") + local cachedLow, _, freshLow = evaluateGem(sparkGroup, gemData, 1) + assertClose(freshLow.combinedDPS, cachedLow.combinedDPS, "combinedDPS at level 1") + local cachedHigh, _, freshHigh = evaluateGem(sparkGroup, gemData) + assertClose(freshHigh.combinedDPS, cachedHigh.combinedDPS, "combinedDPS at max level") + end) + + it("end to end: the misc calculator fast path matches the slow path", function() + local sparkGroup = buildTwoGroups() + local calcFunc = build.calcsTab:GetMiscCalculator() + local fastOpts = { nodeAlloc = true, requirementsItems = true, requirementsGems = true, skipEHP = true, fullDPSOnly = true } + for _, gemName in ipairs({ "Controlled Destruction", "Fire Exposure" }) do + local gemData = findGem(gemName) + local slotIndex = #sparkGroup.gemList + 1 + sparkGroup.gemList[slotIndex] = makeGemInstance(gemData) + local slow = calcFunc(nil, true) + local slowFullDPS, slowDot = slow.FullDPS, slow.FullDotDPS + local fast = calcFunc(nil, true, fastOpts) + sparkGroup.gemList[slotIndex] = nil + assertClose(slowFullDPS, fast.FullDPS, gemName .. " FullDPS") + assertClose(slowDot, fast.FullDotDPS, gemName .. " FullDotDPS") + end + end) + + it("a stale cache is not reused after the build changes when recaptured", function() + local sparkGroup, fireballGroup = buildTwoGroups() + local gemData = findGem("Controlled Destruction") + local before = evaluateGem(sparkGroup, gemData) + -- change the other group's gem and re-evaluate; evaluateGem recaptures its own base, + -- mirroring the calculator closure being rebuilt on every build change + fireballGroup.gemList[1].level = 1 + build.buildFlag = true + runCallback("OnFrame") + local cachedResult, _, freshResult = evaluateGem(sparkGroup, gemData) + assertClose(freshResult.combinedDPS, cachedResult.combinedDPS, "combinedDPS after build change") + assert.is_true(math.abs(before.combinedDPS - cachedResult.combinedDPS) > 1e-6, "expected combinedDPS to change after lowering Fireball's level") + end) +end) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 5e083d6dfd..cd9edd49a0 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -124,7 +124,10 @@ function calcs.getMiscCalculator(build) -- Run base calculation pass local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR") calcs.perform(env) - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + -- Capture per-skill Full DPS results and their input references during the base pass, + -- so accelerated calls can reuse results for skills whose inputs are unchanged + local fullDPSStore = { } + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, fullDPSCache = { store = fullDPSStore, capture = true }}) local usedFullDPS = #fullDPS.skills > 0 if usedFullDPS then env.player.output.SkillDPS = fullDPS.skills @@ -136,8 +139,9 @@ function calcs.getMiscCalculator(build) 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 - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + -- 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 } }) 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 @@ -173,9 +177,130 @@ function calcs.getMiscCalculator(build) end, env.player.output end +-- Output fields harvested from each Full DPS calc pass; captured into plain snapshot +-- tables so that cached passes can be merged identically to freshly computed ones +local playerHarvestFields = { "TotalDPS", "BleedDPS", "CorruptingBloodDPS", "IgniteDPS", "BurningGroundDPS", "PoisonDPS", "CausticGroundDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier" } +local minionHarvestFields = { "TotalDPS", "BleedDPS", "IgniteDPS", "PoisonDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier" } +local mirageHarvestFields = { "TotalDPS", "BleedDPS", "IgniteDPS", "PoisonDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier", "BurningGroundDPS", "CausticGroundDPS" } +local function captureFields(output, fields) + local captured = { } + for _, field in ipairs(fields) do + captured[field] = output[field] + end + return captured +end + +-- Tolerant modifier equality for the Full DPS input diff: mod tables are pointer-stable +-- across initEnv calls within one build revision, except for a few mods constructed per +-- pass (e.g. GemLevel, level-scaled support mods), which are compared structurally instead. +-- Comparison is depth-limited; anything deeper or non-plain stays conservatively unequal. +local function valuesEqual(a, b, depth) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" or depth <= 0 then + return false + end + local keyCount = 0 + for k, v in pairs(a) do + keyCount = keyCount + 1 + if not valuesEqual(v, b[k], depth - 1) then + return false + end + end + for _ in pairs(b) do + keyCount = keyCount - 1 + end + return keyCount == 0 +end +local function modsEqual(a, b) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + if a.name ~= b.name or a.type ~= b.type or a.flags ~= b.flags or a.keywordFlags ~= b.keywordFlags or a.source ~= b.source then + return false + end + if not valuesEqual(a.value, b.value, 3) then + return false + end + if #a ~= #b then + return false + end + for i = 1, #a do + if not valuesEqual(a[i], b[i], 3) then + return false + end + end + return true +end +local function modListsEqual(refList, curList) + if #refList ~= #curList then + return false + end + for i = 1, #refList do + if not modsEqual(refList[i], curList[i]) then + return false + end + end + return true +end + +-- Capture the coupling surface of an environment: the state through which one skill's gems +-- can influence other skills' results - buffs/auras/curses each skill provides (buffList) +-- and exposure it can inflict. While this surface is unchanged, a skill whose own mod list +-- is unchanged must produce unchanged results. +local exposureElements = { "Fire", "Cold", "Lightning", "Chaos" } +local function captureCouplingSurface(env) + local surface = { mods = { }, meta = { } } + for _, skill in ipairs(env.player.activeSkillList) do + for _, buff in ipairs(skill.buffList or { }) do + surface.meta[#surface.meta + 1] = tostring(buff.type) .. "/" .. tostring(buff.name) + for _, mod in ipairs(buff.modList or { }) do + surface.mods[#surface.mods + 1] = mod + end + end + local modList = skill.baseSkillModList + if modList then + if modList:HasMod("FLAG", nil, "InflictExposure") then + surface.meta[#surface.meta + 1] = "expoFlag" + end + for _, element in ipairs(exposureElements) do + if modList:HasMod("BASE", nil, element .. "ExposureChance") then + surface.meta[#surface.meta + 1] = "expo" .. element + end + end + end + end + surface.metaStr = table.concat(surface.meta, ";") + return surface +end +local function surfacesEqual(refSurface, curSurface) + return refSurface.metaStr == curSurface.metaStr and modListsEqual(refSurface.mods, curSurface.mods) +end + function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil + -- 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 + local fullDPSCache = specEnv and specEnv.fullDPSCache + local cacheStore = fullDPSCache and fullDPSCache.store + local surfaceSame = false + if cacheStore then + local curSurface = captureCouplingSurface(fullEnv) + if fullDPSCache.capture then + cacheStore.snapshots = { } + cacheStore.refs = { } + cacheStore.surface = curSurface + else + surfaceSame = cacheStore.surface ~= nil and surfacesEqual(cacheStore.surface, curSurface) + end + end local fullDPS = { combinedDPS = 0, @@ -200,132 +325,208 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local burningGroundSource = "" local causticGroundSource = "" + -- Merge one captured minion pass into the Full DPS totals + local function mergeMinionPass(pass) + local minionOut = pass.minion + if minionOut.TotalDPS and minionOut.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = pass.skillName, dps = minionOut.TotalDPS, count = pass.minionCount, trigger = pass.trigger, skillPart = pass.minionSkillPart }) + fullDPS.combinedDPS = fullDPS.combinedDPS + minionOut.TotalDPS * pass.minionCount + end + if minionOut.BleedDPS and minionOut.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = minionOut.BleedDPS + bleedSource = pass.skillName + end + if minionOut.IgniteDPS and minionOut.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = minionOut.IgniteDPS + igniteSource = pass.skillName + end + if minionOut.PoisonDPS and minionOut.PoisonDPS > fullDPS.poisonDPS then + fullDPS.poisonDPS = minionOut.PoisonDPS + poisonSource = pass.skillName + end + if minionOut.ImpaleDPS and minionOut.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + minionOut.ImpaleDPS * pass.minionCount + end + if minionOut.DecayDPS and minionOut.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + minionOut.DecayDPS + end + if minionOut.TotalDot and minionOut.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + minionOut.TotalDot + end + if minionOut.CullMultiplier and minionOut.CullMultiplier > 1 and minionOut.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = minionOut.CullMultiplier + end + end + + -- Merge one captured mirage result into the Full DPS totals + local function mergeMiragePass(pass) + local mirage = pass.mirage + local mirageOut = mirage.fields + if mirageOut.TotalDPS and mirageOut.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = mirage.name, dps = mirageOut.TotalDPS, count = mirage.count, trigger = mirage.trigger, skillPart = mirage.skillPart }) + fullDPS.combinedDPS = fullDPS.combinedDPS + mirageOut.TotalDPS * mirage.count + end + if mirageOut.BleedDPS and mirageOut.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = mirageOut.BleedDPS + bleedSource = mirage.sourceName + end + if mirageOut.IgniteDPS and mirageOut.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = mirageOut.IgniteDPS + igniteSource = mirage.sourceName + end + if mirageOut.PoisonDPS and mirageOut.PoisonDPS > fullDPS.poisonDPS then + fullDPS.poisonDPS = mirageOut.PoisonDPS + poisonSource = mirage.sourceName + end + if mirageOut.ImpaleDPS and mirageOut.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + mirageOut.ImpaleDPS * mirage.count + end + if mirageOut.DecayDPS and mirageOut.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + mirageOut.DecayDPS + end + -- This will only take skillFlags from main env. Needs rework if trigger section is to be kept. + if mirageOut.TotalDot and mirageOut.TotalDot > 0 and (pass.dotCanStack or (pass.player.TotalDot and pass.player.TotalDot == 0)) then + fullDPS.dotDPS = fullDPS.dotDPS + mirageOut.TotalDot * (pass.dotCanStack and mirage.count or 1) + end + if mirageOut.CullMultiplier and mirageOut.CullMultiplier > 1 and mirageOut.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = mirageOut.CullMultiplier + end + if mirageOut.BurningGroundDPS and mirageOut.BurningGroundDPS > fullDPS.burningGroundDPS then + fullDPS.burningGroundDPS = mirageOut.BurningGroundDPS + burningGroundSource = mirage.sourceName + end + if mirageOut.CausticGroundDPS and mirageOut.CausticGroundDPS > fullDPS.causticGroundDPS then + fullDPS.causticGroundDPS = mirageOut.CausticGroundDPS + causticGroundSource = mirage.sourceName + end + end + + -- Merge one captured player result into the Full DPS totals + local function mergePlayerPass(pass) + local playerOut = pass.player + if playerOut.TotalDPS and playerOut.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = pass.skillName, dps = playerOut.TotalDPS, count = pass.count, trigger = pass.trigger, skillPart = pass.skillPart }) + fullDPS.combinedDPS = fullDPS.combinedDPS + playerOut.TotalDPS * pass.count + end + if playerOut.BleedDPS and playerOut.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = playerOut.BleedDPS + bleedSource = pass.skillName + end + if playerOut.CorruptingBloodDPS and playerOut.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then + fullDPS.corruptingBloodDPS = playerOut.CorruptingBloodDPS + corruptingBloodSource = pass.skillName + end + if playerOut.IgniteDPS and playerOut.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = playerOut.IgniteDPS + igniteSource = pass.skillName + end + if playerOut.BurningGroundDPS and playerOut.BurningGroundDPS > fullDPS.burningGroundDPS then + fullDPS.burningGroundDPS = playerOut.BurningGroundDPS + burningGroundSource = pass.skillName + end + if playerOut.PoisonDPS and playerOut.PoisonDPS > fullDPS.poisonDPS then + fullDPS.poisonDPS = playerOut.PoisonDPS + poisonSource = pass.skillName + end + if playerOut.CausticGroundDPS and playerOut.CausticGroundDPS > fullDPS.causticGroundDPS then + fullDPS.causticGroundDPS = playerOut.CausticGroundDPS + causticGroundSource = pass.skillName + end + if playerOut.ImpaleDPS and playerOut.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + playerOut.ImpaleDPS * pass.count + end + if playerOut.DecayDPS and playerOut.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + playerOut.DecayDPS + end + -- This will only take skillFlags from main env. Needs rework. + if playerOut.TotalDot and playerOut.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + playerOut.TotalDot * (pass.dotCanStack and pass.count or 1) + end + if playerOut.CullMultiplier and playerOut.CullMultiplier > 1 and playerOut.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = playerOut.CullMultiplier + end + end + + -- Merge one captured calc pass (minion, then mirage, then player sections, + -- preserving the original harvest order) into the Full DPS totals + local function mergePass(pass) + if pass.minion then + mergeMinionPass(pass) + end + if pass.mirage then + mergeMiragePass(pass) + end + if pass.player then + mergePlayerPass(pass) + end + end + for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then - local activeSkillCount, enabled = calcs.getActiveSkillCount(activeSkill) - if enabled then + local uuid = cacheStore and cacheSkillUUID(activeSkill, fullEnv) + local cachedPasses + if 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] + end + end + local activeSkillCount, enabled + if not cachedPasses then + activeSkillCount, enabled = calcs.getActiveSkillCount(activeSkill) + end + if cachedPasses then + -- This skill's own mod list and the coupling surface are unchanged since the + -- capture pass, so its results cannot have changed: merge the cached passes + for _, pass in ipairs(cachedPasses) do + mergePass(pass) + end + elseif enabled then + local ownRef + if cacheStore and fullDPSCache.capture and activeSkill.baseSkillModList then + -- Reference the skill's pre-perform mod list for later input diffing + ownRef = { } + for i, mod in ipairs(activeSkill.baseSkillModList) do + ownRef[i] = mod + end + end fullEnv.player.mainSkill = activeSkill calcs.perform(fullEnv, true) usedEnv = fullEnv - local minionName = nil + -- Capture this pass's results into a plain snapshot, then merge it into the totals; + -- the snapshot lets later calls reuse the results when this skill's inputs are unchanged + local pass = { skillName = activeSkill.activeEffect.grantedEffect.name, trigger = activeSkill.infoTrigger, count = activeSkillCount, dotCanStack = activeSkill.activeEffect.statSet.skillFlags.DotCanStack } if activeSkill.minion or usedEnv.minion then - if usedEnv.minion.output.TotalDPS and usedEnv.minion.output.TotalDPS > 0 then - minionName = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData.name..": ") or "" - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = usedEnv.minion.output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName..activeSkill.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + usedEnv.minion.output.TotalDPS * activeSkillCount - end - if usedEnv.minion.output.BleedDPS and usedEnv.minion.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = usedEnv.minion.output.BleedDPS - bleedSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.minion.output.IgniteDPS and usedEnv.minion.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = usedEnv.minion.output.IgniteDPS - igniteSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.minion.output.PoisonDPS and usedEnv.minion.output.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = usedEnv.minion.output.PoisonDPS - poisonSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.minion.output.ImpaleDPS and usedEnv.minion.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.minion.output.ImpaleDPS * activeSkillCount - end - if usedEnv.minion.output.DecayDPS and usedEnv.minion.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.minion.output.DecayDPS - end - if usedEnv.minion.output.TotalDot and usedEnv.minion.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.minion.output.TotalDot - end - if usedEnv.minion.output.CullMultiplier and usedEnv.minion.output.CullMultiplier > 1 and usedEnv.minion.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = usedEnv.minion.output.CullMultiplier - end + pass.minion = captureFields(usedEnv.minion.output, minionHarvestFields) + pass.minionCount = activeSkillCount + local minionNamePrefix = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData.name..": ") or "" + pass.minionSkillPart = minionNamePrefix .. activeSkill.skillPartName -- This is a fix to prevent Absolution spell hit from being counted multiple times when increasing minions count if activeSkill.activeEffect.grantedEffect.name == "Absolution" and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce") then activeSkillCount = 1 activeSkill.infoMessage2 = "Skill Damage" + pass.count = 1 end end if activeSkill.mirage then - local mirageCount = (activeSkill.mirage.count or 1) * activeSkillCount - if activeSkill.mirage.output.TotalDPS and activeSkill.mirage.output.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = activeSkill.mirage.name .. " (Mirage)", dps = activeSkill.mirage.output.TotalDPS, count = mirageCount, trigger = activeSkill.mirage.infoTrigger, skillPart = activeSkill.mirage.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + activeSkill.mirage.output.TotalDPS * mirageCount - end - if activeSkill.mirage.output.BleedDPS and activeSkill.mirage.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = activeSkill.mirage.output.BleedDPS - bleedSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" - end - if activeSkill.mirage.output.IgniteDPS and activeSkill.mirage.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = activeSkill.mirage.output.IgniteDPS - igniteSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" - end - if activeSkill.mirage.output.PoisonDPS and activeSkill.mirage.output.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = activeSkill.mirage.output.PoisonDPS - poisonSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" - end - if activeSkill.mirage.output.ImpaleDPS and activeSkill.mirage.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + activeSkill.mirage.output.ImpaleDPS * mirageCount - end - if activeSkill.mirage.output.DecayDPS and activeSkill.mirage.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + activeSkill.mirage.output.DecayDPS - end - -- This will only take skillFlags from main env. Needs rework if trigger section is to be kept. - if activeSkill.mirage.output.TotalDot and activeSkill.mirage.output.TotalDot > 0 and (activeSkill.activeEffect.statSet.skillFlags.DotCanStack or (usedEnv.player.output.TotalDot and usedEnv.player.output.TotalDot == 0)) then - fullDPS.dotDPS = fullDPS.dotDPS + activeSkill.mirage.output.TotalDot * (activeSkill.activeEffect.statSet.skillFlags.DotCanStack and mirageCount or 1) - end - if activeSkill.mirage.output.CullMultiplier and activeSkill.mirage.output.CullMultiplier > 1 and activeSkill.mirage.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = activeSkill.mirage.output.CullMultiplier - end - if activeSkill.mirage.output.BurningGroundDPS and activeSkill.mirage.output.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = activeSkill.mirage.output.BurningGroundDPS - burningGroundSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" - end - if activeSkill.mirage.output.CausticGroundDPS and activeSkill.mirage.output.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = activeSkill.mirage.output.CausticGroundDPS - causticGroundSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" - end + pass.mirage = { + fields = captureFields(activeSkill.mirage.output, mirageHarvestFields), + name = activeSkill.mirage.name .. " (Mirage)", + sourceName = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)", + count = (activeSkill.mirage.count or 1) * activeSkillCount, + trigger = activeSkill.mirage.infoTrigger, + skillPart = activeSkill.mirage.skillPartName, + } end - if usedEnv.player.output.TotalDPS and usedEnv.player.output.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = usedEnv.player.output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName and activeSkill.infoMessage2 or activeSkill.skillPartName }) - fullDPS.combinedDPS = fullDPS.combinedDPS + usedEnv.player.output.TotalDPS * activeSkillCount - end - if usedEnv.player.output.BleedDPS and usedEnv.player.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = usedEnv.player.output.BleedDPS - bleedSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.player.output.CorruptingBloodDPS and usedEnv.player.output.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then - fullDPS.corruptingBloodDPS = usedEnv.player.output.CorruptingBloodDPS - corruptingBloodSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.player.output.IgniteDPS and usedEnv.player.output.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = usedEnv.player.output.IgniteDPS - igniteSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.player.output.BurningGroundDPS and usedEnv.player.output.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = usedEnv.player.output.BurningGroundDPS - burningGroundSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.player.output.PoisonDPS and usedEnv.player.output.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = usedEnv.player.output.PoisonDPS - poisonSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.player.output.CausticGroundDPS and usedEnv.player.output.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = usedEnv.player.output.CausticGroundDPS - causticGroundSource = activeSkill.activeEffect.grantedEffect.name - end - if usedEnv.player.output.ImpaleDPS and usedEnv.player.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.player.output.ImpaleDPS * activeSkillCount - end - if usedEnv.player.output.DecayDPS and usedEnv.player.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.player.output.DecayDPS - end - -- This will only take skillFlags from main env. Needs rework. - if usedEnv.player.output.TotalDot and usedEnv.player.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.player.output.TotalDot * (activeSkill.activeEffect.statSet.skillFlags.DotCanStack and activeSkillCount or 1) - end - if usedEnv.player.output.CullMultiplier and usedEnv.player.output.CullMultiplier > 1 and usedEnv.player.output.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = usedEnv.player.output.CullMultiplier + pass.player = captureFields(usedEnv.player.output, playerHarvestFields) + local minionContributed = pass.minion and pass.minion.TotalDPS and pass.minion.TotalDPS > 0 + pass.skillPart = minionContributed and activeSkill.infoMessage2 or activeSkill.skillPartName + mergePass(pass) + if cacheStore and fullDPSCache.capture and ownRef then + cacheStore.snapshots[uuid] = { pass } + cacheStore.refs[uuid] = ownRef end -- Re-Build env calculator for new run From 5d45d97db037309bbf958393675f3bcfdde61eb6 Mon Sep 17 00:00:00 2001 From: Leonel Togniolli Date: Fri, 12 Jun 2026 21:09:47 -0300 Subject: [PATCH 3/3] Simplify Full DPS harvesting into shared folding and uniform actor entries - Replace the repeated per-stat, per-actor merge blocks with a single merge spec and fold; pass snapshots hold uniform per-actor entries, with the per-actor differences (entry naming, pass counts, dot scaling and gating) resolved as plain data at capture time. - Unify the captured output fields into one harvestFields list. Stats previously not folded for minions never occur on test builds, and the mirage path is currently disabled, so results are unchanged. - Compare modifiers with tableDeepEquals, called in both directions since it only inspects the first table's keys; a one-sided match could let a modifier that gained a field produce a stale cache hit. --- src/Modules/Calcs.lua | 286 +++++++++++++----------------------------- 1 file changed, 86 insertions(+), 200 deletions(-) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index cd9edd49a0..a0bd837a29 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -179,62 +179,32 @@ end -- Output fields harvested from each Full DPS calc pass; captured into plain snapshot -- tables so that cached passes can be merged identically to freshly computed ones -local playerHarvestFields = { "TotalDPS", "BleedDPS", "CorruptingBloodDPS", "IgniteDPS", "BurningGroundDPS", "PoisonDPS", "CausticGroundDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier" } -local minionHarvestFields = { "TotalDPS", "BleedDPS", "IgniteDPS", "PoisonDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier" } -local mirageHarvestFields = { "TotalDPS", "BleedDPS", "IgniteDPS", "PoisonDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier", "BurningGroundDPS", "CausticGroundDPS" } -local function captureFields(output, fields) +local harvestFields = { "TotalDPS", "BleedDPS", "CorruptingBloodDPS", "IgniteDPS", "BurningGroundDPS", "PoisonDPS", "CausticGroundDPS", "ImpaleDPS", "DecayDPS", "TotalDot", "CullMultiplier" } +local function captureFields(output) local captured = { } - for _, field in ipairs(fields) do + for _, field in ipairs(harvestFields) do captured[field] = output[field] end return captured end +local mergeStatsSpec = { + { key = "BleedDPS", target = "bleedDPS", mode = "max" }, + { key = "CorruptingBloodDPS", target = "corruptingBloodDPS", mode = "max" }, + { key = "IgniteDPS", target = "igniteDPS", mode = "max" }, + { key = "BurningGroundDPS", target = "burningGroundDPS", mode = "max" }, + { key = "PoisonDPS", target = "poisonDPS", mode = "max" }, + { key = "CausticGroundDPS", target = "causticGroundDPS", mode = "max" }, + { key = "ImpaleDPS", target = "impaleDPS", mode = "add", scaled = true }, + { key = "DecayDPS", target = "decayDPS", mode = "add" }, + { key = "CullMultiplier", target = "cullingMulti", mode = "cull" }, +} + -- Tolerant modifier equality for the Full DPS input diff: mod tables are pointer-stable -- across initEnv calls within one build revision, except for a few mods constructed per -- pass (e.g. GemLevel, level-scaled support mods), which are compared structurally instead. --- Comparison is depth-limited; anything deeper or non-plain stays conservatively unequal. -local function valuesEqual(a, b, depth) - if a == b then - return true - end - if type(a) ~= "table" or type(b) ~= "table" or depth <= 0 then - return false - end - local keyCount = 0 - for k, v in pairs(a) do - keyCount = keyCount + 1 - if not valuesEqual(v, b[k], depth - 1) then - return false - end - end - for _ in pairs(b) do - keyCount = keyCount - 1 - end - return keyCount == 0 -end local function modsEqual(a, b) - if a == b then - return true - end - if type(a) ~= "table" or type(b) ~= "table" then - return false - end - if a.name ~= b.name or a.type ~= b.type or a.flags ~= b.flags or a.keywordFlags ~= b.keywordFlags or a.source ~= b.source then - return false - end - if not valuesEqual(a.value, b.value, 3) then - return false - end - if #a ~= #b then - return false - end - for i = 1, #a do - if not valuesEqual(a[i], b[i], 3) then - return false - end - end - return true + return a == b or (type(a) == "table" and type(b) == "table" and tableDeepEquals(a, b) and tableDeepEquals(b, a)) end local function modListsEqual(refList, curList) if #refList ~= #curList then @@ -318,146 +288,43 @@ function calcs.calcFullDPS(build, mode, override, specEnv) cullingMulti = 0 } - local poisonSource = "" - local bleedSource = "" - local corruptingBloodSource = "" - local igniteSource = "" - local burningGroundSource = "" - local causticGroundSource = "" - - -- Merge one captured minion pass into the Full DPS totals - local function mergeMinionPass(pass) - local minionOut = pass.minion - if minionOut.TotalDPS and minionOut.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = pass.skillName, dps = minionOut.TotalDPS, count = pass.minionCount, trigger = pass.trigger, skillPart = pass.minionSkillPart }) - fullDPS.combinedDPS = fullDPS.combinedDPS + minionOut.TotalDPS * pass.minionCount - end - if minionOut.BleedDPS and minionOut.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = minionOut.BleedDPS - bleedSource = pass.skillName - end - if minionOut.IgniteDPS and minionOut.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = minionOut.IgniteDPS - igniteSource = pass.skillName - end - if minionOut.PoisonDPS and minionOut.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = minionOut.PoisonDPS - poisonSource = pass.skillName - end - if minionOut.ImpaleDPS and minionOut.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + minionOut.ImpaleDPS * pass.minionCount - end - if minionOut.DecayDPS and minionOut.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + minionOut.DecayDPS - end - if minionOut.TotalDot and minionOut.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + minionOut.TotalDot - end - if minionOut.CullMultiplier and minionOut.CullMultiplier > 1 and minionOut.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = minionOut.CullMultiplier - end - end - -- Merge one captured mirage result into the Full DPS totals - local function mergeMiragePass(pass) - local mirage = pass.mirage - local mirageOut = mirage.fields - if mirageOut.TotalDPS and mirageOut.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = mirage.name, dps = mirageOut.TotalDPS, count = mirage.count, trigger = mirage.trigger, skillPart = mirage.skillPart }) - fullDPS.combinedDPS = fullDPS.combinedDPS + mirageOut.TotalDPS * mirage.count - end - if mirageOut.BleedDPS and mirageOut.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = mirageOut.BleedDPS - bleedSource = mirage.sourceName - end - if mirageOut.IgniteDPS and mirageOut.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = mirageOut.IgniteDPS - igniteSource = mirage.sourceName - end - if mirageOut.PoisonDPS and mirageOut.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = mirageOut.PoisonDPS - poisonSource = mirage.sourceName - end - if mirageOut.ImpaleDPS and mirageOut.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + mirageOut.ImpaleDPS * mirage.count - end - if mirageOut.DecayDPS and mirageOut.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + mirageOut.DecayDPS - end - -- This will only take skillFlags from main env. Needs rework if trigger section is to be kept. - if mirageOut.TotalDot and mirageOut.TotalDot > 0 and (pass.dotCanStack or (pass.player.TotalDot and pass.player.TotalDot == 0)) then - fullDPS.dotDPS = fullDPS.dotDPS + mirageOut.TotalDot * (pass.dotCanStack and mirage.count or 1) - end - if mirageOut.CullMultiplier and mirageOut.CullMultiplier > 1 and mirageOut.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = mirageOut.CullMultiplier - end - if mirageOut.BurningGroundDPS and mirageOut.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = mirageOut.BurningGroundDPS - burningGroundSource = mirage.sourceName - end - if mirageOut.CausticGroundDPS and mirageOut.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = mirageOut.CausticGroundDPS - causticGroundSource = mirage.sourceName - end - end + local sources = { } - -- Merge one captured player result into the Full DPS totals - local function mergePlayerPass(pass) - local playerOut = pass.player - if playerOut.TotalDPS and playerOut.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = pass.skillName, dps = playerOut.TotalDPS, count = pass.count, trigger = pass.trigger, skillPart = pass.skillPart }) - fullDPS.combinedDPS = fullDPS.combinedDPS + playerOut.TotalDPS * pass.count - end - if playerOut.BleedDPS and playerOut.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = playerOut.BleedDPS - bleedSource = pass.skillName - end - if playerOut.CorruptingBloodDPS and playerOut.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then - fullDPS.corruptingBloodDPS = playerOut.CorruptingBloodDPS - corruptingBloodSource = pass.skillName - end - if playerOut.IgniteDPS and playerOut.IgniteDPS > fullDPS.igniteDPS then - fullDPS.igniteDPS = playerOut.IgniteDPS - igniteSource = pass.skillName - end - if playerOut.BurningGroundDPS and playerOut.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = playerOut.BurningGroundDPS - burningGroundSource = pass.skillName - end - if playerOut.PoisonDPS and playerOut.PoisonDPS > fullDPS.poisonDPS then - fullDPS.poisonDPS = playerOut.PoisonDPS - poisonSource = pass.skillName - end - if playerOut.CausticGroundDPS and playerOut.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = playerOut.CausticGroundDPS - causticGroundSource = pass.skillName - end - if playerOut.ImpaleDPS and playerOut.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + playerOut.ImpaleDPS * pass.count - end - if playerOut.DecayDPS and playerOut.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + playerOut.DecayDPS - end - -- This will only take skillFlags from main env. Needs rework. - if playerOut.TotalDot and playerOut.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + playerOut.TotalDot * (pass.dotCanStack and pass.count or 1) - end - if playerOut.CullMultiplier and playerOut.CullMultiplier > 1 and playerOut.CullMultiplier > fullDPS.cullingMulti then - fullDPS.cullingMulti = playerOut.CullMultiplier + local function mergeStats(out, count, sourceName) + for _, stat in ipairs(mergeStatsSpec) do + local value = out[stat.key] + if value then + if stat.mode == "max" then + if value > fullDPS[stat.target] then + fullDPS[stat.target] = value + sources[stat.target] = sourceName + end + elseif stat.mode == "add" then + if value > 0 then + fullDPS[stat.target] = fullDPS[stat.target] + value * (stat.scaled and count or 1) + end + elseif stat.mode == "cull" then + if value > 1 and value > fullDPS[stat.target] then + fullDPS[stat.target] = value + end + end + end end end - -- Merge one captured calc pass (minion, then mirage, then player sections, - -- preserving the original harvest order) into the Full DPS totals - local function mergePass(pass) - if pass.minion then - mergeMinionPass(pass) - end - if pass.mirage then - mergeMiragePass(pass) - end - if pass.player then - mergePlayerPass(pass) + -- Merge one captured calc pass into the Full DPS totals + local function mergePass(pass) + for _, actor in ipairs(pass.actors) do + local out = actor.out + if out.TotalDPS and out.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = actor.name, dps = out.TotalDPS, count = actor.count, trigger = actor.trigger, skillPart = actor.skillPart }) + fullDPS.combinedDPS = fullDPS.combinedDPS + out.TotalDPS * actor.count + end + mergeStats(out, actor.count, actor.sourceName) + if out.TotalDot and out.TotalDot > 0 and actor.dotScale then + fullDPS.dotDPS = fullDPS.dotDPS + out.TotalDot * actor.dotScale + end end end @@ -495,34 +362,53 @@ function calcs.calcFullDPS(build, mode, override, specEnv) usedEnv = fullEnv -- Capture this pass's results into a plain snapshot, then merge it into the totals; -- the snapshot lets later calls reuse the results when this skill's inputs are unchanged - local pass = { skillName = activeSkill.activeEffect.grantedEffect.name, trigger = activeSkill.infoTrigger, count = activeSkillCount, dotCanStack = activeSkill.activeEffect.statSet.skillFlags.DotCanStack } + local skillName = activeSkill.activeEffect.grantedEffect.name + local dotCanStack = activeSkill.activeEffect.statSet.skillFlags.DotCanStack + local pass = { actors = { } } + local minionOut if activeSkill.minion or usedEnv.minion then - pass.minion = captureFields(usedEnv.minion.output, minionHarvestFields) - pass.minionCount = activeSkillCount + minionOut = captureFields(usedEnv.minion.output) local minionNamePrefix = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData.name..": ") or "" - pass.minionSkillPart = minionNamePrefix .. activeSkill.skillPartName + t_insert(pass.actors, { + out = minionOut, + name = skillName, + count = activeSkillCount, + trigger = activeSkill.infoTrigger, + skillPart = minionNamePrefix .. activeSkill.skillPartName, + sourceName = skillName, + dotScale = 1, + }) -- This is a fix to prevent Absolution spell hit from being counted multiple times when increasing minions count if activeSkill.activeEffect.grantedEffect.name == "Absolution" and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce") then activeSkillCount = 1 activeSkill.infoMessage2 = "Skill Damage" - pass.count = 1 end end + local playerOut = captureFields(usedEnv.player.output) if activeSkill.mirage then - pass.mirage = { - fields = captureFields(activeSkill.mirage.output, mirageHarvestFields), + local mirageCount = (activeSkill.mirage.count or 1) * activeSkillCount + t_insert(pass.actors, { + out = captureFields(activeSkill.mirage.output), name = activeSkill.mirage.name .. " (Mirage)", - sourceName = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)", - count = (activeSkill.mirage.count or 1) * activeSkillCount, + count = mirageCount, trigger = activeSkill.mirage.infoTrigger, skillPart = activeSkill.mirage.skillPartName, - } + sourceName = skillName .. " (Mirage)", + dotScale = (dotCanStack or (playerOut.TotalDot and playerOut.TotalDot == 0)) and (dotCanStack and mirageCount or 1) or nil, + }) end - pass.player = captureFields(usedEnv.player.output, playerHarvestFields) - local minionContributed = pass.minion and pass.minion.TotalDPS and pass.minion.TotalDPS > 0 - pass.skillPart = minionContributed and activeSkill.infoMessage2 or activeSkill.skillPartName + local minionContributed = minionOut and minionOut.TotalDPS and minionOut.TotalDPS > 0 + t_insert(pass.actors, { + out = playerOut, + name = skillName, + count = activeSkillCount, + trigger = activeSkill.infoTrigger, + skillPart = minionContributed and activeSkill.infoMessage2 or activeSkill.skillPartName, + sourceName = skillName, + dotScale = dotCanStack and activeSkillCount or 1, + }) mergePass(pass) if cacheStore and fullDPSCache.capture and ownRef then cacheStore.snapshots[uuid] = { pass } @@ -545,27 +431,27 @@ function calcs.calcFullDPS(build, mode, override, specEnv) -- Re-Add ailment DPS components fullDPS.TotalDotDPS = 0 if fullDPS.bleedDPS > 0 then - t_insert(fullDPS.skills, { name = "Best Bleed DPS", dps = fullDPS.bleedDPS, count = 1, source = bleedSource }) + t_insert(fullDPS.skills, { name = "Best Bleed DPS", dps = fullDPS.bleedDPS, count = 1, source = sources.bleedDPS or "" }) fullDPS.TotalDotDPS = fullDPS.TotalDotDPS + fullDPS.bleedDPS end if fullDPS.corruptingBloodDPS > 0 then - t_insert(fullDPS.skills, { name = "Best Corr. Blood DPS", dps = fullDPS.corruptingBloodDPS, count = 1, source = corruptingBloodSource }) + t_insert(fullDPS.skills, { name = "Best Corr. Blood DPS", dps = fullDPS.corruptingBloodDPS, count = 1, source = sources.corruptingBloodDPS or "" }) fullDPS.TotalDotDPS = fullDPS.TotalDotDPS + fullDPS.corruptingBloodDPS end if fullDPS.igniteDPS > 0 then - t_insert(fullDPS.skills, { name = "Best Ignite DPS", dps = fullDPS.igniteDPS, count = 1, source = igniteSource }) + t_insert(fullDPS.skills, { name = "Best Ignite DPS", dps = fullDPS.igniteDPS, count = 1, source = sources.igniteDPS or "" }) fullDPS.TotalDotDPS = fullDPS.TotalDotDPS + fullDPS.igniteDPS end if fullDPS.burningGroundDPS > 0 then - t_insert(fullDPS.skills, { name = "Best Burning Ground DPS", dps = fullDPS.burningGroundDPS, count = 1, source = burningGroundSource }) + t_insert(fullDPS.skills, { name = "Best Burning Ground DPS", dps = fullDPS.burningGroundDPS, count = 1, source = sources.burningGroundDPS or "" }) fullDPS.TotalDotDPS = fullDPS.TotalDotDPS + fullDPS.burningGroundDPS end if fullDPS.poisonDPS > 0 then - t_insert(fullDPS.skills, { name = "Best Poison DPS", dps = fullDPS.poisonDPS, count = 1, source = poisonSource }) + t_insert(fullDPS.skills, { name = "Best Poison DPS", dps = fullDPS.poisonDPS, count = 1, source = sources.poisonDPS or "" }) fullDPS.TotalDotDPS = fullDPS.TotalDotDPS + fullDPS.poisonDPS end if fullDPS.causticGroundDPS > 0 then - t_insert(fullDPS.skills, { name = "Best Caustic Ground DPS", dps = fullDPS.causticGroundDPS, count = 1, source = causticGroundSource }) + t_insert(fullDPS.skills, { name = "Best Caustic Ground DPS", dps = fullDPS.causticGroundDPS, count = 1, source = sources.causticGroundDPS or "" }) fullDPS.TotalDotDPS = fullDPS.TotalDotDPS + fullDPS.causticGroundDPS end if fullDPS.impaleDPS > 0 then