From f90f29504b910af9cd61ce8bf9dce596b1d908ce Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:04:06 +0300 Subject: [PATCH 01/10] Improve new() performance --- spec/System/TestCommon_spec.lua | 4 +- src/Modules/Common.lua | 165 ++++++++++++++++++-------------- 2 files changed, 93 insertions(+), 76 deletions(-) diff --git a/spec/System/TestCommon_spec.lua b/spec/System/TestCommon_spec.lua index 61a848e14e8..4ff1906d83c 100644 --- a/spec/System/TestCommon_spec.lua +++ b/spec/System/TestCommon_spec.lua @@ -5,7 +5,7 @@ describe("Common", function() function ParentClass:ConstructorTestParentClass() return self end - local ChildClass = newClass("ConstructorTestProblemChildClass", "ConstructorTestParentClass") + local ChildClass = newClass("ConstructorTestProblemChild", "ConstructorTestParentClass") function ChildClass:ConstructorTestProblemChild() -- Intentionally does not call self:ConstructorTestParentClass() return self @@ -40,7 +40,7 @@ describe("Common", function() return self end - local ChildClass = newClass("ConstructorTestProblemChildClass", "ConstructorTestParentClass") + local ChildClass = newClass("ConstructorTestProblemChild", "ConstructorTestParentClass") function ChildClass:ConstructorTestProblemChild() self.ConstructorTestParentClass() return self diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index 20e3346330e..d9f71152821 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -76,6 +76,25 @@ local function getClass(className) return class end +-- wrap constructor to check that the constructors for all parent and superparent classes have been called +local function wrapConstructor(class, className, originalFunc) + return function(self, ...) + local ret = originalFunc(self, ...) + if class._parents then + for parent in pairs(class._superParents) do + if parent[parent._className] and not self._parentInit[parent] then + error("Parent class '" .. + parent._className .. "' of class '" .. className .. "' must be initialised") + end + end + end + if not ret then + error(string.format("Class %s constructor did not return a value", className)) + end + return ret + end +end + ---@generic T ---@param className `T` ---@param ... string parent class names @@ -90,8 +109,11 @@ function newClass(className, ...) end return obj end + -- a list of metatables. one for each parent + class._metaList = {} class._className = className local numVarArg = select("#", ...) + local parentIndex if numVarArg > 0 then -- Build list of parent classes class._parents = { } @@ -102,21 +124,79 @@ function newClass(className, ...) class._superParents = { } addSuperParents(class, class) -- Set up inheritance - setmetatable(class, { - __index = function(self, key) - for _, parent in ipairs(class._parents) do - local val = parent[key] - if val ~= nil then - self[key] = val - return val - end + function parentIndex(self, key) + for _, parent in ipairs(class._parents) do + local val = parent[key] + if val ~= nil then + rawset(self, key, val) + return val end end - }) + end end + setmetatable(class, { + __index = parentIndex, + __newindex = function(self, k, v) + if k == className then + -- Check that the constructors for all parent and superparent classes have been called + v = wrapConstructor(class, className, v) + end + rawset(self, k, v) + end + }) + class._unconstructedMeta = { + __index = function(obj, key) + if key == className then + setmetatable(obj, class) + return class[className] + end + error(s_format( + "Object of class '%s' was used before it was constructed (accessed '%s'). Did you forget to call new(\"%s\"):%s()?", + className, tostring(key), className, className)) + end, + } return class end +-- avoid rebuilding metatables constantly. this is done by caching class-parent pair metatables +local function getMeta(class, parent) + local metaList = rawget(class, "_metaList") + local meta = metaList[parent] + if not meta then + local parentName = parent._className + meta = { + __index = function(proxy, key) + local object = rawget(proxy, "_object") + local v = rawget(object, key) + if v ~= nil then + return v + else + return parent[key] + end + end, + __newindex = function(proxy, k, v) + local object = rawget(proxy, "_object") + object[k] = v + end, + __call = function(proxy, self, ...) + local object = rawget(proxy, "_object") + if not parent[parentName] then + error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has no constructor") + end + if object._parentInit[parent] then + error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has already been initialised") + end + if self ~= object then + error(string.format("Parent class %s constructor of class %s was not provided self. Are you perhaps calling it with self.%s instead of self:%s?", parentName, class._className, parentName, parentName)) + end + parent[parent._className](self, ...) + object._parentInit[parent] = true + end, + } + metaList[parent] = meta + end + return meta +end ---@generic T ---@param className `T` ---@param extraArg nil Never pass extra parameters. Defined purely to guard against old syntax. @@ -130,77 +210,14 @@ function new(className, extraArg) end local class = getClass(className) -- protect against calling new("Foo") without calling :Foo() - local object - if class[className] then - if not rawget(class, "_unconstructedMeta") then - class._unconstructedMeta = { - __index = function(obj, key) - if key == className then - setmetatable(obj, class) - return class[className] - end - error(s_format( - "Object of class '%s' was used before it was constructed (accessed '%s'). Did you forget to call new(\"%s\"):%s()?", - className, tostring(key), className, className)) - end, - } - end - object = setmetatable({}, class._unconstructedMeta) - else - object = setmetatable({}, class) - end + local object = setmetatable({}, class._unconstructedMeta or class) object.Object = object if class._parents then -- Add parent and superparent class proxies object._parentInit = { } for parent in pairs(class._superParents) do - local proxyMeta = { - __index = function(self, key) - local v = rawget(object, key) - if v ~= nil then - return v - else - return parent[key] - end - end, - __newindex = object, - __call = function(_, self, ...) - if not parent[parent._className] then - error("Parent class '"..parent._className.."' of class '"..class._className.."' has no constructor") - end - if object._parentInit[parent] then - error("Parent class '"..parent._className.."' of class '"..class._className.."' has already been initialised") - end - if self ~= object then - error(string.format("Parent class %s constructor of class %s was not provided self. Are you perhaps calling it with self.%s instead of self:%s?", parent._className, className, parent._className, parent._className)) - end - parent[parent._className](self, ...) - object._parentInit[parent] = true - end, - } - object[parent._className] = setmetatable(proxyMeta, proxyMeta) - end - end - - if class[className] and not rawget(class, "_constructorInitialised") then - local originalFunc = class[className] - class[className] = function(self, ...) - local ret = originalFunc(self, ...) - if class._parents then - -- Check that the constructors for all parent and superparent classes have been called - for parent in pairs(class._superParents) do - if parent[parent._className] and not self._parentInit[parent] then - error("Parent class '" .. - parent._className .. "' of class '" .. className .. "' must be initialised") - end - end - end - if not ret then - error(string.format("Class %s constructor did not return a value", className)) - end - return ret + object[parent._className] = setmetatable({ _object = object }, getMeta(class, parent)) end - class._constructorInitialised = true end return object end From 267d4bc0518b371c3ed0278909147f6b8cc8d587 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:36:05 +0300 Subject: [PATCH 02/10] Improve buildModListForNode (10%-ish) --- src/Modules/CalcSetup.lua | 111 ++++++++++++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 240cafb8e05..e91d7f9de8d 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -123,50 +123,117 @@ function calcs.buildModListForNode(env, node) end -- Run first pass radius jewels - for _, rad in pairs(env.radiusJewelList) do - if rad.type == "Other" and rad.nodes[node.id] and rad.nodes[node.id].type ~= "Mastery" then - rad.func(node, modList, rad.data) + for i = 1, #env.radiusJewelList do + local rad = env.radiusJewelList[i] + if rad.type == "Other" then + local radNode = rad.nodes[node.id] + if radNode and radNode.type ~= "Mastery" then + rad.func(node, modList, rad.data) + end end end - if modList:Flag(nil, "PassiveSkillHasNoEffect") or (env.allocNodes[node.id] and modList:Flag(nil, "AllocatedPassiveSkillHasNoEffect")) then + -- prefilter the modlist so that every :Flag() call does not have to go through the entire mod list + local hasNoEffect, hasAllocNoEffect, hasScale, hasOtherEffect, hasExtraSkill, hasExplode + for i = 1, #modList do + local name = modList[i].name + if name == "PassiveSkillHasNoEffect" then + hasNoEffect = true + elseif name == "AllocatedPassiveSkillHasNoEffect" then + hasAllocNoEffect = true + elseif name == "PassiveSkillEffect" then + hasScale = true + elseif name == "PassiveSkillHasOtherEffect" then + hasOtherEffect = true + elseif name == "ExtraSkill" then + hasExtraSkill = true + elseif name == "CanExplode" then + hasExplode = true + end + end + + if (hasNoEffect and modList:Flag(nil, "PassiveSkillHasNoEffect")) or (env.allocNodes[node.id] and (hasAllocNoEffect and modList:Flag(nil, "AllocatedPassiveSkillHasNoEffect"))) then wipeTable(modList) + hasScale = false + hasOtherEffect = nil + hasExtraSkill = nil + hasExplode = nil end -- Apply effect scaling - local scale = calcLib.mod(modList, nil, "PassiveSkillEffect") - if scale ~= 1 then - local scaledList = new("ModList"):ModList() - scaledList:ScaleAddList(modList, scale) - modList = scaledList + if hasScale then + local scale = calcLib.mod(modList, nil, "PassiveSkillEffect") + if scale ~= 1 then + local scaledList = new("ModList"):ModList() + scaledList:ScaleAddList(modList, scale) + modList = scaledList + end end -- Run second pass radius jewels - for _, rad in pairs(env.radiusJewelList) do + local rescan = false + for i = 1, #env.radiusJewelList do + local rad = env.radiusJewelList[i] if rad.nodes[node.id] and rad.nodes[node.id].type ~= "Mastery" and (rad.type == "Threshold" or (rad.type == "Self" and env.allocNodes[node.id]) or (rad.type == "SelfUnalloc" and not env.allocNodes[node.id])) then rad.func(node, modList, rad.data) + rescan = true + hasOtherEffect = nil + hasExtraSkill = nil + hasExplode = nil end end - if modList:Flag(nil, "PassiveSkillHasOtherEffect") then - for i, mod in ipairs(modList:List(skillCfg, "NodeModifier")) do - if i == 1 then wipeTable(modList) end + if rescan then + for i = 1, #modList do + local name = modList[i].name + if name == "PassiveSkillHasOtherEffect" then + hasOtherEffect = true + elseif name == "ExtraSkill" then + hasExtraSkill = true + elseif name == "CanExplode" then + hasExplode = true + end + end + end + + if hasOtherEffect and modList:Flag(nil, "PassiveSkillHasOtherEffect") then + local newMods = modList:List(nil, "NodeModifier") + for i = 1, #newMods do + local mod = newMods[i] + if i == 1 then + wipeTable(modList) + hasExtraSkill = nil + hasExplode = nil + end + if mod.name == "ExtraSkill" then + hasExtraSkill = true + elseif mod.name == "CanExplode" then + hasExplode = true + end modList:AddMod(mod.mod) end end - node.grantedSkills = { } - for _, skill in ipairs(modList:List(nil, "ExtraSkill")) do - if skill.name ~= "Unknown" then - t_insert(node.grantedSkills, { - skillId = skill.skillId, - level = skill.level, - source = "Tree:"..node.id - }) + node.grantedSkills = {} + if hasExtraSkill then + local list = modList:List(nil, "ExtraSkill") + for i = 1, #list do + local skill = list[i] + if skill.name ~= "Unknown" then + t_insert(node.grantedSkills, { + skillId = skill.skillId, + level = skill.level, + source = "Tree:" .. node.id + }) + end end end - return modList, modList:Flag(nil, "CanExplode") and node + if hasExplode then + return modList, modList:Flag(nil, "CanExplode") and node + else + return modList + end end -- Build list of modifiers from the listed tree nodes From f7c54127dda1fc4a8752a3bfcecadebbb6d119a7 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:42:53 +0300 Subject: [PATCH 03/10] Avoid allocating globalLimits on modb calls right away (4%) --- src/Classes/ModDB.lua | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Classes/ModDB.lua b/src/Classes/ModDB.lua index c71599b5a02..ffaf1e5ea59 100644 --- a/src/Classes/ModDB.lua +++ b/src/Classes/ModDB.lua @@ -135,7 +135,7 @@ end function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, source, ...) local result = 0 - local globalLimits = { } + local globalLimits for i = 1, select('#', ...) do local modList = self.mods[select(i, ...)] if modList then @@ -143,6 +143,9 @@ function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, sour local mod = modList[i] if mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or ( mod.source and mod.source:match("[^:]+") == source )) then if mod[1] then + if not globalLimits then + globalLimits = {} + end local value = context:EvalMod(mod, cfg, globalLimits) or 0 result = result + value else @@ -161,7 +164,7 @@ end function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) local result = 1 local modPrecision = nil - local globalLimits = { } + local globalLimits for i = 1, select('#', ...) do local modList = self.mods[select(i, ...)] local modResult = 1 --The more multipliers for each mod are computed to the nearest percent then applied. @@ -171,6 +174,9 @@ function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) if mod.type == "MORE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then local value if mod[1] then + if not globalLimits then + globalLimits = {} + end value = context:EvalMod(mod, cfg, globalLimits) or 0 else value = mod.value or 0 @@ -270,7 +276,7 @@ function ModDBClass:ListInternal(context, result, cfg, flags, keywordFlags, sour end function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, ...) - local globalLimits = { } + local globalLimits for i = 1, select('#', ...) do local modName = select(i, ...) local modList = self.mods[modName] @@ -280,6 +286,9 @@ function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywo if (mod.type == modType or not modType) and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then local value if mod[1] then + if not globalLimits then + globalLimits = {} + end value = context:EvalMod(mod, cfg, globalLimits) else value = mod.value From 04ce75e8dc6f5d85ed9dc53a3a937ea4e1855109 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:57:48 +0300 Subject: [PATCH 04/10] Reuse modlists in buildModListForNode (10-15)% high variance --- src/Modules/CalcSetup.lua | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index e91d7f9de8d..8d0b37042dd 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -114,8 +114,21 @@ function calcs.initModDB(env, modDB) modDB.conditions["Effective"] = env.mode_effective end -function calcs.buildModListForNode(env, node) - local modList = new("ModList"):ModList() +-- Recycle a modlist so that we do not allocate many tables for each node. +local function resetModList(list) + for i = #list, 1, -1 do + list[i] = nil + end + list.multipliers = wipeTable(list.multipliers) + list.conditions = wipeTable(list.conditions) + list.actor = wipeTable(list.actor) + list.parent = false + return list +end + +---@param reuse table|nil A ModList to recycle instead of allocating. Only safe when the caller discards the result. +function calcs.buildModListForNode(env, node, reuse) + local modList = reuse and resetModList(reuse) or new("ModList"):ModList() if node.type == "Keystone" then modList:AddMod(node.keystoneMod) else @@ -214,7 +227,7 @@ function calcs.buildModListForNode(env, node) end end - node.grantedSkills = {} + node.grantedSkills = wipeTable(node.grantedSkills) if hasExtraSkill then local list = modList:List(nil, "ExtraSkill") for i = 1, #list do @@ -247,8 +260,12 @@ function calcs.buildModListForNodeList(env, nodeList, finishJewels) -- Add node modifiers local modList = new("ModList"):ModList() local explodeSources = {} + -- Outside MAIN mode the per-node list is merged into modList and then + -- dropped, so a single list can be recycled for every node instead of + -- allocating one each time. + local scratch = env.mode ~= "MAIN" and new("ModList"):ModList() or nil for _, node in pairs(nodeList) do - local nodeModList, explode = calcs.buildModListForNode(env, node) + local nodeModList, explode = calcs.buildModListForNode(env, node, scratch) t_insert(explodeSources, explode) modList:AddList(nodeModList) if env.mode == "MAIN" then @@ -259,7 +276,7 @@ function calcs.buildModListForNodeList(env, nodeList, finishJewels) if finishJewels then -- Process extra radius nodes; these are unallocated nodes near conversion or threshold jewels that need to be processed for _, node in pairs(env.extraRadiusNodeList) do - local nodeModList = calcs.buildModListForNode(env, node) + local nodeModList = calcs.buildModListForNode(env, node, scratch) if env.mode == "MAIN" then node.finalModList = nodeModList end From e162f79ef3abb2debd58b6fb24544359f9c8c1ef Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:43:11 +0300 Subject: [PATCH 05/10] Fix passiveskillhasothereffect --- src/Modules/CalcSetup.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 8d0b37042dd..c934d008351 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -212,7 +212,7 @@ function calcs.buildModListForNode(env, node, reuse) if hasOtherEffect and modList:Flag(nil, "PassiveSkillHasOtherEffect") then local newMods = modList:List(nil, "NodeModifier") for i = 1, #newMods do - local mod = newMods[i] + local mod = newMods[i].mod if i == 1 then wipeTable(modList) hasExtraSkill = nil @@ -223,7 +223,7 @@ function calcs.buildModListForNode(env, node, reuse) elseif mod.name == "CanExplode" then hasExplode = true end - modList:AddMod(mod.mod) + modList:AddMod(mod) end end From 54c2d2ad5768f8e80c4d687711af9f46dc58f73a Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:50:18 +0300 Subject: [PATCH 06/10] Optimise path building: - Cache mastery options - Use multi-source bfs for closest node calculation - Look up impossible escape keystone instead of scanning every node - Avoid scanning every jewel for each node in spec --- src/Classes/ItemsTab.lua | 7 +- src/Classes/PassiveSpec.lua | 256 ++++++++++++++++++++++-------------- 2 files changed, 166 insertions(+), 97 deletions(-) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index b9dd8b79cca..26488c00b23 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4435,6 +4435,8 @@ end local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementItem) local tempItemId + local socket = require("socket.core") + local start = socket.gettime() * 1000 local spec = cloneSpecForJewelComparison(itemsTab.build.spec) if replacementItem then if replacementItem.id and itemsTab.items[replacementItem.id] == replacementItem then @@ -4450,10 +4452,13 @@ local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementIte else spec.jewels[compareSlot.nodeId] = nil end - + ConPrintf("spec build time: %.2f ms", socket.gettime() * 1000 - start) + start = socket.gettime() * 1000 local ok, err = xpcall(function() spec:BuildAllDependsAndPaths() end, debug.traceback) + ConPrintf("build all depends: %.2f ms", socket.gettime() * 1000 - start) + start = socket.gettime() * 1000 if tempItemId then itemsTab.items[tempItemId] = nil end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 1a31f414fd4..ffae06fd53e 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -36,6 +36,7 @@ function PassiveSpecClass:Init(treeVersion, convert) self.tree = main:LoadTree(treeVersion) self.ignoredNodes = { } self.ignoreAllocatingSubgraph = false + self.checkNodeLinks = false local previousTreeNodes = { } if convert then previousTreeNodes = self.build.spec.nodes @@ -909,57 +910,79 @@ function PassiveSpecClass:GetSocketedJewel(nodeId) return self:GetJewel(itemId) end --- Perform a breadth-first search of the tree, starting from this node, and determine if it is the closest node to any other nodes -function PassiveSpecClass:BuildPathFromNode(root) - root.pathDist = 0 - root.path = { } - local queue = { root } - local o, i = 1, 2 -- Out, in - while o < i do - -- Nodes are processed in a queue, until there are no nodes left - -- All nodes that are 1 node away from the root will be processed first, then all nodes that are 2 nodes away, etc - local node = queue[o] - o = o + 1 - local curDist = node.pathDist - -- Iterate through all nodes that are connected to this one - for index, other in ipairs(node.linked) do - -- Cluster subgraph rebuilds can replace node objects while retaining IDs. - -- Normalize stale link references to the canonical node object. - local canonicalNode = other and other.id and self.nodes[other.id] - if not canonicalNode then - other = nil - elseif canonicalNode ~= other then - node.linked[index] = canonicalNode - other = canonicalNode - end - if other then - -- Paths must obey these rules: - -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) - -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree - -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree - -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas - -- 3. They must not pass away from mastery nodes - local otherPathDist = other.pathDist or 1000 - if node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and otherPathDist > curDist and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) then - -- The shortest path to the other node is through the current node - other.pathDist = curDist - if not other.alloc then - other.pathDist = other.pathDist + 1 - end - other.path = wipeTable(other.path) - other.path[1] = other - for i, n in ipairs(node.path) do - other.path[i+1] = n - end - -- Add the other node to the end of the queue - queue[i] = other - i = i + 1 +local function traversable(curDist, node, other) + -- Paths must obey these rules: + -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) + -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree + -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree + -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas + -- 3. They must not pass away from mastery nodes + return node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) +end + +-- Cluster subgraph rebuilds can replace node objects while retaining IDs. +-- Normalize stale link references to the canonical node object. +function PassiveSpecClass:NormalizeNodeLinks(node) + local linked = node.linked + for index = #linked, 1, -1 do + local other = linked[index] + local canonicalNode = other and other.id and self.nodes[other.id] + if not canonicalNode then + t_remove(linked, index) + elseif canonicalNode ~= other then + linked[index] = canonicalNode + end + end +end + +-- Multi-source 0-1 BFS to find what other root (i.e., allocated) nodes each node is closest to +---@param roots Node[] A list of currently allocated, and other nodes which should be considered as the sources of distances. +function PassiveSpecClass:BuildNodePathsToRootNodes(roots) + -- A dequeue. We will keep a pointer to the start and end of this to keep + -- track of its length + local q = {} + for _, node in ipairs(roots) do + node.pathDist = 0 + node.path = wipeTable(node.path) + t_insert(q, node) + end + local qStart = 1 + local qLen = #q + while qStart <= qLen do + -- pop front + local node = q[qStart] + qStart = qStart + 1 + local linked = node.linked + local nodeDist = node.pathDist + local nodePath = node.path + for i = 1, #linked do + local other = linked[i] + local weight = other.alloc and 0 or 1 + local distViaNode = nodeDist + weight + if (distViaNode < (other.pathDist or math.huge)) + and traversable(nodeDist, node, other) then + -- if this node is free, push it to the front so that it can shorten paths + if weight == 0 then + qStart = qStart - 1 + q[qStart] = other + -- otherwise push to back + else + qLen = qLen + 1 + q[qLen] = other end + + -- save path and distance for the node + other.pathDist = distViaNode + local path = wipeTable(other.path) + path[1] = other + for i = 1, #nodePath do + path[i + 1] = nodePath[i] + end + other.path = path end end end end - -- Determine this node's distance from the class' start -- Only allocated nodes can be traversed function PassiveSpecClass:SetNodeDistanceToClassStart(root) @@ -1064,23 +1087,38 @@ function PassiveSpecClass:BuildSplitPersonalityPath() self.splitPersonalityPath = splitPersonalityPath end +-- cache the unallocated mastery option data as it doesn't change based on the build +-- adds stat descriptions and other node data for unallocated mastery nodes function PassiveSpecClass:AddMasteryEffectOptionsToNode(node) - node.sd = {} - if node.masteryEffects ~= nil and #node.masteryEffects > 0 then - for _, effect in ipairs(node.masteryEffects) do - effect = self.tree.masteryEffects[effect.effect] - local startIndex = #node.sd + 1 - for _, sd in ipairs(effect.sd) do - t_insert(node.sd, sd) + local treeNode = self.tree.nodes[node.id] + local cacheNode = treeNode and treeNode.masteryCache + if not cacheNode then + cacheNode = { id = node.id, sd = {} } + if node.masteryEffects ~= nil and #node.masteryEffects > 0 then + for _, effect in ipairs(node.masteryEffects) do + effect = self.tree.masteryEffects[effect.effect] + for _, sd in ipairs(effect.sd) do + t_insert(cacheNode.sd, sd) + end + self.tree:ProcessStats(cacheNode, 1) end - self.tree:ProcessStats(node, startIndex) + else + self.tree:ProcessStats(cacheNode) + end + if treeNode then + treeNode.masteryCache = cacheNode + end + end + for k, v in pairs(cacheNode) do + if k == "modList" then + node.modList = new("ModList"):ModList() + node.modList:AddList(v) + else + node[k] = v end - else - self.tree:ProcessStats(node) end node.allMasteryOptions = true end - function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) local result = { } if self.jewels[node.id] and self.jewels[node.id] > 0 then @@ -1111,8 +1149,10 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) return result end +-- local sock = require("socket.core") -- Rebuilds dependencies and paths for all nodes function PassiveSpecClass:BuildAllDependsAndPaths() + -- local start = sock.gettime() * 1000 local timelessJewelTypeByConqueror = { vaal = 1, karui = 2, @@ -1147,58 +1187,76 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end end end + + -- gather list of radius jewels + local radiusJewels = {} + for nodeId, itemId in pairs(self.jewels) do + local item = self.build.itemsTab.items[itemId] + if item and item.jewelRadiusIndex and self.allocNodes[nodeId] and item.jewelData and not item.jewelData.limitDisabled then + local socket = self.nodes[nodeId] + table.insert(radiusJewels, { + socket = socket, + item = item, + }) + end + end -- Check all nodes for other nodes which depend on them (i.e. are only connected to the tree through that node) for id, node in pairs(self.nodes) do node.depends = wipeTable(node.depends) node.intuitiveLeapLikesAffecting = { } node.conqueredBy = nil + if self.checkNodeLinks then + self:NormalizeNodeLinks(node) + end -- ignore cluster jewel nodes that don't have an id in the tree if self.tree.nodes[id] then self:ReplaceNode(node,self.tree.nodes[id]) end node.conqueredBy = abyssConquests[id] - if node.type ~= "ClassStart" and node.type ~= "Socket" and not node.ascendancyName then - for nodeId, itemId in pairs(self.jewels) do - local item = self.build.itemsTab.items[itemId] - if item and item.jewelRadiusIndex and self.allocNodes[nodeId] and item.jewelData and not item.jewelData.limitDisabled then - local radiusIndex = item.jewelRadiusIndex - if self.nodes[nodeId].nodesInRadius and self.nodes[nodeId].nodesInRadius[radiusIndex][node.id] then - if itemId ~= 0 then - if item.jewelData.intuitiveLeapLike and not (item.jewelData.intuitiveLeapKeystoneOnly and node.type ~= "Keystone") then - -- This node depends on Intuitive Leap-like behaviour - -- This flag: - -- 1. Prevents generation of paths from this node unless it's also connected to the start - -- 2. Prevents allocation of path nodes when this node is being allocated - t_insert(node.intuitiveLeapLikesAffecting, self.nodes[nodeId]) - end - if item.jewelData.conqueredBy then - local radiusJewelType = timelessJewelTypeByConqueror[item.jewelData.conqueredBy.conqueror.type] - if not radiusJewelType or radiusJewelType < 7 then - node.conqueredBy = item.jewelData.conqueredBy - end + if #radiusJewels > 0 and node.type ~= "ClassStart" and node.type ~= "Socket" and not node.ascendancyName then + for _, radiusJewel in ipairs(radiusJewels) do + local item = radiusJewel.item + local socket = radiusJewel.socket + local radiusIndex = item.jewelRadiusIndex + local nodesInRadius = radiusIndex and socket.nodesInRadius and socket.nodesInRadius[item.jewelRadiusIndex] + if nodesInRadius and nodesInRadius[node.id] then + if item.id ~= 0 then + if item.jewelData.intuitiveLeapLike and not (item.jewelData.intuitiveLeapKeystoneOnly and node.type ~= "Keystone") then + -- This node depends on Intuitive Leap-like behaviour + -- This flag: + -- 1. Prevents generation of paths from this node unless it's also connected to the start + -- 2. Prevents allocation of path nodes when this node is being allocated + t_insert(node.intuitiveLeapLikesAffecting, socket) + end + if item.jewelData.conqueredBy then + local radiusJewelType = timelessJewelTypeByConqueror[item.jewelData.conqueredBy.conqueror.type] + if not radiusJewelType or radiusJewelType < 7 then + node.conqueredBy = item.jewelData.conqueredBy end end end + end - if item.jewelData and item.jewelData.impossibleEscapeKeystone then - for keyName, keyNode in pairs(self.tree.keystoneMap) do - if item.jewelData.impossibleEscapeKeystones[keyName] and keyNode.nodesInRadius then - if keyNode.nodesInRadius[radiusIndex][node.id] then - t_insert(node.intuitiveLeapLikesAffecting, self.nodes[nodeId]) - end - end + local impossibleEscapeKeystones = item.jewelData and item.jewelData.impossibleEscapeKeystones + if impossibleEscapeKeystones then + for keyName in pairs(impossibleEscapeKeystones) do + local keyNode = self.tree.keystoneMap[keyName] + local inRadius = keyNode and keyNode.nodesInRadius and keyNode.nodesInRadius[radiusIndex] + if inRadius and inRadius[node.id] then + t_insert(node.intuitiveLeapLikesAffecting, socket) end end end end + end if node.alloc then node.depends[1] = node -- All nodes depend on themselves end end - + self.checkNodeLinks = false for id, node in pairs(self.nodes) do -- If node is tattooed, replace it if self.hashOverrides[node.id] then @@ -1609,23 +1667,28 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end -- Reset and rebuild all node paths - for id, node in pairs(self.nodes) do + for _, node in pairs(self.nodes) do node.pathDist = (node.alloc and #node.intuitiveLeapLikesAffecting == 0) and 0 or 1000 node.path = nil if node.isJewelSocket or node.expansionJewel then node.distanceToClassStart = 0 end end - for id, node in pairs(self.allocNodes) do + local rootList = {} + for _, node in pairs(self.allocNodes) do if #node.intuitiveLeapLikesAffecting == 0 or node.connectedToStart then - self:BuildPathFromNode(node) - if node.isJewelSocket or node.expansionJewel then - self:SetNodeDistanceToClassStart(node) - end + t_insert(rootList, node) + end + end + self:BuildNodePathsToRootNodes(rootList) + for _, node in ipairs(rootList) do + if node.isJewelSocket or node.expansionJewel then + self:SetNodeDistanceToClassStart(node) end end self:BuildSplitPersonalityPath() + -- ConPrintf("BuildAllDependsAndPaths time: %.2f ms", sock.gettime() * 1000 - start) end function PassiveSpecClass:ReplaceNode(old, newNode) @@ -1740,6 +1803,8 @@ end function PassiveSpecClass:BuildClusterJewelGraphs() local needsLegacyClusterHashConversion = self:BeginLegacyClusterHashConversion() + -- Mark that path building should clear out stale references to cluster nodes + self.checkNodeLinks = true -- Remove old subgraphs for id, subGraph in pairs(self.subGraphs) do for _, node in ipairs(subGraph.nodes) do @@ -2415,13 +2480,12 @@ function PassiveSpecClass:NodeAdditionOrReplacementFromString(node,sd,replacemen end function PassiveSpecClass:NodeInKeystoneRadius(keystoneNames, nodeId, radiusIndex) - for _, node in pairs(self.nodes) do - if node.name and node.type == "Keystone" and keystoneNames[node.name:lower()] then - if (node.nodesInRadius[radiusIndex][nodeId]) then - return true - end + for keystoneName, _ in pairs(keystoneNames) do + local keystoneNode = self.tree.keystoneMap[keystoneName] + local radius = keystoneNode and keystoneNode.nodesInRadius and keystoneNode.nodesInRadius[radiusIndex] + if radius and radius[nodeId] then + return true end end - return false end From bbb023a6502f15c4e218d0b8c8af15403f5660af Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:03:01 +0300 Subject: [PATCH 07/10] Optimise code in some loops, and avoid replacing unallocated mastery nodes --- src/Classes/PassiveSpec.lua | 46 ++++++++++++++++++++++--------------- src/Modules/Common.lua | 5 +++- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index ffae06fd53e..36d7666be3b 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -866,23 +866,27 @@ function PassiveSpecClass:FindStartFromNode(node, visited, noAscend) node.visited = true t_insert(visited, node) -- For each node which is connected to this one, check if... + local nodeAscendancy = node.ascendancyName for _, other in ipairs(node.linked) do -- Either: -- - the other node is a start node, or -- - there is a path to a start node through the other node which didn't pass through any nodes which have already been visited - local startIndex = #visited + 1 - if other.alloc and - (other.type == "ClassStart" or other.type == "AscendClassStart" or - (not other.visited and node.type ~= "Mastery" and self:FindStartFromNode(other, visited, noAscend)) - ) then - if node.ascendancyName and not other.ascendancyName then - -- Pathing out of Ascendant, un-visit the outside nodes - for i = startIndex, #visited do - visited[i].visited = false - visited[i] = nil + local startIndex = nodeAscendancy and #visited + 1 + if other.alloc then + local otherType = other.type + if + (otherType == "ClassStart" or otherType == "AscendClassStart" or + (not other.visited and node.type ~= "Mastery" and self:FindStartFromNode(other, visited, noAscend)) + ) then + if nodeAscendancy and not other.ascendancyName then + -- Pathing out of Ascendant, un-visit the outside nodes + for i = startIndex, #visited do + visited[i].visited = false + visited[i] = nil + end + elseif not noAscend or otherType ~= "AscendClassStart" then + return true end - elseif not noAscend or other.type ~= "AscendClassStart" then - return true end end end @@ -1203,14 +1207,17 @@ function PassiveSpecClass:BuildAllDependsAndPaths() -- Check all nodes for other nodes which depend on them (i.e. are only connected to the tree through that node) for id, node in pairs(self.nodes) do node.depends = wipeTable(node.depends) - node.intuitiveLeapLikesAffecting = { } + node.intuitiveLeapLikesAffecting = wipeTable(node.intuitiveLeapLikesAffecting) node.conqueredBy = nil if self.checkNodeLinks then self:NormalizeNodeLinks(node) end -- ignore cluster jewel nodes that don't have an id in the tree - if self.tree.nodes[id] then + local treeNode = self.tree.nodes[id] + -- skip updating unallocated masteries which don't have a runegraft + local isUnallocatedMastery = node.allMasteryOptions and node.type == "Mastery" and not self.hashOverrides[id] + if treeNode and not isUnallocatedMastery then self:ReplaceNode(node,self.tree.nodes[id]) end node.conqueredBy = abyssConquests[id] @@ -1220,7 +1227,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() local item = radiusJewel.item local socket = radiusJewel.socket local radiusIndex = item.jewelRadiusIndex - local nodesInRadius = radiusIndex and socket.nodesInRadius and socket.nodesInRadius[item.jewelRadiusIndex] + local nodesInRadius = radiusIndex and socket.nodesInRadius and socket.nodesInRadius[radiusIndex] if nodesInRadius and nodesInRadius[node.id] then if item.id ~= 0 then if item.jewelData.intuitiveLeapLike and not (item.jewelData.intuitiveLeapKeystoneOnly and node.type ~= "Keystone") then @@ -1294,7 +1301,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() return statToFix -- if it doesn't need to be changed end - if jewelType >= 7 then + if jewelType >= 7 and node.type ~= "Mastery" then for _, component in ipairs(conqueredBy.modification) do local changedNode, replacesNode = data.resolveAbyssJewelComponent(component, self.tree.legion) if changedNode then @@ -1458,6 +1465,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() self.allocatedMasteryTypes = { } self.allocatedMasteryTypeCount = 0 self.allocatedTattooTypes = { } + local masteryReminderText = { "Tip: Right click to select a different effect" } for id, node in pairs(self.nodes) do if self.ignoredNodes[id] and self.allocNodes[id] then self.nodes[id].alloc = false @@ -1474,7 +1482,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() node.sd = effect.sd end node.allMasteryOptions = false - node.reminderText = { "Tip: Right click to select a different effect" } + node.reminderText = masteryReminderText self.tree:ProcessStats(node) self.allocatedMasteryCount = self.allocatedMasteryCount + 1 if not self.allocatedMasteryTypes[self.allocNodes[id].name] then @@ -1493,7 +1501,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() self.allocNodes[id] = nil self.masterySelections[id] = nil end - elseif node.type == "Mastery" then + elseif node.type == "Mastery" and not node.allMasteryOptions then self:AddMasteryEffectOptionsToNode(node) elseif node.type == "Notable" and node.alloc then self.allocatedNotableCount = self.allocatedNotableCount + 1 @@ -1711,7 +1719,7 @@ function PassiveSpecClass:ReplaceNode(old, newNode) old.icon = newNode.icon old.spriteId = newNode.spriteId old.activeEffectImage = newNode.activeEffectImage - old.reminderText = newNode.reminderText or { } + old.reminderText = newNode.reminderText or wipeTable(old.reminderText) end ---Reconnects altered timeless jewel to class start, for Pure Talent diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index d9f71152821..95750f2747e 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -551,7 +551,10 @@ function specCopy(env) return modDB, enemyDB, minionDB end --- Wipe all keys from the table and return it, or return a new table if no table provided +-- Wipe all keys from the table and return it, or return a new table if no table +-- provided. This is useful to avoid alllocations in hot paths if a table can be reused. Using LuaJIT's `table.clear()` is another alternative to this, but this performs similarly on small tables, or tables which are often already empty. +---@param tbl table? +---@return table tbl function wipeTable(tbl) if not tbl then return { } From a157351d2db59a2bea39480fc386c7e902a21601 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:10:34 +0300 Subject: [PATCH 08/10] Remove debug timers --- src/Classes/ItemsTab.lua | 6 ------ src/Classes/PassiveSpec.lua | 3 --- 2 files changed, 9 deletions(-) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 26488c00b23..09dd95d72e4 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4435,8 +4435,6 @@ end local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementItem) local tempItemId - local socket = require("socket.core") - local start = socket.gettime() * 1000 local spec = cloneSpecForJewelComparison(itemsTab.build.spec) if replacementItem then if replacementItem.id and itemsTab.items[replacementItem.id] == replacementItem then @@ -4452,13 +4450,9 @@ local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementIte else spec.jewels[compareSlot.nodeId] = nil end - ConPrintf("spec build time: %.2f ms", socket.gettime() * 1000 - start) - start = socket.gettime() * 1000 local ok, err = xpcall(function() spec:BuildAllDependsAndPaths() end, debug.traceback) - ConPrintf("build all depends: %.2f ms", socket.gettime() * 1000 - start) - start = socket.gettime() * 1000 if tempItemId then itemsTab.items[tempItemId] = nil end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 36d7666be3b..f0083e5fce9 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1153,10 +1153,8 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) return result end --- local sock = require("socket.core") -- Rebuilds dependencies and paths for all nodes function PassiveSpecClass:BuildAllDependsAndPaths() - -- local start = sock.gettime() * 1000 local timelessJewelTypeByConqueror = { vaal = 1, karui = 2, @@ -1696,7 +1694,6 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end self:BuildSplitPersonalityPath() - -- ConPrintf("BuildAllDependsAndPaths time: %.2f ms", sock.gettime() * 1000 - start) end function PassiveSpecClass:ReplaceNode(old, newNode) From ead51513f6096c35ca701978edd0e89cab21594c Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:50:17 +0300 Subject: [PATCH 09/10] Fix runegraft behaviour, and show runegraft node instead of mastery options --- src/Classes/PassiveSpec.lua | 2 +- src/Classes/PassiveTreeView.lua | 5 +++-- src/Classes/TreeTab.lua | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index f0083e5fce9..b2556011d76 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1499,7 +1499,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() self.allocNodes[id] = nil self.masterySelections[id] = nil end - elseif node.type == "Mastery" and not node.allMasteryOptions then + elseif node.type == "Mastery" and not node.allMasteryOptions and node.overrideType ~= "AlternateMastery" then self:AddMasteryEffectOptionsToNode(node) elseif node.type == "Notable" and node.alloc then self.allocatedNotableCount = self.allocatedNotableCount + 1 diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index ea7bef3f111..bc0fcd052d1 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -1580,7 +1580,8 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build) end end - if mNode.sd[1] and mNode.allMasteryOptions then + local isRunegraft = mNode.overrideType == "AlternateMastery" + if mNode.sd[1] and mNode.allMasteryOptions and not isRunegraft then tooltip:AddSeparator(14) tooltip:AddLine(14, "^7Available Mastery node options are:") tooltip:AddLine(6, "") @@ -1604,7 +1605,7 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build) end -- This stanza actives for both Mastery and non Mastery tooltips. Proof: add '"Blah "..' to addModInfoToTooltip - if mNode.sd[1] and not mNode.allMasteryOptions then + if mNode.sd[1] and (not mNode.allMasteryOptions or isRunegraft) then tooltip:AddLine(16, "") for i, line in ipairs(mNode.sd) do addModInfoToTooltip(mNode, i, masteryColor..line) diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index c9cb48c2111..f080c7c1748 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -352,6 +352,7 @@ function TreeTabClass:RemoveTattooFromNode(node) self.build.spec.tree.nodes[node.id].isTattoo = false self.build.spec.hashOverrides[node.id] = nil self.build.spec:ReplaceNode(node, self.build.spec.tree.nodes[node.id]) + node.allMasteryOptions = false self.build.spec:BuildAllDependsAndPaths() end @@ -886,6 +887,9 @@ function TreeTabClass:ModifyNodePopup(selectedNode) newTattooNode.id = selectedNode.id self.build.spec.hashOverrides[selectedNode.id] = newTattooNode self.build.spec:ReplaceNode(selectedNode, newTattooNode) + if selectedNode.type == "Mastery" then + selectedNode.allMasteryOptions = false + end self.build.spec:BuildAllDependsAndPaths() end From 80f00a423a728ce579d9240508037ea107a7df20 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Mon, 24 Aug 2026 07:03:36 +1000 Subject: [PATCH 10/10] Fixes Remove single use helper functions Fix mastery issues --- spec/System/TestTreeTab_spec.lua | 39 +++++++++ src/Classes/PassiveSpec.lua | 144 +++++++++++++++---------------- src/Modules/CalcSetup.lua | 27 +++--- src/Modules/Common.lua | 130 +++++++++++++--------------- 4 files changed, 182 insertions(+), 158 deletions(-) diff --git a/spec/System/TestTreeTab_spec.lua b/spec/System/TestTreeTab_spec.lua index e9418b8921b..d8b7ae3340f 100644 --- a/spec/System/TestTreeTab_spec.lua +++ b/spec/System/TestTreeTab_spec.lua @@ -2,6 +2,14 @@ describe("TreeTab", function() local originalClusterNodeMap local originalMasteryEffects + local function findMasteryNode() + for _, node in pairs(build.spec.nodes) do + if node.type == "Mastery" and node.masteryEffects and #node.masteryEffects > 0 then + return node + end + end + end + before_each(function() newBuild() originalClusterNodeMap = build.spec.tree.clusterNodeMap @@ -13,6 +21,37 @@ describe("TreeTab", function() build.spec.tree.masteryEffects = originalMasteryEffects end) + it("restores a Runegraft after rebuilding mastery options", function() + local node = assert(findMasteryNode()) + local override + for _, tattooNode in pairs(build.spec.tree.tattoo.nodes) do + if tattooNode.overrideType == "AlternateMastery" then + override = copyTable(tattooNode, true) + break + end + end + assert(override) + override.id = node.id + build.spec.hashOverrides[node.id] = override + build.spec:ReplaceNode(node, override) + + build.spec:DeallocSingleNode(node) + build.spec:BuildAllDependsAndPaths() + + assert.is_false(node.allMasteryOptions) + assert.are.equal("AlternateMastery", node.overrideType) + end) + + it("clears the selected mastery reminder after deallocation", function() + local node = assert(findMasteryNode()) + node.reminderText = { "Tip: Right click to select a different effect" } + + build.spec:DeallocSingleNode(node) + build.spec:BuildAllDependsAndPaths() + + assert.is_nil(node.reminderText) + end) + it("adds separate power report entries for mastery effects", function() local treeTab = build.treeTab local parentNode = { id = 2 } diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index b2556011d76..ad7dfbc7924 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -914,79 +914,6 @@ function PassiveSpecClass:GetSocketedJewel(nodeId) return self:GetJewel(itemId) end -local function traversable(curDist, node, other) - -- Paths must obey these rules: - -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) - -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree - -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree - -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas - -- 3. They must not pass away from mastery nodes - return node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) -end - --- Cluster subgraph rebuilds can replace node objects while retaining IDs. --- Normalize stale link references to the canonical node object. -function PassiveSpecClass:NormalizeNodeLinks(node) - local linked = node.linked - for index = #linked, 1, -1 do - local other = linked[index] - local canonicalNode = other and other.id and self.nodes[other.id] - if not canonicalNode then - t_remove(linked, index) - elseif canonicalNode ~= other then - linked[index] = canonicalNode - end - end -end - --- Multi-source 0-1 BFS to find what other root (i.e., allocated) nodes each node is closest to ----@param roots Node[] A list of currently allocated, and other nodes which should be considered as the sources of distances. -function PassiveSpecClass:BuildNodePathsToRootNodes(roots) - -- A dequeue. We will keep a pointer to the start and end of this to keep - -- track of its length - local q = {} - for _, node in ipairs(roots) do - node.pathDist = 0 - node.path = wipeTable(node.path) - t_insert(q, node) - end - local qStart = 1 - local qLen = #q - while qStart <= qLen do - -- pop front - local node = q[qStart] - qStart = qStart + 1 - local linked = node.linked - local nodeDist = node.pathDist - local nodePath = node.path - for i = 1, #linked do - local other = linked[i] - local weight = other.alloc and 0 or 1 - local distViaNode = nodeDist + weight - if (distViaNode < (other.pathDist or math.huge)) - and traversable(nodeDist, node, other) then - -- if this node is free, push it to the front so that it can shorten paths - if weight == 0 then - qStart = qStart - 1 - q[qStart] = other - -- otherwise push to back - else - qLen = qLen + 1 - q[qLen] = other - end - - -- save path and distance for the node - other.pathDist = distViaNode - local path = wipeTable(other.path) - path[1] = other - for i = 1, #nodePath do - path[i + 1] = nodePath[i] - end - other.path = path - end - end - end -end -- Determine this node's distance from the class' start -- Only allocated nodes can be traversed function PassiveSpecClass:SetNodeDistanceToClassStart(root) @@ -1113,6 +1040,8 @@ function PassiveSpecClass:AddMasteryEffectOptionsToNode(node) treeNode.masteryCache = cacheNode end end + -- Cached options do not include the reminder from a previously selected effect + node.reminderText = nil for k, v in pairs(cacheNode) do if k == "modList" then node.modList = new("ModList"):ModList() @@ -1196,7 +1125,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() local item = self.build.itemsTab.items[itemId] if item and item.jewelRadiusIndex and self.allocNodes[nodeId] and item.jewelData and not item.jewelData.limitDisabled then local socket = self.nodes[nodeId] - table.insert(radiusJewels, { + t_insert(radiusJewels, { socket = socket, item = item, }) @@ -1209,7 +1138,18 @@ function PassiveSpecClass:BuildAllDependsAndPaths() node.conqueredBy = nil if self.checkNodeLinks then - self:NormalizeNodeLinks(node) + -- Cluster subgraph rebuilds can leave links to replaced node objects, + -- so normalize them to the canonical nodes before rebuilding paths. + local linked = node.linked + for index = #linked, 1, -1 do + local other = linked[index] + local canonicalNode = other and other.id and self.nodes[other.id] + if not canonicalNode then + t_remove(linked, index) + elseif canonicalNode ~= other then + linked[index] = canonicalNode + end + end end -- ignore cluster jewel nodes that don't have an id in the tree local treeNode = self.tree.nodes[id] @@ -1266,6 +1206,11 @@ function PassiveSpecClass:BuildAllDependsAndPaths() -- If node is tattooed, replace it if self.hashOverrides[node.id] then self:ReplaceNode(node, self.hashOverrides[node.id]) + -- Runegrafts use mastery nodes, but represent one modifier rather than + -- the usual list of mastery options + if node.overrideType == "AlternateMastery" then + node.allMasteryOptions = false + end end -- If node is conquered, replace it or add mods @@ -1686,7 +1631,54 @@ function PassiveSpecClass:BuildAllDependsAndPaths() t_insert(rootList, node) end end - self:BuildNodePathsToRootNodes(rootList) + + -- Use a multi-source 0-1 BFS to find the closest allocated node. Allocated + -- nodes have zero weight, while each unallocated node costs one passive point. + local queue = { } + for _, node in ipairs(rootList) do + node.pathDist = 0 + node.path = wipeTable(node.path) + t_insert(queue, node) + end + local queueStart = 1 + local queueLength = #queue + while queueStart <= queueLength do + local node = queue[queueStart] + queueStart = queueStart + 1 + local linked = node.linked + local nodeDist = node.pathDist + local nodePath = node.path + for i = 1, #linked do + local other = linked[i] + local weight = other.alloc and 0 or 1 + local distViaNode = nodeDist + weight + -- Paths cannot pass through start nodes, cross ascendancies, or move + -- away from masteries. Ascendant paths may leave at distance zero. + local canTraverse = node.type ~= "Mastery" + and other.type ~= "ClassStart" + and other.type ~= "AscendClassStart" + and (node.ascendancyName == other.ascendancyName or (nodeDist == 0 and not other.ascendancyName)) + if distViaNode < (other.pathDist or math.huge) and canTraverse then + if weight == 0 then + -- Free nodes go to the front so they can shorten paid paths immediately. + queueStart = queueStart - 1 + queue[queueStart] = other + else + queueLength = queueLength + 1 + queue[queueLength] = other + end + + other.pathDist = distViaNode + local path = wipeTable(other.path) + path[1] = other + for pathIndex = 1, #nodePath do + path[pathIndex + 1] = nodePath[pathIndex] + end + other.path = path + end + end + end + for _, node in ipairs(rootList) do if node.isJewelSocket or node.expansionJewel then self:SetNodeDistanceToClassStart(node) diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index c934d008351..4987e3b6a57 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -114,21 +114,22 @@ function calcs.initModDB(env, modDB) modDB.conditions["Effective"] = env.mode_effective end --- Recycle a modlist so that we do not allocate many tables for each node. -local function resetModList(list) - for i = #list, 1, -1 do - list[i] = nil - end - list.multipliers = wipeTable(list.multipliers) - list.conditions = wipeTable(list.conditions) - list.actor = wipeTable(list.actor) - list.parent = false - return list -end - ---@param reuse table|nil A ModList to recycle instead of allocating. Only safe when the caller discards the result. function calcs.buildModListForNode(env, node, reuse) - local modList = reuse and resetModList(reuse) or new("ModList"):ModList() + local modList + if reuse then + -- Reset the scratch list so non-MAIN calculations can reuse it for each node + modList = reuse + for i = #modList, 1, -1 do + modList[i] = nil + end + modList.multipliers = wipeTable(modList.multipliers) + modList.conditions = wipeTable(modList.conditions) + modList.actor = wipeTable(modList.actor) + modList.parent = false + else + modList = new("ModList"):ModList() + end if node.type == "Keystone" then modList:AddMod(node.keystoneMod) else diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index 95750f2747e..b1556116aab 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -76,25 +76,6 @@ local function getClass(className) return class end --- wrap constructor to check that the constructors for all parent and superparent classes have been called -local function wrapConstructor(class, className, originalFunc) - return function(self, ...) - local ret = originalFunc(self, ...) - if class._parents then - for parent in pairs(class._superParents) do - if parent[parent._className] and not self._parentInit[parent] then - error("Parent class '" .. - parent._className .. "' of class '" .. className .. "' must be initialised") - end - end - end - if not ret then - error(string.format("Class %s constructor did not return a value", className)) - end - return ret - end -end - ---@generic T ---@param className `T` ---@param ... string parent class names @@ -113,7 +94,7 @@ function newClass(className, ...) class._metaList = {} class._className = className local numVarArg = select("#", ...) - local parentIndex + local classMeta = { } if numVarArg > 0 then -- Build list of parent classes class._parents = { } @@ -124,7 +105,7 @@ function newClass(className, ...) class._superParents = { } addSuperParents(class, class) -- Set up inheritance - function parentIndex(self, key) + classMeta.__index = function(self, key) for _, parent in ipairs(class._parents) do local val = parent[key] if val ~= nil then @@ -134,16 +115,29 @@ function newClass(className, ...) end end end - setmetatable(class, { - __index = parentIndex, - __newindex = function(self, k, v) - if k == className then - -- Check that the constructors for all parent and superparent classes have been called - v = wrapConstructor(class, className, v) + classMeta.__newindex = function(self, k, v) + if k == className then + local constructor = v + -- Check that the constructors for all parent and superparent classes have been called + v = function(self, ...) + local ret = constructor(self, ...) + if class._parents then + for parent in pairs(class._superParents) do + if parent[parent._className] and not self._parentInit[parent] then + error("Parent class '" .. + parent._className .. "' of class '" .. className .. "' must be initialised") + end + end + end + if not ret then + error(string.format("Class %s constructor did not return a value", className)) + end + return ret end - rawset(self, k, v) end - }) + rawset(self, k, v) + end + setmetatable(class, classMeta) class._unconstructedMeta = { __index = function(obj, key) if key == className then @@ -158,45 +152,6 @@ function newClass(className, ...) return class end --- avoid rebuilding metatables constantly. this is done by caching class-parent pair metatables -local function getMeta(class, parent) - local metaList = rawget(class, "_metaList") - local meta = metaList[parent] - if not meta then - local parentName = parent._className - meta = { - __index = function(proxy, key) - local object = rawget(proxy, "_object") - local v = rawget(object, key) - if v ~= nil then - return v - else - return parent[key] - end - end, - __newindex = function(proxy, k, v) - local object = rawget(proxy, "_object") - object[k] = v - end, - __call = function(proxy, self, ...) - local object = rawget(proxy, "_object") - if not parent[parentName] then - error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has no constructor") - end - if object._parentInit[parent] then - error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has already been initialised") - end - if self ~= object then - error(string.format("Parent class %s constructor of class %s was not provided self. Are you perhaps calling it with self.%s instead of self:%s?", parentName, class._className, parentName, parentName)) - end - parent[parent._className](self, ...) - object._parentInit[parent] = true - end, - } - metaList[parent] = meta - end - return meta -end ---@generic T ---@param className `T` ---@param extraArg nil Never pass extra parameters. Defined purely to guard against old syntax. @@ -215,8 +170,45 @@ function new(className, extraArg) if class._parents then -- Add parent and superparent class proxies object._parentInit = { } + local metaList = rawget(class, "_metaList") for parent in pairs(class._superParents) do - object[parent._className] = setmetatable({ _object = object }, getMeta(class, parent)) + local meta = metaList[parent] + if not meta then + local proxyParent = parent + local parentName = parent._className + -- Cache one proxy metatable for each class-parent pair instead of rebuilding it for every object + meta = { + __index = function(proxy, key) + local proxyObject = rawget(proxy, "_object") + local value = rawget(proxyObject, key) + if value ~= nil then + return value + else + return proxyParent[key] + end + end, + __newindex = function(proxy, k, v) + local proxyObject = rawget(proxy, "_object") + proxyObject[k] = v + end, + __call = function(proxy, self, ...) + local proxyObject = rawget(proxy, "_object") + if not proxyParent[parentName] then + error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has no constructor") + end + if proxyObject._parentInit[proxyParent] then + error("Parent class '" .. parentName .. "' of class '" .. class._className .. "' has already been initialised") + end + if self ~= proxyObject then + error(string.format("Parent class %s constructor of class %s was not provided self. Are you perhaps calling it with self.%s instead of self:%s?", parentName, class._className, parentName, parentName)) + end + proxyParent[parentName](self, ...) + proxyObject._parentInit[proxyParent] = true + end, + } + metaList[parent] = meta + end + object[parent._className] = setmetatable({ _object = object }, meta) end end return object