diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 154ddf2bbe..d4da3e3116 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -2750,13 +2750,52 @@ end function PassiveSpecClass:SwitchAttributeNode(nodeId, attributeIndex) if self.tree.nodes[nodeId] then --Make sure node exists on current tree + -- Idempotence: already switched to exactly this option => no-op. + -- Without it, every re-switch deep-copies the SHARED tree node and + -- attaches a brand-new mirror generation (ReplaceNode / ProcessStats + -- below); BuildAllDependsAndPaths then projects the override back + -- onto the shared node, so the node's reachable graph doubles with + -- each call. Repeated allocate/deallocate cycles over an attribute + -- node (weapon-set switching, build import, tree reload with + -- retained overrides) accumulate generations: copy cost and Lua heap + -- grow exponentially until a single copyTableSafe hits LuaJIT's hard + -- 2^26 hash-node limit ("table overflow", LJ_MAX_HBITS). + local existing = self.hashOverrides[nodeId] + if existing and existing._attrOptionIndex == attributeIndex then + return + end + -- Build at most ONE mirror generation per (node, option). + -- main:LoadTree caches one PassiveTree per version for the life of + -- the process, while specs swap in fresh hashOverrides maps (import + -- and class change wipe it; DeallocNode nils entries). Re-attaching + -- the SAME cached mirror after such a reset keeps the shared node at + -- one generation per option. ReplaceNode/ProcessStats only ever + -- rewrite shared nodes FROM these overrides (an idempotent + -- re-projection), so reusing the object across specs is safe. + local cache = self.tree._attrSwitchCache + if not cache then + cache = { } + self.tree._attrSwitchCache = cache + end + local byIndex = cache[nodeId] + if not byIndex then + byIndex = { } + cache[nodeId] = byIndex + end + local cached = byIndex[attributeIndex] + if cached then + self.hashOverrides[nodeId] = cached + return + end local newNode = copyTableSafe(self.tree.nodes[nodeId], false, true) if not newNode.isAttribute then return end -- safety check local option = newNode.options[attributeIndex] self:ReplaceNode(newNode, option) self.tree:ProcessStats(newNode) + newNode._attrOptionIndex = attributeIndex + byIndex[attributeIndex] = newNode self.hashOverrides[nodeId] = newNode end end diff --git a/src/Modules/Common.lua b/src/Modules/Common.lua index cef84e00d8..1741f1aa88 100644 --- a/src/Modules/Common.lua +++ b/src/Modules/Common.lua @@ -504,10 +504,19 @@ function copyTable(tbl, noRecurse) return out end do - local subTableMap = { } - function copyTableSafe(tbl, noRecurse, preserveMeta, isSubTable) + -- The sub-table memo is threaded through the recursion as a parameter + -- rather than kept in a module-level table: it is created by the + -- outermost call and becomes garbage as soon as that call returns -- + -- INCLUDING when it never returns (a copy abandoned while its coroutine + -- is suspended, or aborted by an error mid-copy). A module-level memo + -- was only wiped on the success path, so any abandoned copy pinned its + -- entire source sub-tree (and every mirror table already copied) for + -- the life of the process, growing the Lua heap until a memo write + -- threw "table overflow" and the post-error full GC ground for minutes. + function copyTableSafe(tbl, noRecurse, preserveMeta, isSubTable, subTableMap) local out = {} if not noRecurse then + subTableMap = subTableMap or { } subTableMap[tbl] = out end if preserveMeta then @@ -515,14 +524,11 @@ do end for k, v in pairs(tbl) do if not noRecurse and type(v) == "table" then - out[k] = subTableMap[v] or copyTableSafe(v, false, preserveMeta, true) + out[k] = subTableMap[v] or copyTableSafe(v, false, preserveMeta, true, subTableMap) else out[k] = v end end - if not noRecurse and not isSubTable then - wipeTable(subTableMap) - end return out end end