Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/Classes/PassiveSpec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 12 additions & 6 deletions src/Modules/Common.lua
Original file line number Diff line number Diff line change
Expand Up @@ -504,25 +504,31 @@ 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
setmetatable(out, getmetatable(tbl))
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
Expand Down