From dc503f8072b8671dd205e4beba008fef8f837942 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Thu, 12 Mar 2026 19:38:31 -0400 Subject: [PATCH 01/12] Make source assignment non-mutating --- src/Modules/CalcPerform.lua | 16 ++++++---------- src/Modules/CalcSetup.lua | 8 +++----- src/Modules/ModTools.lua | 14 ++++++++++++-- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 50041da89b..c16576e470 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -584,7 +584,7 @@ local function applyEnemyModifiers(actor, clearCache) local mod = value.value and value.value.mod if mod and not cache[mod] then local source = mod.source or value.mod.source - enemyDB:AddMod(modLib.setSource(mod, source)) + enemyDB:AddMod(modLib.withSource(mod, source)) cache[mod] = true end end @@ -1753,8 +1753,7 @@ function calcs.perform(env, skipEHP) for _, value in ipairs(env.modDB:Tabulate(nil, nil, "EnemyModifier")) do local mod = value.value and value.value.mod if mod then - local copy = copyTable(mod, true) - env.minion.modDB:AddMod(modLib.setSource(copy, mod.source or value.mod.source)) + env.minion.modDB:AddMod(modLib.withSource(mod, mod.source or value.mod.source)) end end end @@ -2025,23 +2024,20 @@ function calcs.perform(env, skipEHP) buffs["Spectre"] = buffs["Spectre"] or new("ModList") minionBuffs["Spectre"] = minionBuffs["Spectre"] or new("ModList") for _, modValue in pairs(modData.value) do - local copyModValue = copyTable(modValue) - copyModValue.source = "Spectre:"..spectreData.name + local copyModValue = modLib.withSource(modValue, "Spectre:"..spectreData.name) t_insert(minionBuffs["Spectre"], copyModValue) t_insert(buffs["Spectre"], copyModValue) end elseif modData.name == "MinionModifier" and modData.type == "LIST" then minionBuffs["Spectre"] = minionBuffs["Spectre"] or new("ModList") for _, modValue in pairs(modData.value) do - local copyModValue = copyTable(modValue) - copyModValue.source = "Spectre:"..spectreData.name + local copyModValue = modLib.withSource(modValue, "Spectre:"..spectreData.name) t_insert(minionBuffs["Spectre"], copyModValue) end elseif modData.name == "PlayerModifier" and modData.type == "LIST" then buffs["Spectre"] = buffs["Spectre"] or new("ModList") for _, modValue in pairs(modData.value) do - local copyModValue = copyTable(modValue) - copyModValue.source = "Spectre:"..spectreData.name + local copyModValue = modLib.withSource(modValue, "Spectre:"..spectreData.name) t_insert(buffs["Spectre"], copyModValue) end end @@ -2557,7 +2553,7 @@ function calcs.perform(env, skipEHP) source = source..castingMinion.minionData.name end for i = 1, #modList do - modList[i].source = source + modList[i] = modLib.withSource(modList[i], source) end end end diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 43af65c78c..cf904dd4e9 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -310,7 +310,7 @@ local function applySocketMods(env, gem, groupCfg, socketNum, modSource) socketCfg.skillGem = gem socketCfg.socketNum = socketNum for _, value in ipairs(env.modDB:List(socketCfg, "SocketProperty")) do - env.player.modDB:AddMod(modLib.setSource(value.value, modSource or groupCfg.slotName or "")) + env.player.modDB:AddMod(modLib.withSource(value.value, modSource or groupCfg.slotName or "")) end end @@ -1054,9 +1054,7 @@ function calcs.initEnv(build, mode, override, specEnv) end end - local modCopy = copyTable(mod) - modLib.setSource(modCopy, item.modSource) - env.itemModDB:ScaleAddMod(modCopy, scale) + env.itemModDB:ScaleAddMod(modLib.withSource(mod, item.modSource), scale) ::skip_mod:: end @@ -1684,7 +1682,7 @@ function calcs.initEnv(build, mode, override, specEnv) } local groupCfg = groupCfgList[slotName or "noSlot"][group] for _, value in ipairs(env.modDB:List(groupCfg, "GroupProperty")) do - env.player.modDB:AddMod(modLib.setSource(value.value, groupCfg.slotName or "")) + env.player.modDB:AddMod(modLib.withSource(value.value, groupCfg.slotName or "")) end if index == env.mainSocketGroup and #socketGroupSkillList > 0 then diff --git a/src/Modules/ModTools.lua b/src/Modules/ModTools.lua index 781008ff22..bed3509f88 100644 --- a/src/Modules/ModTools.lua +++ b/src/Modules/ModTools.lua @@ -214,6 +214,16 @@ function modLib.formatSourceMod(mod) return s_format("%s|%s|%s", modLib.formatValue(mod.value), mod.source, modLib.formatModParams(mod)) end +function modLib.withSource(mod, source) + local copy = copyTableSafe(mod, false) + copy.source = source + if type(copy.value) == "table" and copy.value.mod then + copy.value.mod.source = source + end + return copy +end + +-- Deprecated: internal-only helper that mutates the provided mod; prefer withSource for shared mods. function modLib.setSource(mod, source) mod.source = source if type(mod.value) == "table" and mod.value.mod then @@ -230,8 +240,8 @@ function modLib.mergeKeystones(env, modDB) env.keystonesAdded[modObj.value] = true local fromTree = modObj.mod.source and not modObj.mod.source:lower():match("tree") for _, mod in ipairs(env.spec.tree.keystoneMap[modObj.value].modList) do - modDB:AddMod(fromTree and modLib.setSource(mod, modObj.mod.source) or mod) + modDB:AddMod(fromTree and modLib.withSource(mod, modObj.mod.source) or mod) end end end -end \ No newline at end of file +end From 9eaec76a1705d83c8158bb1d7dbaabb01ebd1df7 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 09:04:47 -0400 Subject: [PATCH 02/12] Avoid unsafe graph cloning in calc paths --- src/Classes/ModStore.lua | 12 +- src/Modules/CalcActiveSkill.lua | 439 ++++++++++++++++++++++++++------ src/Modules/CalcMirages.lua | 10 +- src/Modules/CalcPerform.lua | 12 +- src/Modules/CalcTriggers.lua | 19 +- src/Modules/Common.lua | 32 ++- 6 files changed, 425 insertions(+), 99 deletions(-) diff --git a/src/Classes/ModStore.lua b/src/Classes/ModStore.lua index 92603eb323..27f8e20020 100644 --- a/src/Classes/ModStore.lua +++ b/src/Classes/ModStore.lua @@ -32,6 +32,8 @@ local ModStoreClass = newClass("ModStore", function(self, parent) self.actor = parent and parent.actor or { } self.multipliers = { } self.conditions = { } + -- ModDB/ModList instances participate in actor/parent graphs and are not plain copyTable() targets. + graphNodeTag(self, self._className or "ModStore") end) function ModStoreClass:ScaleAddMod(mod, scale, replace) @@ -45,7 +47,7 @@ function ModStoreClass:ScaleAddMod(mod, scale, replace) if scale == 1 or unscalable then self:AddMod(mod) else - local scaledMod = copyTable(mod) + local scaledMod = type(mod.value) == "table" and copyTableSafe(mod, false) or copyTable(mod) local subMod = scaledMod if type(scaledMod.value) == "table" then if scaledMod.value.mod then @@ -367,7 +369,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) mult = 1 / mult end if type(value) == "table" then - value = copyTable(value) + value = copyTableSafe(value, false) if value.mod then value.mod.value = value.mod.value * mult + (tag.base or 0) if limitTotal then @@ -453,7 +455,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end end if type(value) == "table" then - value = copyTable(value) + value = copyTableSafe(value, false) if value.mod then value.mod.value = value.mod.value * mult + (tag.base or 0) if limitTotal then @@ -502,7 +504,7 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end end if type(value) == "table" then - value = copyTable(value) + value = copyTableSafe(value, false) if value.mod then value.mod.value = m_ceil(value.mod.value * mult + (tag.base or 0)) if limitTotal then @@ -901,4 +903,4 @@ function ModStoreClass:EvalMod(mod, cfg, globalLimits) end end return value -end \ No newline at end of file +end diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index 582cd37691..6c2614db54 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -31,9 +31,8 @@ local function mergeLevelMod(modList, mod, value) elseif value then local newMod = copyTable(mod, true) if type(newMod.value) == "table" then - newMod.value = copyTable(newMod.value, true) + newMod.value = copyTableSafe(newMod.value, false) if newMod.value.mod then - newMod.value.mod = copyTable(newMod.value.mod, true) newMod.value.mod.value = value else newMod.value.value = value @@ -80,7 +79,8 @@ end -- Create an active skill using the given active gem and list of support gems -- It will determine the base flag set, and check which of the support gems can support this skill function calcs.createActiveSkill(activeEffect, supportList, actor, socketGroup, summonSkill) - local activeSkill = { + -- Active skills retain live links to actor/support/socket/minion state and are graph objects. + local activeSkill = graphNodeTag({ activeEffect = activeEffect, supportList = supportList, actor = actor, @@ -88,7 +88,7 @@ function calcs.createActiveSkill(activeEffect, supportList, actor, socketGroup, socketGroup = socketGroup, skillData = { }, buffList = { }, - } + }, "ActiveSkill") local activeGrantedEffect = activeEffect.grantedEffect @@ -160,25 +160,337 @@ function calcs.createActiveSkill(activeEffect, supportList, actor, socketGroup, return activeSkill end --- Copy an Active Skill -function calcs.copyActiveSkill(env, mode, skill) - local activeEffect = { - grantedEffect = skill.activeEffect.grantedEffect, - level = skill.activeEffect.level, - quality = skill.activeEffect.quality +function calcs.getActiveEffectSourceValue(activeEffect, key, fallback) + if activeEffect.snapshotState and activeEffect.snapshotState[key] ~= nil then + return activeEffect.snapshotState[key] + end + if activeEffect.srcInstance and activeEffect.srcInstance[key] ~= nil then + return activeEffect.srcInstance[key] + end + if activeEffect[key] ~= nil then + return activeEffect[key] + end + return fallback +end + +function calcs.getActiveEffectSelection(activeEffect, calcKey, mainKey, useCalcSelection, fallback) + if activeEffect.snapshotState and activeEffect.snapshotState[mainKey] ~= nil then + return activeEffect.snapshotState[mainKey] + end + if activeEffect.srcInstance then + local key = useCalcSelection and calcKey or mainKey + if activeEffect.srcInstance[key] ~= nil then + return activeEffect.srcInstance[key] + end + end + return fallback +end + +local function setActiveEffectSelection(activeEffect, calcKey, mainKey, useCalcSelection, value) + if activeEffect.snapshotState then + activeEffect.snapshotState[mainKey] = value + elseif activeEffect.srcInstance then + activeEffect.srcInstance[useCalcSelection and calcKey or mainKey] = value + end + return value +end + +local function clearActiveEffectSelection(activeEffect, calcKey, mainKey) + if activeEffect.snapshotState then + activeEffect.snapshotState[mainKey] = nil + elseif activeEffect.srcInstance then + activeEffect.srcInstance[calcKey] = nil + activeEffect.srcInstance[mainKey] = nil + end +end + +local function findListIndex(list, target) + if not list or not target then + return + end + for index, value in ipairs(list) do + if value == target then + return index + end + end +end + +local function snapshotGemInstance(gemInstance) + if not gemInstance then + return nil + end + return { + gemId = gemInstance.gemId or (gemInstance.gemData and gemInstance.gemData.id), + skillId = gemInstance.skillId or (gemInstance.grantedEffect and gemInstance.grantedEffect.id) or (gemInstance.gemData and gemInstance.gemData.grantedEffectId), + level = gemInstance.level, + quality = gemInstance.quality, + qualityId = gemInstance.qualityId, + enabled = gemInstance.enabled, + } +end + +local function gemInstanceMatchesSnapshot(gemInstance, snapshot) + if not snapshot then + return gemInstance == nil + end + if not gemInstance then + return false + end + local skillId = gemInstance.skillId or (gemInstance.grantedEffect and gemInstance.grantedEffect.id) or (gemInstance.gemData and gemInstance.gemData.grantedEffectId) + local gemId = gemInstance.gemId or (gemInstance.gemData and gemInstance.gemData.id) + return (not snapshot.gemId or snapshot.gemId == gemId) + and (not snapshot.skillId or snapshot.skillId == skillId) + and snapshot.level == gemInstance.level + and snapshot.quality == gemInstance.quality + and snapshot.qualityId == gemInstance.qualityId + and snapshot.enabled == gemInstance.enabled +end + +local function snapshotSupportEffect(supportEffect) + return { + grantedEffectId = supportEffect.grantedEffect.id, + level = supportEffect.level, + quality = supportEffect.quality, + qualityId = supportEffect.qualityId, + fromItem = supportEffect.grantedEffect.fromItem or (supportEffect.srcInstance and supportEffect.srcInstance.fromItem), + gem = snapshotGemInstance(supportEffect.srcInstance), } +end + +local function supportEffectMatchesSnapshot(supportEffect, snapshot) + if not supportEffect or not snapshot then + return supportEffect == nil and snapshot == nil + end + return supportEffect.grantedEffect.id == snapshot.grantedEffectId + and supportEffect.level == snapshot.level + and supportEffect.quality == snapshot.quality + and supportEffect.qualityId == snapshot.qualityId + and (supportEffect.grantedEffect.fromItem or (supportEffect.srcInstance and supportEffect.srcInstance.fromItem)) == snapshot.fromItem + and gemInstanceMatchesSnapshot(supportEffect.srcInstance, snapshot.gem) +end + +local function supportListMatchesSnapshot(supportList, snapshotList) + if not snapshotList then + return true + end + if #supportList ~= #snapshotList then + return false + end + for index, supportSnapshot in ipairs(snapshotList) do + if not supportEffectMatchesSnapshot(supportList[index], supportSnapshot) then + return false + end + end + return true +end - if skill.activeEffect.srcInstance then - activeEffect.level = skill.activeEffect.srcInstance.level - activeEffect.quality = skill.activeEffect.srcInstance.quality - activeEffect.qualityId = skill.activeEffect.srcInstance.qualityId - activeEffect.srcInstance = skill.activeEffect.srcInstance - activeEffect.gemData = skill.activeEffect.srcInstance.gemData +local function snapshotSocketGroup(socketGroup) + if not socketGroup then + return nil + end + local snapshot = { + slot = socketGroup.slot, + label = socketGroup.label, + source = socketGroup.source, + noSupports = socketGroup.noSupports, + gems = { }, + } + for index, gemInstance in ipairs(socketGroup.gemList or { }) do + snapshot.gems[index] = snapshotGemInstance(gemInstance) end + return snapshot +end - local newSkill = calcs.createActiveSkill(activeEffect, skill.supportList, skill.actor, skill.socketGroup, skill.summonSkill) - local newEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) - calcs.buildActiveSkillModList(newEnv, newSkill) +local function socketGroupMatchesSnapshot(socketGroup, snapshot) + if not snapshot then + return socketGroup == nil + end + if not socketGroup then + return false + end + if socketGroup.slot ~= snapshot.slot or socketGroup.label ~= snapshot.label or socketGroup.source ~= snapshot.source or socketGroup.noSupports ~= snapshot.noSupports then + return false + end + if #socketGroup.gemList ~= #snapshot.gems then + return false + end + for index, gemSnapshot in ipairs(snapshot.gems) do + if not gemInstanceMatchesSnapshot(socketGroup.gemList[index], gemSnapshot) then + return false + end + end + return true +end + +local function findGrantedEffectIndex(grantedEffectList, grantedEffect) + if not grantedEffectList or not grantedEffect then + return nil + end + for index, effect in ipairs(grantedEffectList) do + if effect == grantedEffect or effect.id == grantedEffect.id then + return index + end + end +end + +-- Snapshot locators only store scalar identifiers; live actor/support/socket graphs are resolved later. +local function snapshotSkillLocator(skill) + local activeEffect = skill.activeEffect + local srcInstance = activeEffect.srcInstance + return { + grantedEffectId = activeEffect.grantedEffect.id, + level = calcs.getActiveEffectSourceValue(activeEffect, "level", activeEffect.level), + quality = calcs.getActiveEffectSourceValue(activeEffect, "quality", activeEffect.quality), + qualityId = calcs.getActiveEffectSourceValue(activeEffect, "qualityId", activeEffect.qualityId), + socketGroup = snapshotSocketGroup(skill.socketGroup), + sourceGem = { + gemIndex = skill.socketGroup and findListIndex(skill.socketGroup.gemList, srcInstance) or nil, + grantedEffectIndex = srcInstance and srcInstance.gemData and findGrantedEffectIndex(srcInstance.gemData.grantedEffectList, activeEffect.grantedEffect) or nil, + gem = snapshotGemInstance(srcInstance), + }, + supportList = (function() + local supportList = { } + for index, supportEffect in ipairs(skill.supportList or { }) do + supportList[index] = snapshotSupportEffect(supportEffect) + end + return supportList + end)(), + } +end + +local function skillMatchesSnapshot(skill, snapshot, strict) + local activeEffect = skill.activeEffect + local sourceGem = snapshot.sourceGem + if activeEffect.grantedEffect.id ~= snapshot.grantedEffectId then + return false + end + if calcs.getActiveEffectSourceValue(activeEffect, "level", activeEffect.level) ~= snapshot.level + or calcs.getActiveEffectSourceValue(activeEffect, "quality", activeEffect.quality) ~= snapshot.quality + or calcs.getActiveEffectSourceValue(activeEffect, "qualityId", activeEffect.qualityId) ~= snapshot.qualityId then + return false + end + if not socketGroupMatchesSnapshot(skill.socketGroup, snapshot.socketGroup) then + return false + end + if strict and sourceGem then + if sourceGem.gemIndex and sourceGem.gemIndex ~= (skill.socketGroup and findListIndex(skill.socketGroup.gemList, activeEffect.srcInstance) or nil) then + return false + end + if sourceGem.grantedEffectIndex and sourceGem.grantedEffectIndex ~= (activeEffect.srcInstance and activeEffect.srcInstance.gemData and findGrantedEffectIndex(activeEffect.srcInstance.gemData.grantedEffectList, activeEffect.grantedEffect) or nil) then + return false + end + if not gemInstanceMatchesSnapshot(activeEffect.srcInstance, sourceGem.gem) then + return false + end + if not supportListMatchesSnapshot(skill.supportList or { }, snapshot.supportList) then + return false + end + end + return true +end + +local function findSkillTemplate(skillList, snapshot) + for _, skill in ipairs(skillList or { }) do + if skillMatchesSnapshot(skill, snapshot, true) then + return skill + end + end + for _, skill in ipairs(skillList or { }) do + if skillMatchesSnapshot(skill, snapshot, false) then + return skill + end + end +end + +local function resolveSkillTemplate(env, snapshot, actor) + if snapshot.summonSkill then + local summonSkill = resolveSkillTemplate(env, snapshot.summonSkill, env.player) + if not summonSkill or not summonSkill.minion or not summonSkill.minion.activeSkillList then + return nil + end + if snapshot.minionSkillIndex and summonSkill.minion.activeSkillList[snapshot.minionSkillIndex] and summonSkill.minion.activeSkillList[snapshot.minionSkillIndex].activeEffect.grantedEffect.id == snapshot.grantedEffectId then + return summonSkill.minion.activeSkillList[snapshot.minionSkillIndex] + end + return findSkillTemplate(summonSkill.minion.activeSkillList, snapshot) + end + return findSkillTemplate((actor and actor.activeSkillList) or env.player.activeSkillList, snapshot) +end + +local function cloneActiveEffect(template, snapshot) + local activeEffect = { } + for key, value in pairs(template) do + if type(value) ~= "table" then + activeEffect[key] = value + end + end + activeEffect.grantedEffect = template.grantedEffect + activeEffect.srcInstance = template.srcInstance + activeEffect.gemData = template.gemData + if template.gemCfg then + activeEffect.gemCfg = copyTable(template.gemCfg, true) + end + if template.gemPropertyInfo then + activeEffect.gemPropertyInfo = copyTable(template.gemPropertyInfo, true) + end + activeEffect.level = snapshot.level or template.level + activeEffect.quality = snapshot.quality or template.quality + activeEffect.qualityId = snapshot.qualityId or template.qualityId + + local snapshotState = { + level = activeEffect.level, + quality = activeEffect.quality, + qualityId = activeEffect.qualityId, + } + for key, value in pairs(snapshot.selection or { }) do + snapshotState[key] = value + end + for key, value in pairs(snapshot.trigger or { }) do + snapshotState[key] = value + end + activeEffect.snapshotState = snapshotState + return activeEffect +end + +function calcs.snapshotActiveSkill(skill) + local srcInstance = skill.activeEffect.srcInstance + local selection = { + skillPart = skill.skillPart or (srcInstance and (srcInstance.skillPartCalcs or srcInstance.skillPart)), + skillMineCount = skill.activeMineCount or (srcInstance and (srcInstance.skillMineCountCalcs or srcInstance.skillMineCount)), + skillStageCount = (srcInstance and (srcInstance.skillStageCountCalcs or srcInstance.skillStageCount)) or (skill.activeStageCount and skill.activeStageCount + 1), + skillMinion = skill.minion and skill.minion.type or (srcInstance and (srcInstance.skillMinionCalcs or srcInstance.skillMinion)), + skillMinionItemSet = srcInstance and (srcInstance.skillMinionItemSetCalcs or srcInstance.skillMinionItemSet), + skillMinionSkill = (srcInstance and (srcInstance.skillMinionSkillCalcs or srcInstance.skillMinionSkill)) or (skill.minion and skill.minion.activeSkillList and findListIndex(skill.minion.activeSkillList, skill.minion.mainSkill)), + } + local snapshot = snapshotSkillLocator(skill) + snapshot.selection = selection + snapshot.trigger = { + triggered = calcs.getActiveEffectSourceValue(skill.activeEffect, "triggered"), + triggerChance = calcs.getActiveEffectSourceValue(skill.activeEffect, "triggerChance"), + fromItem = calcs.getActiveEffectSourceValue(skill.activeEffect, "fromItem", skill.activeEffect.grantedEffect.fromItem), + noSupports = calcs.getActiveEffectSourceValue(skill.activeEffect, "noSupports"), + } + if skill.skillFlags and skill.skillFlags.minionSkill and skill.summonSkill then + snapshot.summonSkill = snapshotSkillLocator(skill.summonSkill) + snapshot.minionSkillIndex = findListIndex(skill.summonSkill.minion and skill.summonSkill.minion.activeSkillList, skill) + end + return snapshot +end + +-- Rebuild a calc-local skill using env-local graph objects resolved from the snapshot. +function calcs.rebuildSkillFromSnapshot(env, snapshot, actor) + local template = resolveSkillTemplate(env, snapshot, actor) + if not template then + return nil + end + + local supportList = { } + for index, supportEffect in ipairs(template.supportList or { }) do + supportList[index] = supportEffect + end + + local newSkill = calcs.createActiveSkill(cloneActiveEffect(template.activeEffect, snapshot), supportList, template.actor, template.socketGroup, template.summonSkill) + newSkill.slotName = template.slotName + calcs.buildActiveSkillModList(env, newSkill) newSkill.skillModList = new("ModList", newSkill.baseSkillModList) if newSkill.minion then newSkill.minion.modDB = new("ModDB") @@ -186,6 +498,14 @@ function calcs.copyActiveSkill(env, mode, skill) calcs.createMinionSkills(env, newSkill) newSkill.skillPartName = newSkill.minion.mainSkill.activeEffect.grantedEffect.name end + return newSkill +end + +-- Deprecated: use snapshotActiveSkill()/rebuildSkillFromSnapshot() for calc-local skill clones. +function calcs.copyActiveSkill(env, mode, skill) + local snapshot = calcs.snapshotActiveSkill(skill) + local newEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) + local newSkill = calcs.rebuildSkillFromSnapshot(newEnv, snapshot, newEnv.player) return newSkill, newEnv end @@ -245,13 +565,8 @@ function calcs.buildActiveSkillModList(env, activeSkill) -- Handle multipart skills local activeGemParts = activeGrantedEffect.parts if activeGemParts and #activeGemParts > 1 then - if env.mode == "CALCS" and activeSkill == env.player.mainSkill then - activeEffect.srcInstance.skillPartCalcs = m_min(#activeGemParts, activeEffect.srcInstance.skillPartCalcs or 1) - activeSkill.skillPart = activeEffect.srcInstance.skillPartCalcs - else - activeEffect.srcInstance.skillPart = m_min(#activeGemParts, activeEffect.srcInstance.skillPart or 1) - activeSkill.skillPart = activeEffect.srcInstance.skillPart - end + local useCalcSelection = env.mode == "CALCS" and activeSkill == env.player.mainSkill + activeSkill.skillPart = setActiveEffectSelection(activeEffect, "skillPartCalcs", "skillPart", useCalcSelection, m_min(#activeGemParts, calcs.getActiveEffectSelection(activeEffect, "skillPartCalcs", "skillPart", useCalcSelection, 1) or 1)) local part = activeGemParts[activeSkill.skillPart] for k, v in pairs(part) do if v == true then @@ -263,8 +578,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) activeSkill.skillPartName = part.name skillFlags.multiPart = #activeGemParts > 1 elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillPart = nil - activeEffect.srcInstance.skillPartCalcs = nil + clearActiveEffectSelection(activeEffect, "skillPartCalcs", "skillPart") end if (skillTypes[SkillType.RequiresShield] or skillFlags.shieldAttack) and not activeSkill.summonSkill and (not activeSkill.actor.itemList["Weapon 2"] or activeSkill.actor.itemList["Weapon 2"].type ~= "Shield") then @@ -483,7 +797,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) end -- Mods which apply curses are not disabled by Gruthkul's Pelt - local curseApplicationSkill = activeSkill.socketGroup and activeSkill.socketGroup.sourceItem ~= nil and activeSkill.skillFlags.curse and activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.noSupports and activeSkill.activeEffect.srcInstance.triggered + local curseApplicationSkill = activeSkill.socketGroup and activeSkill.socketGroup.sourceItem ~= nil and activeSkill.skillFlags.curse and calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "noSupports") and calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "triggered") if skillModList:Flag(activeSkill.skillCfg, "DisableSkill") and not (skillModList:Flag(activeSkill.skillCfg, "EnableSkill") or (curseApplicationSkill and skillModList:Flag(nil, "ForceEnableCurseApplication"))) then skillFlags.disable = true activeSkill.disableReason = "Skills of this type are disabled" @@ -531,8 +845,8 @@ function calcs.buildActiveSkillModList(env, activeSkill) end -- Apply gem/quality modifiers from support gems - skillModList:NewMod("GemLevel", "BASE", activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.level or activeSkill.activeEffect.level, "Max Level") - skillModList:NewMod("GemQuality", "BASE", activeSkill.activeEffect.srcInstance and activeSkill.activeEffect.srcInstance.quality or activeSkill.activeEffect.quality, "Max Quality") + skillModList:NewMod("GemLevel", "BASE", calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "level", activeSkill.activeEffect.level), "Max Level") + skillModList:NewMod("GemQuality", "BASE", calcs.getActiveEffectSourceValue(activeSkill.activeEffect, "quality", activeSkill.activeEffect.quality), "Max Quality") for _, supportProperty in ipairs(skillModList:Tabulate("LIST", activeSkill.skillCfg, "SupportedGemProperty")) do local value = supportProperty.value if value.keyword == "grants_active_skill" and activeSkill.activeEffect.gemData and not activeSkill.activeEffect.gemData.tags.support then @@ -593,14 +907,13 @@ function calcs.buildActiveSkillModList(env, activeSkill) -- Add active mine multiplier if skillFlags.mine then - activeSkill.activeMineCount = (env.mode == "CALCS" and activeEffect.srcInstance.skillMineCountCalcs) or (env.mode ~= "CALCS" and activeEffect.srcInstance.skillMineCount) + activeSkill.activeMineCount = calcs.getActiveEffectSelection(activeEffect, "skillMineCountCalcs", "skillMineCount", env.mode == "CALCS") if activeSkill.activeMineCount and activeSkill.activeMineCount > 0 then skillModList:NewMod("Multiplier:ActiveMineCount", "BASE", activeSkill.activeMineCount, "Base") env.enemy.modDB.multipliers["ActiveMineCount"] = m_max(activeSkill.activeMineCount or 0, env.enemy.modDB.multipliers["ActiveMineCount"] or 0) end elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillMineCountCalcs = nil - activeEffect.srcInstance.skillMineCount = nil + clearActiveEffectSelection(activeEffect, "skillMineCountCalcs", "skillMineCount") end @@ -617,7 +930,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) if skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MaxStages") > 0 then skillFlags.multiStage = true - activeSkill.activeStageCount = m_max((env.mode == "CALCS" and activeEffect.srcInstance.skillStageCountCalcs) or (env.mode ~= "CALCS" and activeEffect.srcInstance.skillStageCount) or 1, 1 + skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MinimumStage")) + activeSkill.activeStageCount = m_max(calcs.getActiveEffectSelection(activeEffect, "skillStageCountCalcs", "skillStageCount", env.mode == "CALCS", 1) or 1, 1 + skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MinimumStage")) local limit = skillModList:Sum("BASE", activeSkill.skillCfg, "Multiplier:"..activeGrantedEffect.name:gsub("%s+", "").."MaxStages") if limit > 0 then if activeSkill.activeStageCount and activeSkill.activeStageCount > 0 then @@ -627,8 +940,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) end end elseif noPotentialStage and activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillStageCountCalcs = nil - activeEffect.srcInstance.skillStageCount = nil + clearActiveEffectSelection(activeEffect, "skillStageCountCalcs", "skillStageCount") end -- Extract skill data @@ -665,15 +977,10 @@ function calcs.buildActiveSkillModList(env, activeSkill) activeSkill.minionList = minionList if minionList[1] and not activeSkill.actor.minionData then local minionType - if env.mode == "CALCS" and activeSkill == env.player.mainSkill then - local index = isValueInArray(minionList, activeEffect.srcInstance.skillMinionCalcs) or 1 - minionType = minionList[index] - activeEffect.srcInstance.skillMinionCalcs = minionType - else - local index = isValueInArray(minionList, activeEffect.srcInstance.skillMinion) or 1 - minionType = minionList[index] - activeEffect.srcInstance.skillMinion = minionType - end + local useCalcSelection = env.mode == "CALCS" and activeSkill == env.player.mainSkill + local index = isValueInArray(minionList, calcs.getActiveEffectSelection(activeEffect, "skillMinionCalcs", "skillMinion", useCalcSelection)) or 1 + minionType = minionList[index] + setActiveEffectSelection(activeEffect, "skillMinionCalcs", "skillMinion", useCalcSelection, minionType) if minionType then local minion = { } activeSkill.minion = minion @@ -707,20 +1014,15 @@ function calcs.buildActiveSkillModList(env, activeSkill) damage = damage * attackTime end if activeGrantedEffect.minionHasItemSet then - if env.mode == "CALCS" and activeSkill == env.player.mainSkill then - if not env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSetCalcs] then - activeEffect.srcInstance.skillMinionItemSetCalcs = env.build.itemsTab.itemSetOrderList[1] - end - minion.itemSet = env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSetCalcs] - else - if not env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSet] then - activeEffect.srcInstance.skillMinionItemSet = env.build.itemsTab.itemSetOrderList[1] - end - minion.itemSet = env.build.itemsTab.itemSets[activeEffect.srcInstance.skillMinionItemSet] + local useCalcSelection = env.mode == "CALCS" and activeSkill == env.player.mainSkill + local itemSetId = calcs.getActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet", useCalcSelection) + if not env.build.itemsTab.itemSets[itemSetId] then + itemSetId = env.build.itemsTab.itemSetOrderList[1] + setActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet", useCalcSelection, itemSetId) end + minion.itemSet = env.build.itemsTab.itemSets[itemSetId] elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillMinionItemSetCalcs = nil - activeEffect.srcInstance.skillMinionItemSet = nil + clearActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet") end if (activeSkill.skillData.minionUseBowAndQuiver and env.player.weaponData1.type == "Bow") or activeSkill.skillData.minionUseMainHandWeapon then minion.weaponData1 = env.player.weaponData1 @@ -761,12 +1063,9 @@ function calcs.buildActiveSkillModList(env, activeSkill) end end elseif activeEffect.srcInstance and not (activeEffect.gemData and activeEffect.gemData.secondaryGrantedEffect) then - activeEffect.srcInstance.skillMinionCalcs = nil - activeEffect.srcInstance.skillMinion = nil - activeEffect.srcInstance.skillMinionItemSetCalcs = nil - activeEffect.srcInstance.skillMinionItemSet = nil - activeEffect.srcInstance.skillMinionSkill = nil - activeEffect.srcInstance.skillMinionSkillCalcs = nil + clearActiveEffectSelection(activeEffect, "skillMinionCalcs", "skillMinion") + clearActiveEffectSelection(activeEffect, "skillMinionItemSetCalcs", "skillMinionItemSet") + clearActiveEffectSelection(activeEffect, "skillMinionSkillCalcs", "skillMinionSkill") end -- Separate global effect modifiers (mods that can affect defensive stats or other skills) @@ -889,14 +1188,8 @@ function calcs.createMinionSkills(env, activeSkill) t_insert(minion.activeSkillList, minionSkill) end local skillIndex - if env.mode == "CALCS" then - skillIndex = m_max(m_min(activeEffect.srcInstance.skillMinionSkillCalcs or 1, #minion.activeSkillList), 1) - activeEffect.srcInstance.skillMinionSkillCalcs = skillIndex - else - skillIndex = m_max(m_min(activeEffect.srcInstance.skillMinionSkill or 1, #minion.activeSkillList), 1) - if env.mode == "MAIN" then - activeEffect.srcInstance.skillMinionSkill = skillIndex - end - end + local useCalcSelection = env.mode == "CALCS" + skillIndex = m_max(m_min(calcs.getActiveEffectSelection(activeEffect, "skillMinionSkillCalcs", "skillMinionSkill", useCalcSelection, 1) or 1, #minion.activeSkillList), 1) + setActiveEffectSelection(activeEffect, "skillMinionSkillCalcs", "skillMinionSkill", useCalcSelection, skillIndex) minion.mainSkill = minion.activeSkillList[skillIndex] end diff --git a/src/Modules/CalcMirages.lua b/src/Modules/CalcMirages.lua index 9d14cf1427..b9f4af955d 100644 --- a/src/Modules/CalcMirages.lua +++ b/src/Modules/CalcMirages.lua @@ -35,7 +35,13 @@ local function calculateMirage(env, config) end if mirageSkill then - local newSkill, newEnv = calcs.copyActiveSkill(env, "CALCULATOR", mirageSkill) + local snapshot = calcs.snapshotActiveSkill(mirageSkill) + local newEnv, _, _, _ = calcs.initEnv(env.build, "CALCULATOR", env.override) + local newSkill = calcs.rebuildSkillFromSnapshot(newEnv, snapshot, newEnv.player) + if not newSkill then + config.mirageSkillNotFoundFunc(env, config) + return not config.calcMainSkillOffence + end newSkill.skillCfg.skillCond["usedByMirage"] = true newEnv.limitedSkills = newEnv.limitedSkills or {} newEnv.limitedSkills[cacheSkillUUID(newSkill, newEnv)] = true @@ -434,4 +440,4 @@ function calcs.mirages(env) end return calculateMirage(env, config) -end \ No newline at end of file +end diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index c16576e470..1395400c03 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -533,7 +533,7 @@ function doActorLifeManaReservation(actor, addAura) end if addAura then for _, value in ipairs(modDB:List(nil, "GrantReserved"..pool.."AsAura")) do - local auraMod = copyTable(value.mod) + local auraMod = type(value.mod.value) == "table" and copyTableSafe(value.mod, false) or copyTable(value.mod) auraMod.value = m_floor(auraMod.value * m_min(reserved, max)) modDB:NewMod("ExtraAura", "LIST", { mod = auraMod }) end @@ -2301,7 +2301,7 @@ function calcs.perform(env, skipEHP) for _, modList in ipairs(lists) do for _, mod in ipairs(modList) do if mod.name == "EnergyShield" or mod.name == "Armour" or mod.name == "Evasion" or mod.name:match("Resist?M?a?x?$") then - local totemMod = copyTable(mod) + local totemMod = type(mod.value) == "table" and copyTableSafe(mod, false) or copyTable(mod) totemMod.name = "Totem"..totemMod.name if scale ~= 1 then if type(totemMod.value) == "number" then @@ -2679,7 +2679,7 @@ function calcs.perform(env, skipEHP) for _, modList in ipairs(lists) do for _, mod in ipairs(modList) do if mod.name == "EnergyShield" or mod.name == "Armour" or mod.name == "Evasion" or mod.name:match("Resist?M?a?x?$") then - local totemMod = copyTable(mod) + local totemMod = type(mod.value) == "table" and copyTableSafe(mod, false) or copyTable(mod) totemMod.name = "Totem"..totemMod.name if scale ~= 1 then if type(totemMod.value) == "number" then @@ -3181,7 +3181,7 @@ function calcs.perform(env, skipEHP) buffExports["Aura"]["extraAura"].modList:AddMod(value.mod) local totemModBlacklist = value.mod.name and (value.mod.name == "Speed" or value.mod.name == "CritMultiplier" or value.mod.name == "CritChance") if env.player.mainSkill.skillFlags.totem and not totemModBlacklist then - local totemMod = copyTable(value.mod) + local totemMod = type(value.mod.value) == "table" and copyTableSafe(value.mod, false) or copyTable(value.mod) local totemModName, matches = totemMod.name:gsub("Condition:", "Condition:Totem") if matches < 1 then totemModName = "Totem" .. totemMod.name @@ -3477,7 +3477,7 @@ function calcs.perform(env, skipEHP) -- preStack Mine auras for auraName, aura in pairs(buffExports["Aura"]) do if auraName:match("Mine") and not auraName:match(" Limit") then - buffExports["Aura"][auraName] = copyTable(buffExports["Aura"][auraName]) + buffExports["Aura"][auraName] = copyTableSafe(buffExports["Aura"][auraName], false) aura = buffExports["Aura"][auraName] local stackCount = buffExports["EnemyMods"]["Multiplier:"..auraName.."Stack"] and buffExports["EnemyMods"]["Multiplier:"..auraName.."Stack"].value or 0 buffExports["EnemyMods"]["Multiplier:"..auraName.."Stack"] = nil @@ -3495,7 +3495,7 @@ function calcs.perform(env, skipEHP) end end if buffExports["Aura"][auraName.." Limit"] then - buffExports["Aura"][auraName.." Limit"] = copyTable(buffExports["Aura"][auraName.." Limit"]) + buffExports["Aura"][auraName.." Limit"] = copyTableSafe(buffExports["Aura"][auraName.." Limit"], false) aura = buffExports["Aura"][auraName.." Limit"] if stackCount == 0 then buffExports["Aura"][auraName.." Limit"] = nil diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index 5340fcc664..d1855d7983 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -31,14 +31,14 @@ end local function slotMatch(env, skill) local fromItem = (env.player.mainSkill.activeEffect.grantedEffect.fromItem or skill.activeEffect.grantedEffect.fromItem) - fromItem = fromItem or (env.player.mainSkill.activeEffect.srcInstance and env.player.mainSkill.activeEffect.srcInstance.fromItem) or (skill.activeEffect.srcInstance and skill.activeEffect.srcInstance.fromItem) + fromItem = fromItem or calcs.getActiveEffectSourceValue(env.player.mainSkill.activeEffect, "fromItem") or calcs.getActiveEffectSourceValue(skill.activeEffect, "fromItem") local match1 = fromItem and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot - local match2 = (not env.player.mainSkill.activeEffect.grantedEffect.fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup + local match2 = (not fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup return (match1 or match2) end function isTriggered(skill) - return skill.skillData.triggeredByUnique or skill.skillData.triggered or skill.skillTypes[SkillType.InbuiltTrigger] or skill.skillTypes[SkillType.Triggered] or skill.activeEffect.grantedEffect.triggered or (skill.activeEffect.srcInstance and skill.activeEffect.srcInstance.triggered) + return skill.skillData.triggeredByUnique or skill.skillData.triggered or skill.skillTypes[SkillType.InbuiltTrigger] or skill.skillTypes[SkillType.Triggered] or skill.activeEffect.grantedEffect.triggered or calcs.getActiveEffectSourceValue(skill.activeEffect, "triggered") end local function processAddedCastTime(skill, breakdown) @@ -224,9 +224,10 @@ local function CWCHandler(env) local triggerName = "Cast While Channeling" local output = env.player.output local breakdown = env.player.breakdown + local fromItem = calcs.getActiveEffectSourceValue(env.player.mainSkill.activeEffect, "fromItem", env.player.mainSkill.activeEffect.grantedEffect.fromItem) for _, skill in ipairs(env.player.activeSkillList) do - local match1 = env.player.mainSkill.activeEffect.grantedEffect.fromItem and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot - local match2 = (not env.player.mainSkill.activeEffect.grantedEffect.fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup + local match1 = fromItem and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot + local match2 = (not fromItem) and skill.socketGroup == env.player.mainSkill.socketGroup if env.player.mainSkill.triggeredBy.gemData and calcLib.canGrantedEffectSupportActiveSkill(env.player.mainSkill.triggeredBy.gemData.grantedEffect, skill) and skill ~= env.player.mainSkill and (match1 or match2) and not isTriggered(skill) then source, trigRate = findTriggerSkill(env, skill, source, trigRate) end @@ -785,7 +786,8 @@ local function defaultTriggerHandler(env, config) -- If the current triggered skill ignores tick rate and is the only triggered skill by this trigger use charge based calcs if actor.mainSkill.skillData.ignoresTickRate and ( not config.triggeredSkillCond or (triggeredSkills and #triggeredSkills == 1 and triggeredSkills[1] == packageSkillDataForSimulation(actor.mainSkill, env)) ) then - local overlaps = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or config.overlaps + local stageCount = calcs.getActiveEffectSelection(env.player.mainSkill.activeEffect, "skillStageCountCalcs", "skillStageCount", false, env.player.mainSkill.activeStageCount and env.player.mainSkill.activeStageCount + 1) + local overlaps = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and stageCount or config.overlaps output.SkillTriggerRate = m_min(output.TriggerRateCap, output.EffectiveSourceRate * (overlaps or 1)) if breakdown then if overlaps then @@ -808,7 +810,8 @@ local function defaultTriggerHandler(env, config) end -- stagesAreOverlaps is the skill part which makes the stages behave as overlaps - local hits_per_cast = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and env.player.mainSkill.activeEffect.srcInstance.skillStageCount or 1 + local stageCount = calcs.getActiveEffectSelection(env.player.mainSkill.activeEffect, "skillStageCountCalcs", "skillStageCount", false, env.player.mainSkill.activeStageCount and env.player.mainSkill.activeStageCount + 1) + local hits_per_cast = config.stagesAreOverlaps and env.player.mainSkill.skillPart == config.stagesAreOverlaps and stageCount or 1 output.SkillTriggerRate = hits_per_cast * output.SkillTriggerRate if breakdown then breakdown.SkillTriggerRate = { @@ -1563,7 +1566,7 @@ function calcs.triggers(env, actor) if config then config.actor = config.actor or actor config.triggerName = config.triggerName or triggerName or skillName or uniqueName - config.triggerChance = config.triggerChance or (actor.mainSkill.activeEffect.srcInstance and actor.mainSkill.activeEffect.srcInstance.triggerChance) + config.triggerChance = config.triggerChance or calcs.getActiveEffectSourceValue(actor.mainSkill.activeEffect, "triggerChance") local triggerHandler = config.customHandler or defaultTriggerHandler triggerHandler(env, config) else diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index ac32512910..49aa19ae3d 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -20,6 +20,7 @@ local b_and = bit.band local b_xor = bit.bxor common = { } +local graphNodeTagKey = "__pobGraphNodeTag" -- External libraries common.curl = require("lcurl.safe") @@ -404,20 +405,39 @@ function writeLuaTable(out, t, indent) out:write('}') end --- Make a copy of a table and all subtables -function copyTable(tbl, noRecurse) +function graphNodeTag(obj, label) + if launch.devMode and type(obj) == "table" then + rawset(obj, graphNodeTagKey, rawget(obj, graphNodeTagKey) or label or true) + end + return obj +end + +local function copyTableInternal(tbl, noRecurse) local out = {} for k, v in pairs(tbl) do if not noRecurse and type(v) == "table" then - out[k] = copyTable(v) + out[k] = copyTableInternal(v) else out[k] = v end end return out end + +-- copyTable() is for plain acyclic data only. Graph objects such as env, activeSkill, +-- ModDB, ModList, and cache entries must use copyTableSafe() or targeted/manual cloning. +function copyTable(tbl, noRecurse) + if launch.devMode then + local graphNodeLabel = rawget(tbl, graphNodeTagKey) + if graphNodeLabel then + error("copyTable() cannot clone graph object '"..tostring(graphNodeLabel).."'; use copyTableSafe() or a targeted clone") + end + end + return copyTableInternal(tbl, noRecurse) +end do local subTableMap = { } + -- copyTableSafe() is reserved for graph-risk call sites such as shared mod payloads. function copyTableSafe(tbl, noRecurse, preserveMeta, isSubTable) local out = {} if not noRecurse then @@ -784,7 +804,9 @@ end -- Global Cache related function cacheData(uuid, env) - GlobalCache.cachedData[env.mode][uuid] = { + -- Cache entries intentionally retain live env/skill graph links for follow-up reads. + graphNodeTag(env, "Env") + GlobalCache.cachedData[env.mode][uuid] = graphNodeTag({ Name = env.player.mainSkill.activeEffect.grantedEffect.name, Speed = env.player.output.Speed, HitSpeed = env.player.output.HitSpeed, @@ -799,7 +821,7 @@ function cacheData(uuid, env) TotalDPS = env.player.output.TotalDPS, ActiveSkill = env.player.mainSkill, Env = env, - } + }, "SkillCacheEntry") end -- Wipe all the tables associated with Global Cache From dd77ebaf843468f80e087932b28c2721419f0d5f Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 14:06:11 -0400 Subject: [PATCH 03/12] Finish cache-safe modifier handling and FullDPS reuse --- src/Classes/Item.lua | 2 +- src/Classes/PassiveSpec.lua | 2 +- src/Classes/PassiveTree.lua | 2 +- src/Data/Skills/sup_str.lua | 6 +- src/Export/Skills/sup_str.txt | 6 +- src/Modules/CalcActiveSkill.lua | 2 +- src/Modules/CalcMirages.lua | 42 ++--- src/Modules/CalcPerform.lua | 21 ++- src/Modules/CalcSetup.lua | 12 ++ src/Modules/CalcTriggers.lua | 128 +++++++++------- src/Modules/Calcs.lua | 262 ++++++++++++++++++-------------- src/Modules/Common.lua | 135 +++++++++++++--- src/Modules/ConfigOptions.lua | 2 +- src/Modules/ModTools.lua | 4 +- 14 files changed, 390 insertions(+), 236 deletions(-) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index ae3c0acd93..ddd9d96e36 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -1714,7 +1714,7 @@ function ItemClass:BuildModList() end end for _, mod in ipairs(modLine.modList) do - mod = modLib.setSource(mod, self.modSource) + mod = modLib.withSource(mod, self.modSource) baseList:AddMod(mod) end if modLine.modTags and #modLine.modTags > 0 then diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index c67b7ac27e..b1fe867429 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -2162,7 +2162,7 @@ function PassiveSpecClass:NodeAdditionOrReplacementFromString(node,sd,replacemen for _, mod in pairs(addition.mods) do if mod.list and not mod.extra then for i, mod in ipairs(mod.list) do - mod = modLib.setSource(mod, "Tree:"..node.id) + mod = modLib.withSource(mod, "Tree:"..node.id) addition.modList:AddMod(mod) end end diff --git a/src/Classes/PassiveTree.lua b/src/Classes/PassiveTree.lua index 6ea7da09a0..110cab50c9 100644 --- a/src/Classes/PassiveTree.lua +++ b/src/Classes/PassiveTree.lua @@ -791,7 +791,7 @@ function PassiveTreeClass:ProcessStats(node, startIndex) local mod = node.mods[i] if mod.list and not mod.extra then for i, mod in ipairs(mod.list) do - mod = modLib.setSource(mod, "Tree:"..node.id) + mod = modLib.withSource(mod, "Tree:"..node.id) node.modList:AddMod(mod) end end diff --git a/src/Data/Skills/sup_str.lua b/src/Data/Skills/sup_str.lua index 604798a2d0..2d4f1ed959 100644 --- a/src/Data/Skills/sup_str.lua +++ b/src/Data/Skills/sup_str.lua @@ -2493,8 +2493,8 @@ skills["AvengingFlame"] = { castTime = 1, preDamageFunc = function(activeSkill, output) local uuid = activeSkill.skillData.triggerSourceUUID - local cache = uuid and (GlobalCache.cachedData["MAIN"][uuid] or GlobalCache.cachedData["CALCS"][uuid]) - local totemLife = cache and cache.Env.player.output.TotemLife or 0 + local cache = uuid and (getGlobalCacheTable("MAIN")[uuid] or getGlobalCacheTable("CALCS")[uuid]) + local totemLife = cache and cache.Output.TotemLife or 0 local add = totemLife * activeSkill.skillData.lifeDealtAsFire / 100 activeSkill.skillData.FireMax = (activeSkill.skillData.FireMax or 0) + add @@ -5575,4 +5575,4 @@ skills["SupportExpertRetaliation"] = { [39] = { 107, 53, levelRequirement = 99, manaMultiplier = 30, statInterpolation = { 1, 1, }, }, [40] = { 108, 54, levelRequirement = 100, manaMultiplier = 30, statInterpolation = { 1, 1, }, }, }, -} \ No newline at end of file +} diff --git a/src/Export/Skills/sup_str.txt b/src/Export/Skills/sup_str.txt index 5bbb9a7b49..7ed4f52ca5 100644 --- a/src/Export/Skills/sup_str.txt +++ b/src/Export/Skills/sup_str.txt @@ -338,8 +338,8 @@ local skills, mod, flag, skill = ... #skill AvengingFlame preDamageFunc = function(activeSkill, output) local uuid = activeSkill.skillData.triggerSourceUUID - local cache = uuid and (GlobalCache.cachedData["MAIN"][uuid] or GlobalCache.cachedData["CALCS"][uuid]) - local totemLife = cache and cache.Env.player.output.TotemLife or 0 + local cache = uuid and (getGlobalCacheTable("MAIN")[uuid] or getGlobalCacheTable("CALCS")[uuid]) + local totemLife = cache and cache.Output.TotemLife or 0 local add = totemLife * activeSkill.skillData.lifeDealtAsFire / 100 activeSkill.skillData.FireMax = (activeSkill.skillData.FireMax or 0) + add @@ -796,4 +796,4 @@ local skills, mod, flag, skill = ... mod("CooldownRecovery", "MORE", nil), }, }, -#mods \ No newline at end of file +#mods diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index 6c2614db54..063b011a69 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -1119,7 +1119,7 @@ function calcs.buildActiveSkillModList(env, activeSkill) for d = 1, #modList do local destMod = modList[d] if modLib.compareModParams(skillModList[i], destMod) and (destMod.type == "BASE" or destMod.type == "INC") then - destMod = copyTable(destMod) + destMod = type(destMod.value) == "table" and copyTableSafe(destMod, false) or copyTable(destMod) destMod.value = destMod.value + skillModList[i].value modList[d] = destMod match = true diff --git a/src/Modules/CalcMirages.lua b/src/Modules/CalcMirages.lua index b9f4af955d..1c4365cfdc 100644 --- a/src/Modules/CalcMirages.lua +++ b/src/Modules/CalcMirages.lua @@ -36,7 +36,7 @@ local function calculateMirage(env, config) if mirageSkill then local snapshot = calcs.snapshotActiveSkill(mirageSkill) - local newEnv, _, _, _ = calcs.initEnv(env.build, "CALCULATOR", env.override) + local newEnv, _, _, _ = calcs.initEnv(env.build, "CALCULATOR", env.override, { cacheGeneration = env.cacheGeneration, recursionGuards = env.recursionGuards }) local newSkill = calcs.rebuildSkillFromSnapshot(newEnv, snapshot, newEnv.player) if not newSkill then config.mirageSkillNotFoundFunc(env, config) @@ -125,18 +125,14 @@ function calcs.mirages(env) config = { compareFunc = function(skill, env, config, mirageSkill) if skill ~= env.player.mainSkill and skill.skillTypes[SkillType.Attack] and not skill.skillTypes[SkillType.Totem] and not skill.skillTypes[SkillType.SummonsTotem] and band(skill.skillCfg.flags, bor(ModFlag.Sword, ModFlag.Weapon1H)) == bor(ModFlag.Sword, ModFlag.Weapon1H) and not skill.skillCfg.skillCond["usedByMirage"] then - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end - - if GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].CritChance and GlobalCache.cachedData[env.mode][uuid].CritChance > 0 then + local entry = calcs.getCachedSkillEntry(env, skill) + if entry and entry.CritChance and entry.CritChance > 0 then if not mirageSkill then - usedSkillBestDps = GlobalCache.cachedData[env.mode][uuid].TotalDPS - return GlobalCache.cachedData[env.mode][uuid].ActiveSkill - elseif GlobalCache.cachedData[env.mode][uuid].TotalDPS > usedSkillBestDps then - usedSkillBestDps = GlobalCache.cachedData[env.mode][uuid].TotalDPS - return GlobalCache.cachedData[env.mode][uuid].ActiveSkill + usedSkillBestDps = entry.TotalDPS + return skill + elseif entry.TotalDPS > usedSkillBestDps then + usedSkillBestDps = entry.TotalDPS + return skill end end end @@ -198,15 +194,11 @@ function calcs.mirages(env) local skillTypeMatch = (skill.skillTypes[SkillType.Slam] or skill.skillTypes[SkillType.Melee]) and skill.skillTypes[SkillType.Attack] local skillTypeExcludes = skill.skillTypes[SkillType.Vaal] or skill.skillTypes[SkillType.Totem] or skill.skillTypes[SkillType.SummonsTotem] if skill ~= env.player.mainSkill and not isTriggered(skill) and not isDisabled and skillTypeMatch and not skillTypeExcludes and not skill.skillCfg.skillCond["usedByMirage"] then - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end - - if not mirageSkill or (GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].TotalDPS > usedSkillBestDps) then - usedSkillBestDps = GlobalCache.cachedData[env.mode][uuid].TotalDPS - EffectiveSourceRate = GlobalCache.cachedData[env.mode][uuid].Speed - return GlobalCache.cachedData[env.mode][uuid].ActiveSkill + local entry = calcs.getCachedSkillEntry(env, skill) + if entry and (not mirageSkill or entry.TotalDPS > usedSkillBestDps) then + usedSkillBestDps = entry.TotalDPS + EffectiveSourceRate = entry.Speed + return skill end end return mirageSkill @@ -373,10 +365,8 @@ function calcs.mirages(env) env.player.mainSkill.skillCfg.skillCond["usedByMirage"] = true env.player.mainSkill.skillTypes[SkillType.OtherThingUsesSkill] = true - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, env.player.mainSkill, uuid, {uuid}) - end - local mainSkillOutputCache = GlobalCache.cachedData[env.mode][uuid].Env.player.output + local mainSkillCacheEntry = calcs.getCachedSkillEntry(env, env.player.mainSkill, {uuid}) + local mainSkillOutputCache = mainSkillCacheEntry and mainSkillCacheEntry.Output or {} -- Find the active General's Cry gem to get active properties for _, skill in ipairs(env.player.activeSkillList) do @@ -399,7 +389,7 @@ function calcs.mirages(env) if env.player.mainSkill.skillTypes[SkillType.Channel] then mirageSpawnTime = mirageSpawnTime + 1 else - mirageSpawnTime = mirageSpawnTime + (mainSkillOutputCache.HitTime or mainSkillOutputCache.Time) + mirageSpawnTime = mirageSpawnTime + (mainSkillOutputCache.HitTime or mainSkillOutputCache.Time or 0) env.player.mainSkill.skillData.timeOverride = 1 end diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 1395400c03..aa7bb5afc5 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -28,14 +28,11 @@ local bnot = bit.bnot --- @param ... table keys to values to be returned (Note: EmmyLua does not natively support documenting variadic parameters) --- @return table unpacked table containing the desired values local function getCachedOutputValue(env, activeSkill, ...) - local uuid = cacheSkillUUID(activeSkill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, activeSkill, uuid, {uuid}) - end + local entry = calcs.getCachedSkillEntry(env, activeSkill, { cacheSkillUUID(activeSkill, env) }) local tempValues = {} for i,v in ipairs({...}) do - tempValues[i] = GlobalCache.cachedData[env.mode][uuid].Env.player.output[v] + tempValues[i] = entry and entry.Output[v] or 0 end return unpack(tempValues) end @@ -1726,7 +1723,7 @@ function calcs.perform(env, skipEHP) for key, buffModList in pairs(tinctureBuffs) do for _, buff in ipairs(buffModList) do if band(buff.flags, ModFlag.WeaponMelee) == ModFlag.WeaponMelee then - newMod = copyTable(buff, true) + newMod = type(buff.value) == "table" and copyTableSafe(buff, false) or copyTable(buff, true) newMod.flags = bor(band(newMod.flags, bnot(ModFlag.WeaponMelee)), ModFlag.WeaponRanged) modDB:AddList({newMod}) end @@ -2226,7 +2223,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end if not activeSkill.skillData.auraCannotAffectSelf then @@ -2338,7 +2335,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end local inc = skillModList:Sum("INC", skillCfg, "AuraEffect", "BuffEffect", "DebuffEffect") @@ -2384,7 +2381,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end mult = 0 @@ -2492,7 +2489,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraLinkModList, copyTable(value.mod, true)) + t_insert(extraLinkModList, copyTableSafe(value.mod, false)) -- special handling to add this early if value.mod.name == "ParentNonUniqueFlasksAppliedToYou" then nonUniqueFlasksApplyToMinion = true @@ -2616,7 +2613,7 @@ function calcs.perform(env, skipEHP) end end if add then - t_insert(extraAuraModList, copyTable(value.mod, true)) + t_insert(extraAuraModList, copyTableSafe(value.mod, false)) end end if not (activeSkill.minion.modDB:Flag(nil, "SelfAurasCannotAffectAllies") or activeSkill.minion.modDB:Flag(nil, "SelfAurasOnlyAffectYou") or activeSkill.minion.modDB:Flag(nil, "SelfAuraSkillsCannotAffectAllies") or skillModList:Flag(skillCfg, "SelfAurasAffectYouAndLinkedTarget")) then @@ -3592,5 +3589,5 @@ function calcs.perform(env, skipEHP) end end - cacheData(cacheSkillUUID(env.player.mainSkill, env), env) + cacheData(env.requestedSkillUUID or cacheSkillUUID(env.player.mainSkill, env), env) end diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index cf904dd4e9..6d074f149d 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -360,6 +360,8 @@ function calcs.initEnv(build, mode, override, specEnv) local cachedMinionDB = specEnv and specEnv.cachedMinionDB or nil local env = specEnv and specEnv.env or nil local accelerate = specEnv and specEnv.accelerate or { } + local cacheGeneration = specEnv and specEnv.cacheGeneration or nil + local recursionGuards = specEnv and specEnv.recursionGuards or nil -- environment variables local override = override or { } @@ -375,9 +377,14 @@ function calcs.initEnv(build, mode, override, specEnv) env.configPlaceholder = build.configTab.placeholder env.calcsInput = build.calcsTab.input env.mode = mode + env.cacheGeneration = cacheGeneration env.spec = override.spec or build.spec env.override = override env.classId = env.spec.curClassId + env.recursionGuards = recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } modDB = new("ModDB") env.modDB = modDB @@ -436,6 +443,11 @@ function calcs.initEnv(build, mode, override, specEnv) wipeEnv(env, accelerate) modDB = env.modDB enemyDB = env.enemyDB + env.cacheGeneration = cacheGeneration or env.cacheGeneration + env.recursionGuards = recursionGuards or env.recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } end -- Set buff mode diff --git a/src/Modules/CalcTriggers.lua b/src/Modules/CalcTriggers.lua index d1855d7983..898de1aecc 100644 --- a/src/Modules/CalcTriggers.lua +++ b/src/Modules/CalcTriggers.lua @@ -66,7 +66,8 @@ local function packageSkillDataForSimulation(skill, env) end local function defaultComparer(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed + local entry = getGlobalCacheTable(env)[uuid] + local cachedSpeed = entry and (entry.HitSpeed or entry.Speed) return (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) end @@ -74,13 +75,10 @@ end local function findTriggerSkill(env, skill, source, triggerRate, comparer) local comparer = comparer or defaultComparer - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end + local entry, uuid = calcs.getCachedSkillEntry(env, skill) - if GlobalCache.cachedData[env.mode][uuid] and comparer(env, uuid, source, triggerRate) and (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] then - return skill, GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed, uuid + if entry and comparer(env, uuid, source, triggerRate) and (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] then + return skill, entry.HitSpeed or entry.Speed, uuid end return source, triggerRate, source and cacheSkillUUID(source, env) end @@ -397,6 +395,7 @@ local function defaultTriggerHandler(env, config) local triggeredSkills = config.triggeredSkills or {} local trigRate = config.trigRate local uuid + local cache = getGlobalCacheTable(env) -- Find trigger skill and triggered skills if config.triggeredSkillCond or config.triggerSkillCond then @@ -447,8 +446,8 @@ local function defaultTriggerHandler(env, config) actor.mainSkill.skillData.ignoresTickRate = actor.mainSkill.skillData.ignoresTickRate or (actor.mainSkill.skillData.storedUses and actor.mainSkill.skillData.storedUses > 1) --Account for source unleash - if source and GlobalCache.cachedData[env.mode][uuid] and source.skillModList:Flag(nil, "HasSeals") and source.skillTypes[SkillType.CanRapidFire] then - local unleashDpsMult = GlobalCache.cachedData[env.mode][uuid].ActiveSkill.skillData.dpsMultiplier or 1 + if source and cache[uuid] and source.skillModList:Flag(nil, "HasSeals") and source.skillTypes[SkillType.CanRapidFire] then + local unleashDpsMult = cache[uuid].MainSkillData.dpsMultiplier or 1 trigRate = trigRate * unleashDpsMult actor.mainSkill.skillFlags.HasSeals = true actor.mainSkill.skillData.ignoresTickRate = true @@ -458,8 +457,8 @@ local function defaultTriggerHandler(env, config) end --Account for skills that can hit multiple times per use - if source and GlobalCache.cachedData[env.mode][uuid] and source.skillPartName and source.skillPartName:match("(.*)All(.*)Projectiles(.*)") and source.skillFlags.projectile then - local multiHitDpsMult = GlobalCache.cachedData[env.mode][uuid].Env.player.output.ProjectileCount or 1 + if source and cache[uuid] and source.skillPartName and source.skillPartName:match("(.*)All(.*)Projectiles(.*)") and source.skillFlags.projectile then + local multiHitDpsMult = cache[uuid].Output.ProjectileCount or 1 trigRate = trigRate * multiHitDpsMult if breakdown then t_insert(breakdown.EffectiveSourceRate, s_format("x %.2f ^8(%d projectiles hit)", multiHitDpsMult, multiHitDpsMult)) @@ -477,11 +476,11 @@ local function defaultTriggerHandler(env, config) end -- Battlemage's Cry uptime - if actor.mainSkill.skillData.triggeredByBattleMageCry and GlobalCache.cachedData[env.mode][uuid] and source and source.skillTypes[SkillType.Melee] then - local battleMageExertsCount = GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleCryExertsCount - local battleMageDuration = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleMageCryDuration, data.misc.ServerTickTime) - local battleMageCastTime = GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleMageCryCastTime - local battleMageCooldown = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattleMageCryCooldown, data.misc.ServerTickTime) + if actor.mainSkill.skillData.triggeredByBattleMageCry and cache[uuid] and source and source.skillTypes[SkillType.Melee] then + local battleMageExertsCount = cache[uuid].Output.BattleCryExertsCount or 0 + local battleMageDuration = ceil_b(cache[uuid].Output.BattleMageCryDuration or 0, data.misc.ServerTickTime) + local battleMageCastTime = cache[uuid].Output.BattleMageCryCastTime or 0 + local battleMageCooldown = ceil_b(cache[uuid].Output.BattleMageCryCooldown or 0, data.misc.ServerTickTime) -- Cap the number of hits that happen during the duration local battleMageHits = m_max(m_min(trigRate * battleMageDuration, battleMageExertsCount), 0) @@ -496,11 +495,11 @@ local function defaultTriggerHandler(env, config) end -- Infernal Cry uptime - if actor.mainSkill.activeEffect.grantedEffect.name == "Combust" and GlobalCache.cachedData[env.mode][uuid] and source and source.skillTypes[SkillType.Melee] then - local infernalCryExertsCount = GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalExertsCount - local infernalCryDuration = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalCryDuration, data.misc.ServerTickTime) - local infernalCryCastTime = GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalCryCastTime - local infernalCryCooldown = ceil_b(GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalCryCooldown, data.misc.ServerTickTime) + if actor.mainSkill.activeEffect.grantedEffect.name == "Combust" and cache[uuid] and source and source.skillTypes[SkillType.Melee] then + local infernalCryExertsCount = cache[uuid].Output.InfernalExertsCount or 0 + local infernalCryDuration = ceil_b(cache[uuid].Output.InfernalCryDuration or 0, data.misc.ServerTickTime) + local infernalCryCastTime = cache[uuid].Output.InfernalCryCastTime or 0 + local infernalCryCooldown = ceil_b(cache[uuid].Output.InfernalCryCooldown or 0, data.misc.ServerTickTime) -- Cap the number of hits that happen during the duration local infernalCryHits = m_max(m_min(trigRate * infernalCryDuration, infernalCryExertsCount), 0) @@ -516,14 +515,11 @@ local function defaultTriggerHandler(env, config) -- Handling for mana spending rate for Manaforged Arrows Support if actor.mainSkill.skillData.triggeredByManaforged and trigRate > 0 then - local triggeredUUID = cacheSkillUUID(actor.mainSkill, env) - if not GlobalCache.cachedData[env.mode][triggeredUUID] then - calcs.buildActiveSkill(env, env.mode, actor.mainSkill, triggeredUUID, {triggeredUUID}) - end - local triggeredManaCost = GlobalCache.cachedData[env.mode][triggeredUUID].Env.player.output.ManaCostRaw or 0 + local triggeredEntry = calcs.getCachedSkillEntry(env, actor.mainSkill, { cacheSkillUUID(actor.mainSkill, env) }, { breakdown = breakdown, section = "EffectiveSourceRate" }) + local triggeredManaCost = triggeredEntry and triggeredEntry.Output.ManaCostRaw or 0 if triggeredManaCost > 0 then local manaSpentThreshold = triggeredManaCost * actor.mainSkill.skillData.ManaForgedArrowsPercentThreshold - local sourceManaCost = GlobalCache.cachedData[env.mode][uuid].Env.player.output.ManaCostRaw or 0 + local sourceManaCost = cache[uuid].Output.ManaCostRaw or 0 if sourceManaCost > 0 then if breakdown then breakdown.EffectiveSourceRate = { @@ -581,9 +577,10 @@ local function defaultTriggerHandler(env, config) output.TriggerRateCap = 1 / actionCooldownTickRounded end if config.triggerName == "Doom Blast" and env.build.configTab.input["doomBlastSource"] == "expiration" then - local expirationRate = 1 / GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration + local sourceDuration = cache[uuid].Output.Duration or 0 + local expirationRate = sourceDuration > 0 and 1 / sourceDuration or 0 if breakdown and breakdown.EffectiveSourceRate then - breakdown.EffectiveSourceRate[1] = s_format("1 / %.2f ^8(source curse duration)", GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration) + breakdown.EffectiveSourceRate[1] = s_format("1 / %.2f ^8(source curse duration)", sourceDuration) end if expirationRate > trigRate then env.player.modDB:NewMod("UsesCurseOverlaps", "FLAG", true, "Config") @@ -725,14 +722,14 @@ local function defaultTriggerHandler(env, config) local triggerChanceBreakdown = {} --Accuracy and crit chance - if source and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and GlobalCache.cachedData[env.mode][uuid] and not config.triggerOnUse then + if source and (source.skillTypes[SkillType.Melee] or source.skillTypes[SkillType.Attack]) and cache[uuid] and not config.triggerOnUse then - local sourceHitChance = GlobalCache.cachedData[env.mode][uuid].HitChance or 0 + local sourceHitChance = cache[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 - 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 mainHandHit = cache[uuid].Output.MainHand.HitChance or 0 + local offHandHit = cache[uuid].Output.OffHand.HitChance or 0 local bothHit = mainHandHit * offHandHit / 100 local mainHandMiss = (100 - mainHandHit) local offHandMiss = (100 - offHandHit) @@ -749,15 +746,15 @@ local function defaultTriggerHandler(env, config) end end if actor.mainSkill.skillData.triggerOnCrit then - local onCritChance = actor.mainSkill.skillData.chanceToTriggerOnCrit or (GlobalCache.cachedData[env.mode][uuid] and GlobalCache.cachedData[env.mode][uuid].Env.player.mainSkill.skillData.chanceToTriggerOnCrit) + local onCritChance = actor.mainSkill.skillData.chanceToTriggerOnCrit or (cache[uuid] and cache[uuid].MainSkillData.chanceToTriggerOnCrit) config.triggerChance = config.triggerChance or actor.mainSkill.skillData.chanceToTriggerOnCrit or onCritChance - local sourceCritChance = GlobalCache.cachedData[env.mode][uuid].CritChance or 0 + local sourceCritChance = cache[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 - 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 mainHandCrit = cache[uuid].Output.MainHand.CritChance or 0 + local offHandCrit = cache[uuid].Output.OffHand.CritChance or 0 local bothHit = mainHandCrit * offHandCrit / 100 local mainHandMiss = (100 - mainHandCrit) local offHandMiss = (100 - offHandCrit) @@ -1077,8 +1074,9 @@ local configTable = { return {triggerChance = env.player.modDB:Sum("BASE", nil, "KitavaTriggerChance"), triggerName = "Kitava's Thirst", comparer = function(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed - local cachedManaCost = GlobalCache.cachedData[env.mode][uuid].ManaCost + local entry = getGlobalCacheTable(env)[uuid] + local cachedSpeed = entry and (entry.HitSpeed or entry.Speed) + local cachedManaCost = entry and entry.ManaCost return ( (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) ) and ( (cachedManaCost or 0) >= requiredManaCost ) end, triggerSkillCond = function(env, skill) @@ -1091,8 +1089,9 @@ local configTable = { return {triggerChance = env.player.modDB:Sum("BASE", nil, "FoulbornKitavaTriggerChance"), triggerName = "Foulborn Kitava's Thirst", comparer = function(env, uuid, source, triggerRate) - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].HitSpeed or GlobalCache.cachedData[env.mode][uuid].Speed - local cachedLifeCost = GlobalCache.cachedData[env.mode][uuid].LifeCost + local entry = getGlobalCacheTable(env)[uuid] + local cachedSpeed = entry and (entry.HitSpeed or entry.Speed) + local cachedLifeCost = entry and entry.LifeCost return ( (not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (triggerRate or 0)) ) and ( (cachedLifeCost or 0) >= requiredLifeCost ) end, triggerSkillCond = function(env, skill) @@ -1282,15 +1281,13 @@ local configTable = { end, ["shattershard"] = function(env) env.player.mainSkill.skillFlags.globalTrigger = true - local uuid = cacheSkillUUID(env.player.mainSkill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, env.player.mainSkill, uuid, {uuid}) - end - env.player.mainSkill.skillData.triggerRateCapOverride = 1 / GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration + local entry = calcs.getCachedSkillEntry(env, env.player.mainSkill, { cacheSkillUUID(env.player.mainSkill, env) }, { breakdown = env.player.breakdown, section = "SkillTriggerRate" }) + local duration = entry and entry.Output.Duration or 0 + env.player.mainSkill.skillData.triggerRateCapOverride = duration > 0 and 1 / duration or 0 if env.player.breakdown then env.player.breakdown.SkillTriggerRate = { s_format("Shattershard uses duration as pseudo cooldown"), - s_format("1 / %.2f ^8(Shattershard duration)", GlobalCache.cachedData[env.mode][uuid].Env.player.output.Duration), + s_format("1 / %.2f ^8(Shattershard duration)", duration), s_format("= %.2f ^8per second", env.player.mainSkill.skillData.triggerRateCapOverride), } end @@ -1301,7 +1298,7 @@ local configTable = { return {triggerSkillCond = function(env, skill) return skill.skillTypes[SkillType.Melee] end, comparer = function(env, uuid, source, triggerRate) -- Skills with no uptime ratio are not exerted by battlemage so should not be considered. - local uptimeRatio = GlobalCache.cachedData[env.mode][uuid].Env.player.output.BattlemageUpTimeRatio + local uptimeRatio = getGlobalCacheTable(env)[uuid].Output.BattlemageUpTimeRatio return defaultComparer(env, uuid, source, triggerRate) and uptimeRatio end, triggeredSkillCond = function(env, skill) return skill.skillData.triggeredByBattleMageCry and slotMatch(env, skill) end} @@ -1336,7 +1333,7 @@ local configTable = { return {triggerSkillCond = function(env, skill) return skill.skillTypes[SkillType.Melee] end, comparer = function(env, uuid, source, triggerRate) -- Skills with no uptime ratio are not exerted by infernal cry so should not be considered. - local uptimeRatio = GlobalCache.cachedData[env.mode][uuid].Env.player.output.InfernalUpTimeRatio + local uptimeRatio = getGlobalCacheTable(env)[uuid].Output.InfernalUpTimeRatio return defaultComparer(env, uuid, source, triggerRate) and uptimeRatio end,} end, @@ -1435,14 +1432,11 @@ local configTable = { for _, skill in ipairs(env.player.activeSkillList) do if skill.activeEffect.grantedEffect.name == "Snipe" and skill.socketGroup and skill.socketGroup.slot == env.player.mainSkill.socketGroup.slot then skill.skillData.hitTimeMultiplier = snipeStages - 0.5 - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[env.mode][uuid] or env.mode == "CALCULATOR" then - calcs.buildActiveSkill(env, env.mode, skill, uuid) - end - local cachedSpeed = GlobalCache.cachedData[env.mode][uuid].Env.player.output.HitSpeed + local entry = calcs.getCachedSkillEntry(env, skill) + local cachedSpeed = entry and entry.Output.HitSpeed if (skill.skillFlags and not skill.skillFlags.disable) and (skill.skillCfg and not skill.skillCfg.skillCond["usedByMirage"]) and not skill.skillTypes[SkillType.OtherThingUsesSkill] and ((not source and cachedSpeed) or (cachedSpeed and cachedSpeed > (trigRate or 0))) then trigRate = cachedSpeed - env.player.output.ChannelTimeToTrigger = GlobalCache.cachedData[env.mode][uuid].Env.player.output.HitTime + env.player.output.ChannelTimeToTrigger = entry.Output.HitTime source = skill end end @@ -1460,7 +1454,7 @@ local configTable = { ["avenging flame"] = function(env) return {triggerSkillCond = function(env, skill) return skill.skillFlags.totem and slotMatch(env, skill) end, comparer = function(env, uuid, source, currentTotemLife) - local totemLife = GlobalCache.cachedData[env.mode][uuid].Env.player.output.TotemLife + local totemLife = getGlobalCacheTable(env)[uuid].Output.TotemLife return (not source and totemLife) or (totemLife and totemLife > (currentTotemLife or 0)) end, ignoreSourceRate = true} @@ -1552,6 +1546,27 @@ end function calcs.triggers(env, actor) if actor and not actor.mainSkill.skillFlags.disable and not (env.limitedSkills and env.limitedSkills[cacheSkillUUID(actor.mainSkill, env)]) then + local recursionGuards = env.recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } + env.recursionGuards = recursionGuards + local actorId = actor == env.minion and "minion" or "player" + local guardKey = env.mode..":"..actorId..":"..cacheSkillUUID(actor.mainSkill, env) + if recursionGuards.triggerResolution[guardKey] then + actor.mainSkill.skillData.triggered = nil + actor.mainSkill.infoMessage = "Trigger cycle detected" + actor.output.TriggerRateCap = 0 + actor.output.EffectiveSourceRate = 0 + actor.output.SkillTriggerRate = 0 + if actor.breakdown then + actor.breakdown.SkillTriggerRate = actor.breakdown.SkillTriggerRate or { } + t_insert(actor.breakdown.SkillTriggerRate, "Trigger cycle detected; assuming 0 trigger rate") + end + return + end + recursionGuards.triggerResolution[guardKey] = true + local skillName = actor.mainSkill.activeEffect.grantedEffect.name local triggerName = actor.mainSkill.triggeredBy and actor.mainSkill.triggeredBy.grantedEffect.name local uniqueName = isTriggered(actor.mainSkill) and getUniqueItemTriggerName(actor.mainSkill) @@ -1572,5 +1587,6 @@ function calcs.triggers(env, actor) else actor.mainSkill.skillData.triggered = nil end + recursionGuards.triggerResolution[guardKey] = nil end end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index daaf130572..6b39aa8f6a 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -69,14 +69,44 @@ local function infoDump(env) prettyPrintTable(env.player.output) end +local function appendBreakdownNote(breakdown, section, note) + if not breakdown or not section or not note then + return + end + if not breakdown[section] then + breakdown[section] = { } + end + for _, line in ipairs(breakdown[section]) do + if line == note then + return + end + end + t_insert(breakdown[section], note) +end + +function calcs.getCachedSkillEntry(env, skill, limitedProcessingFlags, noteTarget) + local uuid = cacheSkillUUID(skill, env) + local cache = getGlobalCacheTable(env) + local entry = cache[uuid] + if not entry then + calcs.buildActiveSkill(env, env.mode, skill, uuid, limitedProcessingFlags) + entry = cache[uuid] + end + if entry and entry.Incomplete and noteTarget then + appendBreakdownNote(noteTarget.breakdown, noteTarget.section, entry.DebugNote) + end + return entry, uuid +end + -- Generate a function for calculating the effect of some modification to the environment local function getCalculator(build, fullInit, modFunc) -- Initialise environment - local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR") + local cacheGeneration = nextCalculatorCacheGeneration(build) + local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", nil, { cacheGeneration = cacheGeneration }) -- Run base calculation pass calcs.perform(env) - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil }) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = cacheGeneration }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -101,8 +131,10 @@ local function getCalculator(build, fullInit, modFunc) modFunc(env, ...) -- Run calculation pass + cacheGeneration = nextCalculatorCacheGeneration(build) + env.cacheGeneration = cacheGeneration calcs.perform(env) - fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = cacheGeneration }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -122,9 +154,10 @@ end -- Get calculator for other changes (adding/removing nodes, items, gems, etc) function calcs.getMiscCalculator(build) -- Run base calculation pass - local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR") + local cacheGeneration = nextCalculatorCacheGeneration(build) + local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", nil, { cacheGeneration = cacheGeneration }) calcs.perform(env) - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = cacheGeneration }) local usedFullDPS = #fullDPS.skills > 0 if usedFullDPS then env.player.output.SkillDPS = fullDPS.skills @@ -132,13 +165,11 @@ function calcs.getMiscCalculator(build) env.player.output.FullDotDPS = fullDPS.TotalDotDPS end return function(override, useFullDPS) - local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override) + local compareGeneration = nextCalculatorCacheGeneration(build) + local env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, "CALCULATOR", override, { cacheGeneration = compareGeneration }) calcs.perform(env) if (useFullDPS ~= false or build.viewMode == "TREE") and usedFullDPS then - -- prevent upcoming calculation from using Cached Data and thus forcing it to re-calculate new FullDPS roll-up - -- without this, FullDPS increase/decrease when for node/item/gem comparison would be all 0 as it would be comparing - -- A with A (due to cache reuse) instead of A with B - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil}) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = compareGeneration }) env.player.output.SkillDPS = fullDPS.skills env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS @@ -174,8 +205,7 @@ local function getActiveSkillCount(activeSkill) end function calcs.calcFullDPS(build, mode, override, specEnv) - local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) - local usedEnv = nil + local fullEnv = calcs.initEnv(build, mode, override, specEnv) local fullDPS = { combinedDPS = 0, @@ -198,146 +228,139 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local igniteSource = "" local burningGroundSource = "" local causticGroundSource = "" - + + -- initEnv() is the shared actor-global prepass for the FullDPS run. Each skill then + -- goes through the cached skill-local path so trigger/source lookups can be reused + -- within the current calculator generation. for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = getActiveSkillCount(activeSkill) if enabled then - fullEnv.player.mainSkill = activeSkill - calcs.perform(fullEnv, true) - usedEnv = fullEnv + local entry = calcs.getCachedSkillEntry(fullEnv, activeSkill) + local output = entry and entry.Output or nil + local minionOutput = output and output.Minion or nil + local mirage = entry and entry.Mirage or nil + local mainSkillFlags = entry and entry.MainSkillFlags or { DotCanStack = false } + local infoMessage2 = entry and entry.InfoMessage2 or activeSkill.infoMessage2 local minionName = nil - 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 + + if minionOutput then + if minionOutput.TotalDPS and minionOutput.TotalDPS > 0 then + minionName = entry and entry.MinionName and (entry.MinionName..": ") or "" + t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = minionOutput.TotalDPS, count = activeSkillCount, trigger = entry and entry.InfoTrigger, skillPart = minionName..(entry and entry.SkillPartName or activeSkill.skillPartName) }) + fullDPS.combinedDPS = fullDPS.combinedDPS + minionOutput.TotalDPS * activeSkillCount + end + if minionOutput.BleedDPS and minionOutput.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = minionOutput.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 + if minionOutput.IgniteDPS and minionOutput.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = minionOutput.IgniteDPS igniteSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.minion.output.PoisonDPS and usedEnv.minion.output.PoisonDPS > 0 then - fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + usedEnv.minion.output.TotalPoisonDPS * activeSkillCount + if minionOutput.PoisonDPS and minionOutput.PoisonDPS > 0 then + fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + minionOutput.TotalPoisonDPS * activeSkillCount end - if usedEnv.minion.output.ImpaleDPS and usedEnv.minion.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + usedEnv.minion.output.ImpaleDPS * activeSkillCount + if minionOutput.ImpaleDPS and minionOutput.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + minionOutput.ImpaleDPS * activeSkillCount end - if usedEnv.minion.output.DecayDPS and usedEnv.minion.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.minion.output.DecayDPS + if minionOutput.DecayDPS and minionOutput.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + minionOutput.DecayDPS end - if usedEnv.minion.output.TotalDot and usedEnv.minion.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.minion.output.TotalDot + if minionOutput.TotalDot and minionOutput.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + minionOutput.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 + if minionOutput.CullMultiplier and minionOutput.CullMultiplier > 1 and minionOutput.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = minionOutput.CullMultiplier end - -- This is a fix to prevent skills such as Absolution or Dominating Blow from being counted multiple times when increasing minions count - if (activeSkill.activeEffect.grantedEffect.name:match("Absolution") and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce")) - or (activeSkill.activeEffect.grantedEffect.name:match("Dominating Blow") and fullEnv.modDB:Flag(false, "Condition:DominatingBlowSkillDamageCountedOnce")) - or (activeSkill.activeEffect.grantedEffect.name:match("Holy Strike") and fullEnv.modDB:Flag(false, "Condition:HolyStrikeSkillDamageCountedOnce"))then + if infoMessage2 == "Skill Damage" then activeSkillCount = 1 - activeSkill.infoMessage2 = "Skill Damage" 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 + if mirage and mirage.Output then + local mirageOutput = mirage.Output + local mirageCount = (mirage.Count or 1) * activeSkillCount + if mirageOutput.TotalDPS and mirageOutput.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = mirage.Name .. " (Mirage)", dps = mirageOutput.TotalDPS, count = mirageCount, trigger = mirage.InfoTrigger, skillPart = mirage.SkillPartName }) + fullDPS.combinedDPS = fullDPS.combinedDPS + mirageOutput.TotalDPS * mirageCount end - if activeSkill.mirage.output.BleedDPS and activeSkill.mirage.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = activeSkill.mirage.output.BleedDPS + if mirageOutput.BleedDPS and mirageOutput.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = mirageOutput.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 + if mirageOutput.IgniteDPS and mirageOutput.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = mirageOutput.IgniteDPS igniteSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" end - if activeSkill.mirage.output.PoisonDPS and activeSkill.mirage.output.PoisonDPS > 0 then - fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + activeSkill.mirage.output.TotalPoisonDPS * mirageCount + if mirageOutput.PoisonDPS and mirageOutput.PoisonDPS > 0 then + fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + mirageOutput.TotalPoisonDPS * mirageCount end - if activeSkill.mirage.output.ImpaleDPS and activeSkill.mirage.output.ImpaleDPS > 0 then - fullDPS.impaleDPS = fullDPS.impaleDPS + activeSkill.mirage.output.ImpaleDPS * mirageCount + if mirageOutput.ImpaleDPS and mirageOutput.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + mirageOutput.ImpaleDPS * mirageCount end - if activeSkill.mirage.output.DecayDPS and activeSkill.mirage.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + activeSkill.mirage.output.DecayDPS + if mirageOutput.DecayDPS and mirageOutput.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + mirageOutput.DecayDPS end - if activeSkill.mirage.output.TotalDot and activeSkill.mirage.output.TotalDot > 0 and (activeSkill.skillFlags.DotCanStack or (usedEnv.player.output.TotalDot and usedEnv.player.output.TotalDot == 0)) then - fullDPS.dotDPS = fullDPS.dotDPS + activeSkill.mirage.output.TotalDot * (activeSkill.skillFlags.DotCanStack and mirageCount or 1) + if mirageOutput.TotalDot and mirageOutput.TotalDot > 0 and (mainSkillFlags.DotCanStack or (output and output.TotalDot and output.TotalDot == 0)) then + fullDPS.dotDPS = fullDPS.dotDPS + mirageOutput.TotalDot * (mainSkillFlags.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 + if mirageOutput.CullMultiplier and mirageOutput.CullMultiplier > 1 and mirageOutput.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = mirageOutput.CullMultiplier end - if activeSkill.mirage.output.BurningGroundDPS and activeSkill.mirage.output.BurningGroundDPS > fullDPS.burningGroundDPS then - fullDPS.burningGroundDPS = activeSkill.mirage.output.BurningGroundDPS + if mirageOutput.BurningGroundDPS and mirageOutput.BurningGroundDPS > fullDPS.burningGroundDPS then + fullDPS.burningGroundDPS = mirageOutput.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 + if mirageOutput.CausticGroundDPS and mirageOutput.CausticGroundDPS > fullDPS.causticGroundDPS then + fullDPS.causticGroundDPS = mirageOutput.CausticGroundDPS causticGroundSource = activeSkill.activeEffect.grantedEffect.name .. " (Mirage)" end 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 + if output and output.TotalDPS and output.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = output.TotalDPS, count = activeSkillCount, trigger = entry and entry.InfoTrigger, skillPart = minionName and infoMessage2 or (entry and entry.SkillPartName or activeSkill.skillPartName) }) + fullDPS.combinedDPS = fullDPS.combinedDPS + output.TotalDPS * activeSkillCount end - if usedEnv.player.output.BleedDPS and usedEnv.player.output.BleedDPS > fullDPS.bleedDPS then - fullDPS.bleedDPS = usedEnv.player.output.BleedDPS + if output and output.BleedDPS and output.BleedDPS > fullDPS.bleedDPS then + fullDPS.bleedDPS = 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 + if output and output.CorruptingBloodDPS and output.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then + fullDPS.corruptingBloodDPS = 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 + if output and output.IgniteDPS and output.IgniteDPS > fullDPS.igniteDPS then + fullDPS.igniteDPS = 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 + if output and output.BurningGroundDPS and output.BurningGroundDPS > fullDPS.burningGroundDPS then + fullDPS.burningGroundDPS = output.BurningGroundDPS burningGroundSource = activeSkill.activeEffect.grantedEffect.name end - if usedEnv.player.output.PoisonDPS and usedEnv.player.output.PoisonDPS > 0 then - fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + usedEnv.player.output.TotalPoisonDPS * activeSkillCount + if output and output.PoisonDPS and output.PoisonDPS > 0 then + fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + output.TotalPoisonDPS * activeSkillCount end - if usedEnv.player.output.CausticGroundDPS and usedEnv.player.output.CausticGroundDPS > fullDPS.causticGroundDPS then - fullDPS.causticGroundDPS = usedEnv.player.output.CausticGroundDPS + if output and output.CausticGroundDPS and output.CausticGroundDPS > fullDPS.causticGroundDPS then + fullDPS.causticGroundDPS = 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 + if output and output.ImpaleDPS and output.ImpaleDPS > 0 then + fullDPS.impaleDPS = fullDPS.impaleDPS + output.ImpaleDPS * activeSkillCount end - if usedEnv.player.output.DecayDPS and usedEnv.player.output.DecayDPS > 0 then - fullDPS.decayDPS = fullDPS.decayDPS + usedEnv.player.output.DecayDPS + if output and output.DecayDPS and output.DecayDPS > 0 then + fullDPS.decayDPS = fullDPS.decayDPS + output.DecayDPS end - if usedEnv.player.output.TotalDot and usedEnv.player.output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + usedEnv.player.output.TotalDot * (activeSkill.skillFlags.DotCanStack and activeSkillCount or 1) + if output and output.TotalDot and output.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + output.TotalDot * (mainSkillFlags.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 + if output and output.CullMultiplier and output.CullMultiplier > 1 and output.CullMultiplier > fullDPS.cullingMulti then + fullDPS.cullingMulti = output.CullMultiplier end - - -- Re-Build env calculator for new run - local accelerationTbl = { - nodeAlloc = true, - requirementsItems = true, - requirementsGems = true, - skills = true, - everything = true, - } - fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl }) end end end - -- 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 }) @@ -389,28 +412,46 @@ end -- Process active skill function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingFlags) - local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override) + targetUUID = targetUUID or cacheSkillUUID(skill, env) + local guardKey = mode..":"..targetUUID + local recursionGuards = env.recursionGuards or { + buildingSkills = { }, + triggerResolution = { }, + } + env.recursionGuards = recursionGuards + if recursionGuards.buildingSkills[guardKey] then + local cache = getGlobalCacheTable(env) + cache[targetUUID] = cache[targetUUID] or buildSafeSkillCacheEntry(targetUUID, skill, s_format("Recursive skill cache resolution detected for %s; assuming 0 until the current calculation completes", skill.activeEffect.grantedEffect.name)) + return false + end + recursionGuards.buildingSkills[guardKey] = true + + local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override, { cacheGeneration = env.cacheGeneration, recursionGuards = recursionGuards }) -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops fullEnv.limitedSkills = fullEnv.limitedSkills or {} - for _, uuid in ipairs(env.limitedSkills or {}) do - fullEnv.limitedSkills[uuid] = true + for key, value in pairs(env.limitedSkills or {}) do + fullEnv.limitedSkills[type(key) == "number" and value or key] = true end for _, uuid in ipairs(limitedProcessingFlags or {}) do fullEnv.limitedSkills[uuid] = true end - targetUUID = targetUUID or cacheSkillUUID(skill, env) for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do local activeSkillUUID = cacheSkillUUID(activeSkill, fullEnv) if activeSkillUUID == targetUUID then fullEnv.player.mainSkill = activeSkill + fullEnv.requestedSkillUUID = targetUUID calcs.perform(fullEnv, true) - return + fullEnv.requestedSkillUUID = nil + recursionGuards.buildingSkills[guardKey] = nil + return true end end + recursionGuards.buildingSkills[guardKey] = nil ConPrintf("[calcs.buildActiveSkill] Failed to process skill: " .. skill.activeEffect.grantedEffect.name) + return false end -- Build output for display in the side bar or calcs tab @@ -422,7 +463,7 @@ function calcs.buildOutput(build, mode) local output = env.player.output -- Build output across all skills added to FullDPS skills - local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil }) + local fullDPS = calcs.calcFullDPS(build, "CALCULATOR", {}, { cachedPlayerDB = cachedPlayerDB, cachedEnemyDB = cachedEnemyDB, cachedMinionDB = cachedMinionDB, env = nil, cacheGeneration = nextCalculatorCacheGeneration(build) }) -- Add Full DPS data to main `env` env.player.output.SkillDPS = fullDPS.skills @@ -431,19 +472,16 @@ function calcs.buildOutput(build, mode) if mode == "MAIN" then for _, skill in ipairs(env.player.activeSkillList) do - local uuid = cacheSkillUUID(skill, env) - if not GlobalCache.cachedData[mode][uuid] then - calcs.buildActiveSkill(env, mode, skill, uuid) - end - if GlobalCache.cachedData[mode][uuid] then + local entry = calcs.getCachedSkillEntry(env, skill) + if entry then output.EnergyShieldProtectsMana = env.modDB:Flag(nil, "EnergyShieldProtectsMana") for pool, costResource in pairs({["LifeUnreserved"] = "LifeCost", ["ManaUnreserved"] = "ManaCost", ["Rage"] = "RageCost", ["EnergyShield"] = "ESCost"}) do - local cachedCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource] + local cachedCost = entry.Output[costResource] if cachedCost then local totalPool = (output.EnergyShieldProtectsMana and costResource == "ManaCost" and output["EnergyShield"] or 0) + (output[pool] or 0) if totalPool < cachedCost then local rawPool = pool:gsub("Unreserved$", "") - local reservation = GlobalCache.cachedData[mode][uuid].Env.player.mainSkill and GlobalCache.cachedData[mode][uuid].Env.player.mainSkill.skillData[rawPool .. "ReservedPercent"] + local reservation = entry.MainSkillData[rawPool .. "ReservedPercent"] -- Skill has both cost and reservation check if there's available pool for raw cost before reservation if not reservation or (reservation and (totalPool + m_ceil((output[rawPool] or 0) * reservation / 100)) < cachedCost) then if env.player.mainSkill and env.player.mainSkill.activeEffect.grantedEffect.name == skill.activeEffect.grantedEffect.name then @@ -456,7 +494,7 @@ function calcs.buildOutput(build, mode) end end for pool, costResource in pairs({["LifeUnreservedPercent"] = "LifePercentCost", ["ManaUnreservedPercent"] = "ManaPercentCost"}) do - local cachedCost = GlobalCache.cachedData[mode][uuid].Env.player.output[costResource] + local cachedCost = entry.Output[costResource] if cachedCost then if (output[pool] or 0) < cachedCost then output[costResource.."PercentCostWarningList"] = output[costResource.."PercentCostWarningList"] or {} diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index 49aa19ae3d..139ad407ea 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -802,26 +802,125 @@ function cacheSkillUUID(skill, env) return strName.."_"..strSlotName.."_"..tostring(slotIndx) .. "_" .. tostring(groupIdx) end +local calculatorCacheGeneration = 0 + +function nextCalculatorCacheGeneration(build) + calculatorCacheGeneration = calculatorCacheGeneration + 1 + return s_format("%s:%d", tostring(build and build.outputRevision or 0), calculatorCacheGeneration) +end + +function getGlobalCacheTable(modeOrEnv, build, cacheGeneration) + local mode = modeOrEnv + if type(modeOrEnv) == "table" then + local env = modeOrEnv + mode = env.mode + build = env.build + cacheGeneration = env.cacheGeneration + end + if mode ~= "CALCULATOR" then + return GlobalCache.cachedData[mode] + end + local namespace = cacheGeneration or s_format("%s:default", tostring(build and build.outputRevision or 0)) + GlobalCache.cachedData.CALCULATOR[namespace] = GlobalCache.cachedData.CALCULATOR[namespace] or {} + return GlobalCache.cachedData.CALCULATOR[namespace] +end + +local function buildCachedMainSkillData(mainSkill) + local skillData = mainSkill.skillData or {} + return { + chanceToTriggerOnCrit = skillData.chanceToTriggerOnCrit, + dpsMultiplier = skillData.dpsMultiplier, + LifeReservedPercent = skillData.LifeReservedPercent, + ManaReservedPercent = skillData.ManaReservedPercent, + EnergyShieldReservedPercent = skillData.EnergyShieldReservedPercent, + RageReservedPercent = skillData.RageReservedPercent, + } +end + +local function buildCachedMirageData(mainSkill) + local mirage = mainSkill and mainSkill.mirage + if not mirage then + return nil + end + return { + Name = mirage.name, + Count = mirage.count, + InfoTrigger = mirage.infoTrigger, + SkillPartName = mirage.skillPartName, + Output = mirage.output and copyTableSafe(mirage.output, false) or nil, + MinionOutput = mirage.minion and mirage.minion.output and copyTableSafe(mirage.minion.output, false) or nil, + } +end + +function buildSafeSkillCacheEntry(uuid, skill, debugNote) + local entry = { + Name = skill and skill.activeEffect and skill.activeEffect.grantedEffect.name or "Unknown", + Speed = 0, + HitSpeed = 0, + ManaCost = 0, + LifeCost = 0, + ESCost = 0, + RageCost = 0, + HitChance = 0, + AccuracyHitChance = 0, + PreEffectiveCritChance = 0, + CritChance = 0, + TotalDPS = 0, + Output = { + MainHand = { + HitChance = 0, + CritChance = 0, + }, + OffHand = { + HitChance = 0, + CritChance = 0, + }, + }, + MainSkillData = skill and buildCachedMainSkillData(skill) or {}, + MainSkillFlags = { + DotCanStack = skill and skill.skillFlags and skill.skillFlags.DotCanStack or false, + }, + InfoTrigger = skill and skill.infoTrigger or nil, + InfoMessage2 = skill and skill.infoMessage2 or nil, + SkillPartName = skill and skill.skillPartName or nil, + MinionName = skill and skill.minion and skill.minion.minionData and skill.minion.minionData.name or nil, + Mirage = nil, + SkillUUID = uuid, + Incomplete = true, + DebugNote = debugNote, + } + return entry +end + -- Global Cache related function cacheData(uuid, env) - -- Cache entries intentionally retain live env/skill graph links for follow-up reads. - graphNodeTag(env, "Env") - GlobalCache.cachedData[env.mode][uuid] = graphNodeTag({ - Name = env.player.mainSkill.activeEffect.grantedEffect.name, - Speed = env.player.output.Speed, - HitSpeed = env.player.output.HitSpeed, - ManaCost = env.player.output.ManaCost, - LifeCost = env.player.output.LifeCost, - ESCost = env.player.output.ESCost, - RageCost = env.player.output.RageCost, - HitChance = env.player.output.HitChance, - AccuracyHitChance = env.player.output.AccuracyHitChance, - PreEffectiveCritChance = env.player.output.PreEffectiveCritChance, - CritChance = env.player.output.CritChance, - TotalDPS = env.player.output.TotalDPS, - ActiveSkill = env.player.mainSkill, - Env = env, - }, "SkillCacheEntry") + local mainSkill = env.player.mainSkill + local output = env.player.output + getGlobalCacheTable(env)[uuid] = { + Name = mainSkill.activeEffect.grantedEffect.name, + Speed = output.Speed, + HitSpeed = output.HitSpeed, + ManaCost = output.ManaCost, + LifeCost = output.LifeCost, + ESCost = output.ESCost, + RageCost = output.RageCost, + HitChance = output.HitChance, + AccuracyHitChance = output.AccuracyHitChance, + PreEffectiveCritChance = output.PreEffectiveCritChance, + CritChance = output.CritChance, + TotalDPS = output.TotalDPS, + Output = copyTableSafe(output, false), + MainSkillData = buildCachedMainSkillData(mainSkill), + MainSkillFlags = { + DotCanStack = mainSkill.skillFlags and mainSkill.skillFlags.DotCanStack or false, + }, + InfoTrigger = mainSkill.infoTrigger, + InfoMessage2 = mainSkill.infoMessage2, + SkillPartName = mainSkill.skillPartName, + MinionName = env.minion and env.minion.minionData and env.minion.minionData.name or nil, + Mirage = buildCachedMirageData(mainSkill), + SkillUUID = uuid, + } end -- Wipe all the tables associated with Global Cache diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 52ea0343ae..838ac069fb 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -2282,7 +2282,7 @@ Huge sets the radius to 11. local mod = mods[i] if mod then - mod = modLib.setSource(mod, source) + mod = modLib.withSource(mod, source) modList:AddMod(mod) end end diff --git a/src/Modules/ModTools.lua b/src/Modules/ModTools.lua index bed3509f88..005de2bdaf 100644 --- a/src/Modules/ModTools.lua +++ b/src/Modules/ModTools.lua @@ -223,7 +223,9 @@ function modLib.withSource(mod, source) return copy end --- Deprecated: internal-only helper that mutates the provided mod; prefer withSource for shared mods. +-- Deprecated: internal-only helper that mutates the provided mod in place. +-- Only use this when the caller owns the modifier instance and no shared parser/cache +-- tables can observe the mutation; otherwise prefer withSource(). function modLib.setSource(mod, source) mod.source = source if type(mod.value) == "table" and mod.value.mod then From 251b15772fa6c77d2f631c108193ed009cc94e96 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 15:35:56 -0400 Subject: [PATCH 04/12] Restore hot env reuse in FullDPS calculations --- src/Modules/CalcSetup.lua | 3 ++ src/Modules/Calcs.lua | 99 ++++++++++++++++++++++++++------------- 2 files changed, 70 insertions(+), 32 deletions(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 6d074f149d..1137caf7f4 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -581,6 +581,9 @@ function calcs.initEnv(build, mode, override, specEnv) env.minion.modDB.parent = cachedMinionDB end end + env.cachedPlayerDB = cachedPlayerDB + env.cachedEnemyDB = cachedEnemyDB + env.cachedMinionDB = cachedMinionDB if override.conditions then for _, flag in ipairs(override.conditions) do diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 6b39aa8f6a..0069c82f42 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -205,7 +205,8 @@ local function getActiveSkillCount(activeSkill) end function calcs.calcFullDPS(build, mode, override, specEnv) - local fullEnv = calcs.initEnv(build, mode, override, specEnv) + local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) + local usedEnv = nil local fullDPS = { combinedDPS = 0, @@ -229,25 +230,26 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local burningGroundSource = "" local causticGroundSource = "" - -- initEnv() is the shared actor-global prepass for the FullDPS run. Each skill then - -- goes through the cached skill-local path so trigger/source lookups can be reused - -- within the current calculator generation. + -- initEnv() is the shared actor-global prepass for the FullDPS run. Each included + -- skill then runs through the hot skill-local path on the reused env, while perform() + -- continues to populate the slim cache for trigger/source reuse within the generation. for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = getActiveSkillCount(activeSkill) if enabled then - local entry = calcs.getCachedSkillEntry(fullEnv, activeSkill) - local output = entry and entry.Output or nil - local minionOutput = output and output.Minion or nil - local mirage = entry and entry.Mirage or nil - local mainSkillFlags = entry and entry.MainSkillFlags or { DotCanStack = false } - local infoMessage2 = entry and entry.InfoMessage2 or activeSkill.infoMessage2 + fullEnv.player.mainSkill = activeSkill + calcs.perform(fullEnv, true) + usedEnv = fullEnv + local output = usedEnv.player.output + local minionOutput = usedEnv.minion and usedEnv.minion.output or nil + local mirage = activeSkill.mirage + local infoMessage2 = activeSkill.infoMessage2 local minionName = nil if minionOutput then if minionOutput.TotalDPS and minionOutput.TotalDPS > 0 then - minionName = entry and entry.MinionName and (entry.MinionName..": ") or "" - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = minionOutput.TotalDPS, count = activeSkillCount, trigger = entry and entry.InfoTrigger, skillPart = minionName..(entry and entry.SkillPartName or activeSkill.skillPartName) }) + minionName = (activeSkill.minion and activeSkill.minion.minionData.name..": ") or (usedEnv.minion and usedEnv.minion.minionData and usedEnv.minion.minionData.name..": ") or "" + t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = minionOutput.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName..activeSkill.skillPartName }) fullDPS.combinedDPS = fullDPS.combinedDPS + minionOutput.TotalDPS * activeSkillCount end if minionOutput.BleedDPS and minionOutput.BleedDPS > fullDPS.bleedDPS then @@ -278,11 +280,11 @@ function calcs.calcFullDPS(build, mode, override, specEnv) end end - if mirage and mirage.Output then - local mirageOutput = mirage.Output - local mirageCount = (mirage.Count or 1) * activeSkillCount + if mirage and mirage.output then + local mirageOutput = mirage.output + local mirageCount = (mirage.count or 1) * activeSkillCount if mirageOutput.TotalDPS and mirageOutput.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = mirage.Name .. " (Mirage)", dps = mirageOutput.TotalDPS, count = mirageCount, trigger = mirage.InfoTrigger, skillPart = mirage.SkillPartName }) + t_insert(fullDPS.skills, { name = mirage.name .. " (Mirage)", dps = mirageOutput.TotalDPS, count = mirageCount, trigger = mirage.infoTrigger, skillPart = mirage.skillPartName }) fullDPS.combinedDPS = fullDPS.combinedDPS + mirageOutput.TotalDPS * mirageCount end if mirageOutput.BleedDPS and mirageOutput.BleedDPS > fullDPS.bleedDPS then @@ -302,8 +304,8 @@ function calcs.calcFullDPS(build, mode, override, specEnv) if mirageOutput.DecayDPS and mirageOutput.DecayDPS > 0 then fullDPS.decayDPS = fullDPS.decayDPS + mirageOutput.DecayDPS end - if mirageOutput.TotalDot and mirageOutput.TotalDot > 0 and (mainSkillFlags.DotCanStack or (output and output.TotalDot and output.TotalDot == 0)) then - fullDPS.dotDPS = fullDPS.dotDPS + mirageOutput.TotalDot * (mainSkillFlags.DotCanStack and mirageCount or 1) + if mirageOutput.TotalDot and mirageOutput.TotalDot > 0 and (activeSkill.skillFlags.DotCanStack or (output and output.TotalDot and output.TotalDot == 0)) then + fullDPS.dotDPS = fullDPS.dotDPS + mirageOutput.TotalDot * (activeSkill.skillFlags.DotCanStack and mirageCount or 1) end if mirageOutput.CullMultiplier and mirageOutput.CullMultiplier > 1 and mirageOutput.CullMultiplier > fullDPS.cullingMulti then fullDPS.cullingMulti = mirageOutput.CullMultiplier @@ -318,45 +320,62 @@ function calcs.calcFullDPS(build, mode, override, specEnv) end end - if output and output.TotalDPS and output.TotalDPS > 0 then - t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = output.TotalDPS, count = activeSkillCount, trigger = entry and entry.InfoTrigger, skillPart = minionName and infoMessage2 or (entry and entry.SkillPartName or activeSkill.skillPartName) }) + if output.TotalDPS and output.TotalDPS > 0 then + t_insert(fullDPS.skills, { name = activeSkill.activeEffect.grantedEffect.name, dps = output.TotalDPS, count = activeSkillCount, trigger = activeSkill.infoTrigger, skillPart = minionName and infoMessage2 or activeSkill.skillPartName }) fullDPS.combinedDPS = fullDPS.combinedDPS + output.TotalDPS * activeSkillCount end - if output and output.BleedDPS and output.BleedDPS > fullDPS.bleedDPS then + if output.BleedDPS and output.BleedDPS > fullDPS.bleedDPS then fullDPS.bleedDPS = output.BleedDPS bleedSource = activeSkill.activeEffect.grantedEffect.name end - if output and output.CorruptingBloodDPS and output.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then + if output.CorruptingBloodDPS and output.CorruptingBloodDPS > fullDPS.corruptingBloodDPS then fullDPS.corruptingBloodDPS = output.CorruptingBloodDPS corruptingBloodSource = activeSkill.activeEffect.grantedEffect.name end - if output and output.IgniteDPS and output.IgniteDPS > fullDPS.igniteDPS then + if output.IgniteDPS and output.IgniteDPS > fullDPS.igniteDPS then fullDPS.igniteDPS = output.IgniteDPS igniteSource = activeSkill.activeEffect.grantedEffect.name end - if output and output.BurningGroundDPS and output.BurningGroundDPS > fullDPS.burningGroundDPS then + if output.BurningGroundDPS and output.BurningGroundDPS > fullDPS.burningGroundDPS then fullDPS.burningGroundDPS = output.BurningGroundDPS burningGroundSource = activeSkill.activeEffect.grantedEffect.name end - if output and output.PoisonDPS and output.PoisonDPS > 0 then + if output.PoisonDPS and output.PoisonDPS > 0 then fullDPS.TotalPoisonDPS = fullDPS.TotalPoisonDPS + output.TotalPoisonDPS * activeSkillCount end - if output and output.CausticGroundDPS and output.CausticGroundDPS > fullDPS.causticGroundDPS then + if output.CausticGroundDPS and output.CausticGroundDPS > fullDPS.causticGroundDPS then fullDPS.causticGroundDPS = output.CausticGroundDPS causticGroundSource = activeSkill.activeEffect.grantedEffect.name end - if output and output.ImpaleDPS and output.ImpaleDPS > 0 then + if output.ImpaleDPS and output.ImpaleDPS > 0 then fullDPS.impaleDPS = fullDPS.impaleDPS + output.ImpaleDPS * activeSkillCount end - if output and output.DecayDPS and output.DecayDPS > 0 then + if output.DecayDPS and output.DecayDPS > 0 then fullDPS.decayDPS = fullDPS.decayDPS + output.DecayDPS end - if output and output.TotalDot and output.TotalDot > 0 then - fullDPS.dotDPS = fullDPS.dotDPS + output.TotalDot * (mainSkillFlags.DotCanStack and activeSkillCount or 1) + if output.TotalDot and output.TotalDot > 0 then + fullDPS.dotDPS = fullDPS.dotDPS + output.TotalDot * (activeSkill.skillFlags.DotCanStack and activeSkillCount or 1) end - if output and output.CullMultiplier and output.CullMultiplier > 1 and output.CullMultiplier > fullDPS.cullingMulti then + if output.CullMultiplier and output.CullMultiplier > 1 and output.CullMultiplier > fullDPS.cullingMulti then fullDPS.cullingMulti = output.CullMultiplier end + + local accelerationTbl = { + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skills = true, + everything = true, + } + fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { + cachedPlayerDB = cachedPlayerDB, + cachedEnemyDB = cachedEnemyDB, + cachedMinionDB = cachedMinionDB, + env = fullEnv, + accelerate = accelerationTbl, + cacheGeneration = fullEnv.cacheGeneration, + recursionGuards = fullEnv.recursionGuards, + }) end end end @@ -426,7 +445,23 @@ function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingF end recursionGuards.buildingSkills[guardKey] = true - local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override, { cacheGeneration = env.cacheGeneration, recursionGuards = recursionGuards }) + local accelerationTbl = env.cachedSkillBuildEnv and { + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skills = true, + everything = true, + } or nil + local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override, { + cachedPlayerDB = env.cachedPlayerDB, + cachedEnemyDB = env.cachedEnemyDB, + cachedMinionDB = env.cachedMinionDB, + env = env.cachedSkillBuildEnv, + accelerate = accelerationTbl, + cacheGeneration = env.cacheGeneration, + recursionGuards = recursionGuards, + }) + env.cachedSkillBuildEnv = fullEnv -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops From 3cc41e1538db77cb0a1b9eafa4278eec7d2e11fe Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 15:45:34 -0400 Subject: [PATCH 05/12] Trim cached skill output snapshots --- src/Modules/Common.lua | 75 +++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index 139ad407ea..58b9bd5bbc 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -837,18 +837,42 @@ local function buildCachedMainSkillData(mainSkill) } end -local function buildCachedMirageData(mainSkill) - local mirage = mainSkill and mainSkill.mirage - if not mirage then - return nil - end +local function buildCachedOutput(output) + local mainHand = output and output.MainHand or nil + local offHand = output and output.OffHand or nil return { - Name = mirage.name, - Count = mirage.count, - InfoTrigger = mirage.infoTrigger, - SkillPartName = mirage.skillPartName, - Output = mirage.output and copyTableSafe(mirage.output, false) or nil, - MinionOutput = mirage.minion and mirage.minion.output and copyTableSafe(mirage.minion.output, false) or nil, + Speed = output and output.Speed or 0, + HitSpeed = output and output.HitSpeed or 0, + Duration = output and output.Duration or 0, + Time = output and output.Time or 0, + HitTime = output and output.HitTime or 0, + ManaCost = output and output.ManaCost or 0, + LifeCost = output and output.LifeCost or 0, + ESCost = output and output.ESCost or 0, + RageCost = output and output.RageCost or 0, + ManaCostRaw = output and output.ManaCostRaw or 0, + LifePercentCost = output and output.LifePercentCost or 0, + ManaPercentCost = output and output.ManaPercentCost or 0, + ProjectileCount = output and output.ProjectileCount or 0, + BattleCryExertsCount = output and output.BattleCryExertsCount or 0, + BattleMageCryDuration = output and output.BattleMageCryDuration or 0, + BattleMageCryCastTime = output and output.BattleMageCryCastTime or 0, + BattleMageCryCooldown = output and output.BattleMageCryCooldown or 0, + BattlemageUpTimeRatio = output and output.BattlemageUpTimeRatio or 0, + InfernalExertsCount = output and output.InfernalExertsCount or 0, + InfernalCryDuration = output and output.InfernalCryDuration or 0, + InfernalCryCastTime = output and output.InfernalCryCastTime or 0, + InfernalCryCooldown = output and output.InfernalCryCooldown or 0, + InfernalUpTimeRatio = output and output.InfernalUpTimeRatio or 0, + TotemLife = output and output.TotemLife or 0, + MainHand = { + HitChance = mainHand and mainHand.HitChance or 0, + CritChance = mainHand and mainHand.CritChance or 0, + }, + OffHand = { + HitChance = offHand and offHand.HitChance or 0, + CritChance = offHand and offHand.CritChance or 0, + }, } end @@ -866,25 +890,8 @@ function buildSafeSkillCacheEntry(uuid, skill, debugNote) PreEffectiveCritChance = 0, CritChance = 0, TotalDPS = 0, - Output = { - MainHand = { - HitChance = 0, - CritChance = 0, - }, - OffHand = { - HitChance = 0, - CritChance = 0, - }, - }, + Output = buildCachedOutput(nil), MainSkillData = skill and buildCachedMainSkillData(skill) or {}, - MainSkillFlags = { - DotCanStack = skill and skill.skillFlags and skill.skillFlags.DotCanStack or false, - }, - InfoTrigger = skill and skill.infoTrigger or nil, - InfoMessage2 = skill and skill.infoMessage2 or nil, - SkillPartName = skill and skill.skillPartName or nil, - MinionName = skill and skill.minion and skill.minion.minionData and skill.minion.minionData.name or nil, - Mirage = nil, SkillUUID = uuid, Incomplete = true, DebugNote = debugNote, @@ -909,16 +916,8 @@ function cacheData(uuid, env) PreEffectiveCritChance = output.PreEffectiveCritChance, CritChance = output.CritChance, TotalDPS = output.TotalDPS, - Output = copyTableSafe(output, false), + Output = buildCachedOutput(output), MainSkillData = buildCachedMainSkillData(mainSkill), - MainSkillFlags = { - DotCanStack = mainSkill.skillFlags and mainSkill.skillFlags.DotCanStack or false, - }, - InfoTrigger = mainSkill.infoTrigger, - InfoMessage2 = mainSkill.infoMessage2, - SkillPartName = mainSkill.skillPartName, - MinionName = env.minion and env.minion.minionData and env.minion.minionData.name or nil, - Mirage = buildCachedMirageData(mainSkill), SkillUUID = uuid, } end From e5672f24f9d7dcbeb0b476e56e515d9db35f9bdd Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 16:05:06 -0400 Subject: [PATCH 06/12] Split calc prepass from skill-local pass --- src/Modules/CalcPerform.lua | 41 ++++++++++++++++++++++++++++++------- src/Modules/Calcs.lua | 23 +++++++++++++-------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index aa7bb5afc5..af1a707999 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -1074,14 +1074,38 @@ end -- 8. Processes buffs and debuffs -- 9. Processes charges and misc buffs (doActorCharges, doActorMisc) -- 10. Calculates defence and offence stats (calcs.defence, calcs.offence) -function calcs.perform(env, skipEHP) - local modDB = env.modDB - local enemyDB = env.enemyDB +function calcs.performActorPrepass(env, options) + options = options or { } + local prepassState = { + partyTabEnableExportBuffs = env.build.partyTab.enableExportBuffs and env.mode ~= "CALCULATOR", + } + env.partyMembers = env.build.partyTab.actor + env.player.partyMembers = env.partyMembers - -- Merge keystone modifiers + -- Merge keystone modifiers once per actor-global pass so the skill-local + -- phase can reuse the resulting DB baseline. env.keystonesAdded = { } modLib.mergeKeystones(env, env.modDB) + if options.snapshotDBs then + prepassState.cachedPlayerDB, prepassState.cachedEnemyDB, prepassState.cachedMinionDB = specCopy(env) + prepassState.cachedPlayerDB.parent = env.modDB.parent + prepassState.cachedEnemyDB.parent = env.enemyDB.parent + if prepassState.cachedMinionDB and env.minion and env.minion.modDB then + prepassState.cachedMinionDB.parent = env.minion.modDB.parent + end + end + env.performActorPrepassState = prepassState + return prepassState +end + +function calcs.performSkillPass(env, skipEHP, prepassState) + local modDB = env.modDB + local enemyDB = env.enemyDB + prepassState = prepassState or env.performActorPrepassState + env.partyMembers = env.partyMembers or env.build.partyTab.actor + env.player.partyMembers = env.partyMembers + -- Build minion skills for _, activeSkill in ipairs(env.player.activeSkillList) do activeSkill.skillModList = new("ModList", activeSkill.baseSkillModList) @@ -1097,9 +1121,7 @@ function calcs.perform(env, skipEHP) env.enemy.output = { } local output = env.player.output - env.partyMembers = env.build.partyTab.actor - env.player.partyMembers = env.partyMembers - local partyTabEnableExportBuffs = env.build.partyTab.enableExportBuffs and env.mode ~= "CALCULATOR" + local partyTabEnableExportBuffs = prepassState and prepassState.partyTabEnableExportBuffs or (env.build.partyTab.enableExportBuffs and env.mode ~= "CALCULATOR") env.minion = env.player.mainSkill.minion if env.minion then @@ -3591,3 +3613,8 @@ function calcs.perform(env, skipEHP) cacheData(env.requestedSkillUUID or cacheSkillUUID(env.player.mainSkill, env), env) end + +function calcs.perform(env, skipEHP) + local prepassState = calcs.performActorPrepass(env) + return calcs.performSkillPass(env, skipEHP, prepassState) +end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 0069c82f42..c18dfa7869 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -207,6 +207,10 @@ end function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil + local prepassState = calcs.performActorPrepass(fullEnv, { snapshotDBs = true }) + local skillPassPlayerDB = prepassState.cachedPlayerDB or cachedPlayerDB + local skillPassEnemyDB = prepassState.cachedEnemyDB or cachedEnemyDB + local skillPassMinionDB = prepassState.cachedMinionDB or cachedMinionDB local fullDPS = { combinedDPS = 0, @@ -238,7 +242,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local activeSkillCount, enabled = getActiveSkillCount(activeSkill) if enabled then fullEnv.player.mainSkill = activeSkill - calcs.perform(fullEnv, true) + calcs.performSkillPass(fullEnv, true, prepassState) usedEnv = fullEnv local output = usedEnv.player.output local minionOutput = usedEnv.minion and usedEnv.minion.output or nil @@ -368,9 +372,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) everything = true, } fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { - cachedPlayerDB = cachedPlayerDB, - cachedEnemyDB = cachedEnemyDB, - cachedMinionDB = cachedMinionDB, + cachedPlayerDB = skillPassPlayerDB, + cachedEnemyDB = skillPassEnemyDB, + cachedMinionDB = skillPassMinionDB, env = fullEnv, accelerate = accelerationTbl, cacheGeneration = fullEnv.cacheGeneration, @@ -452,16 +456,19 @@ function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingF skills = true, everything = true, } or nil + local cachedSkillPrepassState = env.cachedSkillBuildPrepassState local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override, { - cachedPlayerDB = env.cachedPlayerDB, - cachedEnemyDB = env.cachedEnemyDB, - cachedMinionDB = env.cachedMinionDB, + cachedPlayerDB = cachedSkillPrepassState and cachedSkillPrepassState.cachedPlayerDB or env.cachedPlayerDB, + cachedEnemyDB = cachedSkillPrepassState and cachedSkillPrepassState.cachedEnemyDB or env.cachedEnemyDB, + cachedMinionDB = cachedSkillPrepassState and cachedSkillPrepassState.cachedMinionDB or env.cachedMinionDB, env = env.cachedSkillBuildEnv, accelerate = accelerationTbl, cacheGeneration = env.cacheGeneration, recursionGuards = recursionGuards, }) env.cachedSkillBuildEnv = fullEnv + local prepassState = cachedSkillPrepassState or calcs.performActorPrepass(fullEnv, { snapshotDBs = true }) + env.cachedSkillBuildPrepassState = prepassState -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops @@ -478,7 +485,7 @@ function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingF if activeSkillUUID == targetUUID then fullEnv.player.mainSkill = activeSkill fullEnv.requestedSkillUUID = targetUUID - calcs.perform(fullEnv, true) + calcs.performSkillPass(fullEnv, true, prepassState) fullEnv.requestedSkillUUID = nil recursionGuards.buildingSkills[guardKey] = nil return true From 265fdcea88217d1217767b318a8a8af363eafa0c Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 16:17:55 -0400 Subject: [PATCH 07/12] Restore FullDPS minion skill count guard --- src/Modules/Calcs.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index c18dfa7869..623bc5c371 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -279,8 +279,12 @@ function calcs.calcFullDPS(build, mode, override, specEnv) if minionOutput.CullMultiplier and minionOutput.CullMultiplier > 1 and minionOutput.CullMultiplier > fullDPS.cullingMulti then fullDPS.cullingMulti = minionOutput.CullMultiplier end - if infoMessage2 == "Skill Damage" then + if (activeSkill.activeEffect.grantedEffect.name:match("Absolution") and fullEnv.modDB:Flag(false, "Condition:AbsolutionSkillDamageCountedOnce")) + or (activeSkill.activeEffect.grantedEffect.name:match("Dominating Blow") and fullEnv.modDB:Flag(false, "Condition:DominatingBlowSkillDamageCountedOnce")) + or (activeSkill.activeEffect.grantedEffect.name:match("Holy Strike") and fullEnv.modDB:Flag(false, "Condition:HolyStrikeSkillDamageCountedOnce")) + or infoMessage2 == "Skill Damage" then activeSkillCount = 1 + activeSkill.infoMessage2 = "Skill Damage" end end From e861849f81aae28fbc2d7c45dd0eb89ea8458f08 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 16:24:45 -0400 Subject: [PATCH 08/12] Restore dev-style FullDPS execution semantics --- src/Modules/Calcs.lua | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 623bc5c371..3b8789750d 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -207,10 +207,6 @@ end function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil - local prepassState = calcs.performActorPrepass(fullEnv, { snapshotDBs = true }) - local skillPassPlayerDB = prepassState.cachedPlayerDB or cachedPlayerDB - local skillPassEnemyDB = prepassState.cachedEnemyDB or cachedEnemyDB - local skillPassMinionDB = prepassState.cachedMinionDB or cachedMinionDB local fullDPS = { combinedDPS = 0, @@ -233,16 +229,12 @@ function calcs.calcFullDPS(build, mode, override, specEnv) local igniteSource = "" local burningGroundSource = "" local causticGroundSource = "" - - -- initEnv() is the shared actor-global prepass for the FullDPS run. Each included - -- skill then runs through the hot skill-local path on the reused env, while perform() - -- continues to populate the slim cache for trigger/source reuse within the generation. for _, activeSkill in ipairs(fullEnv.player.activeSkillList) do if activeSkill.socketGroup and activeSkill.socketGroup.includeInFullDPS then local activeSkillCount, enabled = getActiveSkillCount(activeSkill) if enabled then fullEnv.player.mainSkill = activeSkill - calcs.performSkillPass(fullEnv, true, prepassState) + calcs.perform(fullEnv, true) usedEnv = fullEnv local output = usedEnv.player.output local minionOutput = usedEnv.minion and usedEnv.minion.output or nil @@ -376,9 +368,9 @@ function calcs.calcFullDPS(build, mode, override, specEnv) everything = true, } fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { - cachedPlayerDB = skillPassPlayerDB, - cachedEnemyDB = skillPassEnemyDB, - cachedMinionDB = skillPassMinionDB, + cachedPlayerDB = cachedPlayerDB, + cachedEnemyDB = cachedEnemyDB, + cachedMinionDB = cachedMinionDB, env = fullEnv, accelerate = accelerationTbl, cacheGeneration = fullEnv.cacheGeneration, @@ -453,26 +445,13 @@ function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingF end recursionGuards.buildingSkills[guardKey] = true - local accelerationTbl = env.cachedSkillBuildEnv and { - nodeAlloc = true, - requirementsItems = true, - requirementsGems = true, - skills = true, - everything = true, - } or nil - local cachedSkillPrepassState = env.cachedSkillBuildPrepassState local fullEnv, _, _, _ = calcs.initEnv(env.build, mode, env.override, { - cachedPlayerDB = cachedSkillPrepassState and cachedSkillPrepassState.cachedPlayerDB or env.cachedPlayerDB, - cachedEnemyDB = cachedSkillPrepassState and cachedSkillPrepassState.cachedEnemyDB or env.cachedEnemyDB, - cachedMinionDB = cachedSkillPrepassState and cachedSkillPrepassState.cachedMinionDB or env.cachedMinionDB, - env = env.cachedSkillBuildEnv, - accelerate = accelerationTbl, + cachedPlayerDB = env.cachedPlayerDB, + cachedEnemyDB = env.cachedEnemyDB, + cachedMinionDB = env.cachedMinionDB, cacheGeneration = env.cacheGeneration, recursionGuards = recursionGuards, }) - env.cachedSkillBuildEnv = fullEnv - local prepassState = cachedSkillPrepassState or calcs.performActorPrepass(fullEnv, { snapshotDBs = true }) - env.cachedSkillBuildPrepassState = prepassState -- env.limitedSkills contains a map of uuids that should be limited in calculation -- this is in order to prevent infinite recursion loops @@ -489,7 +468,7 @@ function calcs.buildActiveSkill(env, mode, skill, targetUUID, limitedProcessingF if activeSkillUUID == targetUUID then fullEnv.player.mainSkill = activeSkill fullEnv.requestedSkillUUID = targetUUID - calcs.performSkillPass(fullEnv, true, prepassState) + calcs.perform(fullEnv, true) fullEnv.requestedSkillUUID = nil recursionGuards.buildingSkills[guardKey] = nil return true From bb1d828f17547204bbc24a9e8d628d12c52fb5b8 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 16:47:29 -0400 Subject: [PATCH 09/12] Defer top-level FullDPS cache writes --- src/Modules/CalcPerform.lua | 4 +++- src/Modules/Calcs.lua | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index af1a707999..4cac5fe1ae 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -3611,7 +3611,9 @@ function calcs.performSkillPass(env, skipEHP, prepassState) end end - cacheData(env.requestedSkillUUID or cacheSkillUUID(env.player.mainSkill, env), env) + if env.requestedSkillUUID or not env.deferCacheWrites then + cacheData(env.requestedSkillUUID or cacheSkillUUID(env.player.mainSkill, env), env) + end end function calcs.perform(env, skipEHP) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 3b8789750d..2e0ea751bf 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -207,6 +207,7 @@ end function calcs.calcFullDPS(build, mode, override, specEnv) local fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB = calcs.initEnv(build, mode, override, specEnv) local usedEnv = nil + fullEnv.deferCacheWrites = true local fullDPS = { combinedDPS = 0, From 7a4f6280bae2aa5a23a2431eab69bab794710874 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 16:57:46 -0400 Subject: [PATCH 10/12] Reuse hot-path skill calc containers --- src/Modules/CalcPerform.lua | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 4cac5fe1ae..01e61c03ca 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -19,6 +19,8 @@ local m_huge = math.huge local bor = bit.bor local band = bit.band local bnot = bit.bnot +local wipeTable = wipeTable +local graphNodeTag = graphNodeTag --- getCachedOutputValue --- retrieves a value specified by key from a cached version of skill @@ -1062,6 +1064,26 @@ function calcs.actionSpeedMod(actor) return actionSpeedMod end +local function resetReusableModList(modList, parent) + wipeTable(modList) + modList.parent = parent or false + modList.actor = parent and parent.actor or { } + modList.multipliers = { } + modList.conditions = { } + graphNodeTag(modList, modList._className or "ModList") + return modList +end + +local function resetReusableModDB(modDB, parent, actor) + wipeTable(modDB.mods) + modDB.parent = parent or false + modDB.actor = actor or (parent and parent.actor) or modDB.actor or { } + modDB.multipliers = { } + modDB.conditions = { } + graphNodeTag(modDB, modDB._className or "ModDB") + return modDB +end + -- Finalises the environment and performs the stat calculations: -- 1. Merges keystone modifiers -- 2. Initialises minion skills @@ -1108,10 +1130,18 @@ function calcs.performSkillPass(env, skipEHP, prepassState) -- Build minion skills for _, activeSkill in ipairs(env.player.activeSkillList) do - activeSkill.skillModList = new("ModList", activeSkill.baseSkillModList) + if activeSkill.skillModList and activeSkill.skillModList ~= activeSkill.baseSkillModList then + activeSkill.skillModList = resetReusableModList(activeSkill.skillModList, activeSkill.baseSkillModList) + else + activeSkill.skillModList = new("ModList", activeSkill.baseSkillModList) + end if activeSkill.minion then - activeSkill.minion.modDB = new("ModDB") - activeSkill.minion.modDB.actor = activeSkill.minion + if activeSkill.minion.modDB then + resetReusableModDB(activeSkill.minion.modDB, false, activeSkill.minion) + else + activeSkill.minion.modDB = new("ModDB") + activeSkill.minion.modDB.actor = activeSkill.minion + end calcs.createMinionSkills(env, activeSkill) activeSkill.skillPartName = activeSkill.minion.mainSkill.activeEffect.grantedEffect.name end From a9926ebb51248306aa463248cdddc6cecca44c30 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 17:19:06 -0400 Subject: [PATCH 11/12] Fast-path FullDPS env resets --- src/Modules/CalcSetup.lua | 85 +++++++++++++++++++++++++++++---------- src/Modules/Calcs.lua | 17 +------- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 1137caf7f4..b47e904218 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -345,6 +345,40 @@ local function addBestSupport(supportEffect, appliedSupportList, mode) end end +local function resetActiveSkillsForAcceleratedEnv(env) + -- Wipe skillData and readd required data; the rest of the data will be added by the + -- remaining calc passes. This prevents iterative calculations from carrying stale skillData. + for _, activeSkill in pairs(env.player.activeSkillList) do + local skillData = activeSkill.skillData + local manaReservationPercent = skillData.manaReservationPercent + local cooldown = skillData.cooldown + local storedUses = skillData.storedUses + local critChance = skillData.CritChance + local attackTime = skillData.attackTime + local attackSpeedMultiplier = skillData.attackSpeedMultiplier + local totemLevel = skillData.totemLevel + local damageEffectiveness = skillData.damageEffectiveness + activeSkill.skillData = { } + for _, value in ipairs(env.modDB:List(activeSkill.skillCfg, "SkillData")) do + activeSkill.skillData[value.key] = value.value + end + for _, value in ipairs(activeSkill.skillModList:List(activeSkill.skillCfg, "SkillData")) do + activeSkill.skillData[value.key] = value.value + end + -- These mods were modified with special expressions in buildActiveSkillModList(); use + -- the previous values here to avoid rebuilding the full active-skill graph. + activeSkill.skillData.manaReservationPercent = manaReservationPercent + activeSkill.skillData.cooldown = cooldown + activeSkill.skillData.storedUses = storedUses + activeSkill.skillData.CritChance = critChance + activeSkill.skillData.attackTime = attackTime + activeSkill.skillData.attackSpeedMultiplier = attackSpeedMultiplier + activeSkill.skillData.soulPreventionDuration = activeSkill.soulPreventionDuration + activeSkill.skillData.totemLevel = totemLevel + activeSkill.skillData.damageEffectiveness = damageEffectiveness + end +end + -- Initialise environment: -- 1. Initialises the player and enemy modifier databases -- 2. Merges modifiers for all items @@ -1767,28 +1801,7 @@ function calcs.initEnv(build, mode, override, specEnv) calcs.buildActiveSkillModList(env, activeSkill) end else - -- Wipe skillData and readd required data the rest of the data will be added by the rest of code this stops iterative calculations on skillData not being reset - for _, activeSkill in pairs(env.player.activeSkillList) do - local skillData = copyTable(activeSkill.skillData, true) - activeSkill.skillData = { } - for _, value in ipairs(env.modDB:List(activeSkill.skillCfg, "SkillData")) do - activeSkill.skillData[value.key] = value.value - end - for _, value in ipairs(activeSkill.skillModList:List(activeSkill.skillCfg, "SkillData")) do - activeSkill.skillData[value.key] = value.value - end - -- These mods were modified with special expressions in buildActiveSkillModList() use old one to avoid more calculations - activeSkill.skillData.manaReservationPercent = skillData.manaReservationPercent - activeSkill.skillData.cooldown = skillData.cooldown - activeSkill.skillData.storedUses = skillData.storedUses - activeSkill.skillData.CritChance = skillData.CritChance - activeSkill.skillData.attackTime = skillData.attackTime - activeSkill.skillData.attackSpeedMultiplier = skillData.attackSpeedMultiplier - activeSkill.skillData.soulPreventionDuration = activeSkill.soulPreventionDuration - activeSkill.skillData.totemLevel = skillData.totemLevel - activeSkill.skillData.damageEffectiveness = skillData.damageEffectiveness - activeSkill.skillData.manaReservationPercent = skillData.manaReservationPercent - end + resetActiveSkillsForAcceleratedEnv(env) end -- Merge Requirements Tables @@ -1796,3 +1809,31 @@ function calcs.initEnv(build, mode, override, specEnv) return env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB end + +function calcs.resetEnvForFastSkillLoop(env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB, override) + override = override or env.override or { } + ClearMatchKeywordFlagsCache() + wipeEnv(env, { + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skills = true, + everything = true, + }) + env.modDB.parent = cachedPlayerDB + env.enemyDB.parent = cachedEnemyDB + if cachedMinionDB and env.minion then + env.minion.modDB.parent = cachedMinionDB + end + env.cachedPlayerDB = cachedPlayerDB + env.cachedEnemyDB = cachedEnemyDB + env.cachedMinionDB = cachedMinionDB + if override.conditions then + for _, flag in ipairs(override.conditions) do + env.modDB.conditions[flag] = true + end + end + resetActiveSkillsForAcceleratedEnv(env) + env.requirementsTable = tableConcat(env.requirementsTableItems, env.requirementsTableGems) + return env +end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 2e0ea751bf..5862ac14aa 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -361,22 +361,7 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullDPS.cullingMulti = output.CullMultiplier end - local accelerationTbl = { - nodeAlloc = true, - requirementsItems = true, - requirementsGems = true, - skills = true, - everything = true, - } - fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { - cachedPlayerDB = cachedPlayerDB, - cachedEnemyDB = cachedEnemyDB, - cachedMinionDB = cachedMinionDB, - env = fullEnv, - accelerate = accelerationTbl, - cacheGeneration = fullEnv.cacheGeneration, - recursionGuards = fullEnv.recursionGuards, - }) + fullEnv = calcs.resetEnvForFastSkillLoop(fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB, override) end end end From 2691414a9bfeb21ad4ed048e7bf075e9afd3c021 Mon Sep 17 00:00:00 2001 From: Nostrademous Date: Fri, 13 Mar 2026 17:36:26 -0400 Subject: [PATCH 12/12] Revert "Fast-path FullDPS env resets" This reverts commit a9926ebb51248306aa463248cdddc6cecca44c30. --- src/Modules/CalcSetup.lua | 85 ++++++++++----------------------------- src/Modules/Calcs.lua | 17 +++++++- 2 files changed, 38 insertions(+), 64 deletions(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index b47e904218..1137caf7f4 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -345,40 +345,6 @@ local function addBestSupport(supportEffect, appliedSupportList, mode) end end -local function resetActiveSkillsForAcceleratedEnv(env) - -- Wipe skillData and readd required data; the rest of the data will be added by the - -- remaining calc passes. This prevents iterative calculations from carrying stale skillData. - for _, activeSkill in pairs(env.player.activeSkillList) do - local skillData = activeSkill.skillData - local manaReservationPercent = skillData.manaReservationPercent - local cooldown = skillData.cooldown - local storedUses = skillData.storedUses - local critChance = skillData.CritChance - local attackTime = skillData.attackTime - local attackSpeedMultiplier = skillData.attackSpeedMultiplier - local totemLevel = skillData.totemLevel - local damageEffectiveness = skillData.damageEffectiveness - activeSkill.skillData = { } - for _, value in ipairs(env.modDB:List(activeSkill.skillCfg, "SkillData")) do - activeSkill.skillData[value.key] = value.value - end - for _, value in ipairs(activeSkill.skillModList:List(activeSkill.skillCfg, "SkillData")) do - activeSkill.skillData[value.key] = value.value - end - -- These mods were modified with special expressions in buildActiveSkillModList(); use - -- the previous values here to avoid rebuilding the full active-skill graph. - activeSkill.skillData.manaReservationPercent = manaReservationPercent - activeSkill.skillData.cooldown = cooldown - activeSkill.skillData.storedUses = storedUses - activeSkill.skillData.CritChance = critChance - activeSkill.skillData.attackTime = attackTime - activeSkill.skillData.attackSpeedMultiplier = attackSpeedMultiplier - activeSkill.skillData.soulPreventionDuration = activeSkill.soulPreventionDuration - activeSkill.skillData.totemLevel = totemLevel - activeSkill.skillData.damageEffectiveness = damageEffectiveness - end -end - -- Initialise environment: -- 1. Initialises the player and enemy modifier databases -- 2. Merges modifiers for all items @@ -1801,7 +1767,28 @@ function calcs.initEnv(build, mode, override, specEnv) calcs.buildActiveSkillModList(env, activeSkill) end else - resetActiveSkillsForAcceleratedEnv(env) + -- Wipe skillData and readd required data the rest of the data will be added by the rest of code this stops iterative calculations on skillData not being reset + for _, activeSkill in pairs(env.player.activeSkillList) do + local skillData = copyTable(activeSkill.skillData, true) + activeSkill.skillData = { } + for _, value in ipairs(env.modDB:List(activeSkill.skillCfg, "SkillData")) do + activeSkill.skillData[value.key] = value.value + end + for _, value in ipairs(activeSkill.skillModList:List(activeSkill.skillCfg, "SkillData")) do + activeSkill.skillData[value.key] = value.value + end + -- These mods were modified with special expressions in buildActiveSkillModList() use old one to avoid more calculations + activeSkill.skillData.manaReservationPercent = skillData.manaReservationPercent + activeSkill.skillData.cooldown = skillData.cooldown + activeSkill.skillData.storedUses = skillData.storedUses + activeSkill.skillData.CritChance = skillData.CritChance + activeSkill.skillData.attackTime = skillData.attackTime + activeSkill.skillData.attackSpeedMultiplier = skillData.attackSpeedMultiplier + activeSkill.skillData.soulPreventionDuration = activeSkill.soulPreventionDuration + activeSkill.skillData.totemLevel = skillData.totemLevel + activeSkill.skillData.damageEffectiveness = skillData.damageEffectiveness + activeSkill.skillData.manaReservationPercent = skillData.manaReservationPercent + end end -- Merge Requirements Tables @@ -1809,31 +1796,3 @@ function calcs.initEnv(build, mode, override, specEnv) return env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB end - -function calcs.resetEnvForFastSkillLoop(env, cachedPlayerDB, cachedEnemyDB, cachedMinionDB, override) - override = override or env.override or { } - ClearMatchKeywordFlagsCache() - wipeEnv(env, { - nodeAlloc = true, - requirementsItems = true, - requirementsGems = true, - skills = true, - everything = true, - }) - env.modDB.parent = cachedPlayerDB - env.enemyDB.parent = cachedEnemyDB - if cachedMinionDB and env.minion then - env.minion.modDB.parent = cachedMinionDB - end - env.cachedPlayerDB = cachedPlayerDB - env.cachedEnemyDB = cachedEnemyDB - env.cachedMinionDB = cachedMinionDB - if override.conditions then - for _, flag in ipairs(override.conditions) do - env.modDB.conditions[flag] = true - end - end - resetActiveSkillsForAcceleratedEnv(env) - env.requirementsTable = tableConcat(env.requirementsTableItems, env.requirementsTableGems) - return env -end diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 5862ac14aa..2e0ea751bf 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -361,7 +361,22 @@ function calcs.calcFullDPS(build, mode, override, specEnv) fullDPS.cullingMulti = output.CullMultiplier end - fullEnv = calcs.resetEnvForFastSkillLoop(fullEnv, cachedPlayerDB, cachedEnemyDB, cachedMinionDB, override) + local accelerationTbl = { + nodeAlloc = true, + requirementsItems = true, + requirementsGems = true, + skills = true, + everything = true, + } + fullEnv, _, _, _ = calcs.initEnv(build, mode, override, { + cachedPlayerDB = cachedPlayerDB, + cachedEnemyDB = cachedEnemyDB, + cachedMinionDB = cachedMinionDB, + env = fullEnv, + accelerate = accelerationTbl, + cacheGeneration = fullEnv.cacheGeneration, + recursionGuards = fullEnv.recursionGuards, + }) end end end