Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b6eac49
Add UI elements for Auto Attribute Allocation
majochem Sep 23, 2025
9bc61e9
Add processing of `autoAttributeConfig`
majochem Sep 23, 2025
2d3bba3
Enable save and load functionality
majochem Sep 23, 2025
2b4d8f6
Fix behavior for saved configs
majochem Sep 29, 2025
b831f13
Fix calculation for non-attribute passives
majochem Sep 29, 2025
e3f9da6
Renable hotkey functionality as override option
majochem Sep 29, 2025
e49f9d9
Toggle controls based on `controls.enabledCheck`
majochem Sep 29, 2025
859a4ab
Enable "tab" key navigation for `weight` and `max`
majochem Sep 29, 2025
cb431e9
Fix behavior for "intuitiveLeapLikesAffecting"
majochem Oct 7, 2025
0df97ce
Fix right-click behavior for "intuitiveLeapLikes"
majochem Oct 7, 2025
4b1201d
Fix `maxValue` to use `<` instead of `<=`
majochem Oct 7, 2025
85a70cc
Add tooltip hint on hovering over attribute node
majochem Oct 7, 2025
70b8238
Add hint to `ModifyAttributePopup`
majochem Oct 7, 2025
c03f8d7
Add section on "Auto Attribute Config" to help.txt
majochem Oct 7, 2025
3189640
Merge branch 'dev' into autoAttributeRatio
majochem Oct 7, 2025
69ed68b
Fix typo "strting" to "starting"
majochem Oct 7, 2025
3fc2659
Make UI dimensions static and clean up TODOs
majochem Oct 8, 2025
60391d1
Protect against `0` total attribute edge case
majochem Oct 8, 2025
0bd5b49
Fix 'maxValue' not working for strength
majochem Oct 9, 2025
8f490f1
Fix ratio inaccuracy after reaching max values
majochem Oct 9, 2025
4264498
Add additional `weight = nil` protection
majochem Oct 9, 2025
952774d
Fix crash when loading save with `useAttrReq`
majochem Oct 13, 2025
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
29 changes: 29 additions & 0 deletions help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,32 @@ It will fetch the builds most similar to your character and sort them by the lat

For best results, make sure to select your main item set, tree, and skills before opening the popup.
If you are using leveling gear/tree, it will match with other leveling builds.

---[Auto Attribute Config]

You can enable the automatic allocation of attributes via the "Auto Attribute Config" button at the bottom
of the "Tree" menu section. Each configuration is saved per tree. So if you have multiple trees, each will
have its configuration values.

Weights:
If enabled, attribute travel nodes will automatically be assigned to Strength / Dexterity / Intelligence
according to your configured "weight" values. E.g. values of Str: 1 / Dex: 1 / Int: 2, would result in
roughly 25% of the attribute nodes being assigned to Strength and Dexterity and 50% to Intelligence.

By default, attributes gained from items and other small passive nodes are taken into account when
calculating the actual vs. desired attribute ratios.

Max Value:
If a "Max Value" is entered and the "Limit to Max?" checkbox is ticked, no more nodes will be allocated
to that attribute, once the maximum value is reached

Attribute Requirements:
For ease of use, the "Use Attribute Requirements" checkbox can be ticked. This will result in weights
automatically being based on current attribute requirements from gems and gear.

Item Mods:
If you want your attribute allocation to be gear-agnostic, you can tick the "Ignore Attribute Requirements"
checkbox. Any attribute bonuses gained from equipment will then not be taken into account during allocation.
Note: This does not affect modifiers to attribute requirements found on gear.


181 changes: 178 additions & 3 deletions src/Classes/PassiveSpec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,31 @@ function PassiveSpecClass:Load(xml, dbFileName)
for nodeId in node.attrib.nodes:gmatch("%d+") do
weaponSets[tonumber(nodeId)] = weaponSet
end
elseif node.elem == "AutoAttributeConfig" then
local autoAttributeConfig = { }
if node.attrib then
autoAttributeConfig.enabled = node.attrib.enabled == "true"
autoAttributeConfig.ignoreItemMods = node.attrib.ignoreItemMods == "true"
autoAttributeConfig.useAttrReq = node.attrib.useAttrReq == "true"
for _, attrEntry in ipairs(node) do
if (not attrEntry.elem) or (not attrEntry.attrib) then
launch:ShowErrMsg("^1Error parsing '%s': 'AutoAttributeConfig' element has invalid structure^7", dbFileName)
return true
end
autoAttributeConfig[attrEntry.elem] = { }
autoAttributeConfig[attrEntry.elem].max = attrEntry.attrib.max ~= "nil" and tonumber(attrEntry.attrib.max) or nil
autoAttributeConfig[attrEntry.elem].weight = attrEntry.attrib.weight ~= "nil" and tonumber(attrEntry.attrib.weight) or nil
autoAttributeConfig[attrEntry.elem].useMaxVal = attrEntry.attrib.useMaxVal == "true"
end
else
launch:ShowErrMsg("^1Error parsing '%s': 'AutoAttributeConfig' element missing 'attrib' attribute^7", dbFileName)
return true
end
-- Add static and calculated values
autoAttributeConfig = self.build.treeTab:UpdateAutoAttributeConfig(autoAttributeConfig, true)
self.autoAttributeConfig = copyTable(autoAttributeConfig)
self.autoAttributeConfigSaved = copyTable(autoAttributeConfig) --extra entry to detect changes later

end
end
end
Expand Down Expand Up @@ -255,6 +280,30 @@ function PassiveSpecClass:Save(xml)
t_insert(overrides, attributeOverride)
end
t_insert(xml, overrides)

local autoAttributeConfig = {
elem = "AutoAttributeConfig"
}
if self.autoAttributeConfig then
-- This only saves values to the xml that are neither static, nor calculated. The rest is regenerated on load
autoAttributeConfig.attrib = {
enabled = tostring(self.autoAttributeConfig.enabled),
ignoreItemMods = tostring(self.autoAttributeConfig.ignoreItemMods),
useAttrReq = tostring(self.autoAttributeConfig.useAttrReq),
}
for _, attr in ipairs({"str", "dex", "int"}) do
local attrEntry = { elem = tostring(attr),
attrib = {
weight = tostring(self.autoAttributeConfig[attr].weight),
max = tostring(self.autoAttributeConfig[attr].max),
useMaxVal = tostring(self.autoAttributeConfig[attr].useMaxVal),
}
}
t_insert(autoAttributeConfig, attrEntry)
end
t_insert(xml, autoAttributeConfig)
self.autoAttributeConfigSaved = copyTable(self.autoAttributeConfig)
end

end

Expand Down Expand Up @@ -694,24 +743,46 @@ end
-- Allocate the given node, if possible, and all nodes along the path to the node
-- An alternate path to the node may be provided, otherwise the default path will be used
-- The path must always contain the given node, as will be the case for the default path
function PassiveSpecClass:AllocNode(node, altPath)
function PassiveSpecClass:AllocNode(node, altPath, manualAttribute)
if not node.path then
-- Node cannot be connected to the tree as there is no possible path
return
end

local cachedPlayerAttr = nil -- Used for iterative, automatic determination of desired attribute nodes
local cachedPathAttrResults = nil --Used for temp storage of mod effects gained from the nodes, which are not yet included in the playerModDb until after allocation
local function handleAttributeNode(attrNode)
if not attrNode.isAttribute then return end
if (not manualAttribute) and self.autoAttributeConfig and self.autoAttributeConfig.enabled then
-- Note: cachedPathAttrResults is passed every time, but only used if `cachedPlayerAttr == nil`
self.attributeIndex, cachedPlayerAttr = self:GetAutoAttribute(cachedPlayerAttr, cachedPathAttrResults)
end
self:SwitchAttributeNode(attrNode.id, self.attributeIndex or 1)
end
-- Allocate all nodes along the path
if #node.intuitiveLeapLikesAffecting > 0 then
node.alloc = true
node.allocMode = (node.ascendancyName or node.type == "Keystone" or node.type == "Socket" or node.containJewelSocket) and 0 or self.allocMode
if node.isAttribute then
handleAttributeNode(node)
end
self.allocNodes[node.id] = node
else
if (not manualAttribute) and self.autoAttributeConfig and self.autoAttributeConfig.enabled and ((((altPath and #altPath) or 0) > 1) or ((node.pathDist or 0) > 1) ) then
-- Precalculate effects on attributes from non-attribues passives, if necessary
for _, pathNode in ipairs(altPath or node.path) do
if pathNode.finalModList and #pathNode.finalModList > 0 then
-- Choosing a function to return results, rather than passing the ModList itself because I don't want to modify the playerModDB later
cachedPathAttrResults = self:GetTempPathAttributeResults(pathNode.finalModList, cachedPathAttrResults)
end
end
end
for _, pathNode in ipairs(altPath or node.path) do
pathNode.alloc = true
pathNode.allocMode = (node.ascendancyName or pathNode.type == "Keystone" or pathNode.type == "Socket" or pathNode.containJewelSocket) and 0 or self.allocMode
-- set path attribute nodes to latest chosen attribute or default to Strength if allocating before choosing an attribute
-- set path attribute nodes to latest chosen attribute, configured auto attribute, or default to Strength if allocating before choosing an attribute
if pathNode.isAttribute then
self:SwitchAttributeNode(pathNode.id, self.attributeIndex or 1)
handleAttributeNode(pathNode)
end
self.allocNodes[pathNode.id] = pathNode
end
Expand Down Expand Up @@ -2089,3 +2160,107 @@ function PassiveSpecClass:SwitchAttributeNode(nodeId, attributeIndex)
self.hashOverrides[nodeId] = newNode
end
end

-- Function to auto calculate which attribute to allocate based on desired user weights
-- Should only be called if `self.autoAttributeConfig and self.autoAttributeConfig.enabled`
---@param cachedPlayerAttr table | nil optional table with cached playerAttribute values. Used when iterating over multiple attribute nodes without having to recalculate each time. Ignored if `nil`
---@param cachedPathAttrResults table | nil optional table that contains a cumulative effects of `finalModList` from non-attribute nodes on the path that need to be taken into account for attribute total estimation
---@return number attributeIndex, table playerAttr returns a number for the `attributeIndex` and the `playerAttr` table for future iterations
function PassiveSpecClass:GetAutoAttribute(cachedPlayerAttr, cachedPathAttrResults)
local autoAttributeConfig = self.autoAttributeConfig
local defaultAttrNodeValue = 5 -- doesn't seem to be anywhere in `data`, so I am storing it here, in case it ever changes
local playerAttr
local attributeList = { "dex", "int", "str" }
if cachedPlayerAttr ~= nil then
playerAttr = cachedPlayerAttr
else
-- Mod-based analysis is only performed once per path to reduce performance impact, otherwise cachedPlayerAttr is used
local playerModDB = self.build.calcsTab.mainEnv.player.modDB
local itemModDB = self.build.calcsTab.mainEnv.itemModDB

-- Initialize player attribute values
playerAttr = { }
for _, attr in ipairs(attributeList) do
local attrUpper = attr:gsub("^%l", string.upper)
playerAttr[attr] = { }

-- Calculating individual factor values instead of just using `mainOutput` because they are used to "simulate" effects for multi-node allocation
playerAttr[attr].base = playerModDB:Sum("BASE", nil, attrUpper) + (cachedPathAttrResults and cachedPathAttrResults[attr] and cachedPathAttrResults[attr].base or 0)
playerAttr[attr].inc = playerModDB:Sum("INC", nil, attrUpper) + (cachedPathAttrResults and cachedPathAttrResults[attr] and cachedPathAttrResults[attr].inc or 0)
playerAttr[attr].more = playerModDB:More(nil, attrUpper) * (cachedPathAttrResults and cachedPathAttrResults[attr] and cachedPathAttrResults[attr].more or 1)

-- Remove item effects if configured
-- Note: I believe this currently wouldn't work with "override" mods or "Omniscience", but I don't think those exist in PoE2 atm
if autoAttributeConfig.ignoreItemMods then
playerAttr[attr].itemBase = itemModDB:Sum("BASE", nil, attrUpper)
playerAttr[attr].itemInc = playerModDB:Sum("INC", nil, attrUpper)
playerAttr[attr].itemMore = itemModDB:More(nil, attrUpper)
playerAttr[attr].base = playerAttr[attr].base - playerAttr[attr].itemBase
playerAttr[attr].inc = playerAttr[attr].inc - playerAttr[attr].itemInc
playerAttr[attr].more = playerAttr[attr].more / playerAttr[attr].itemMore
end

playerAttr[attr].mult = (1 + (playerAttr[attr].inc / 100)) * playerAttr[attr].more
playerAttr[attr].total = playerAttr[attr].base * playerAttr[attr].mult
end
end

-- Update weights based on attribute requirements if necessary
if autoAttributeConfig.useAttrReq then
self.autoAttributeConfig = self.build.treeTab:UpdateAutoAttributeConfig(autoAttributeConfig)
end

-- Mark attributes ineligible if the max value is set and already exceeded.
local effConfigWeightTotal = 0
for _, attr in ipairs(attributeList) do
if autoAttributeConfig[attr].max ~= nil and autoAttributeConfig[attr].useMaxVal and (playerAttr[attr].total >= autoAttributeConfig[attr].max) then
playerAttr[attr].eligible = false
playerAttr[attr].effTotal = 0
else
playerAttr[attr].eligible = true
playerAttr[attr].effTotal = playerAttr[attr].total
effConfigWeightTotal = effConfigWeightTotal + (autoAttributeConfig[attr].weight or 0)
end
end

-- Calculating effective totals and ratios that exclude attributes that already exceed max
playerAttr.effSumTotal = m_max(1, playerAttr.dex.effTotal + playerAttr.int.effTotal + playerAttr.str.effTotal ) -- use m_max to protect against division by 0 (e.g. in "Omniscience"-like scenarios)
playerAttr.dex.effRatio = playerAttr.dex.effTotal / playerAttr.effSumTotal
playerAttr.int.effRatio = playerAttr.int.effTotal / playerAttr.effSumTotal
playerAttr.str.effRatio = playerAttr.str.effTotal / playerAttr.effSumTotal

local maxDiff = nil
local neededAttr = nil

-- Find attribute with greatest diff from effective target ratio
for _, attr in ipairs(attributeList) do
if playerAttr[attr].eligible then
local effConfigRatio = (autoAttributeConfig[attr].weight or 0) / m_max(effConfigWeightTotal, 1 )
local diff = effConfigRatio - playerAttr[attr].effRatio
if (maxDiff == nil) or (diff > maxDiff) then
maxDiff = diff
neededAttr = attr
end
end
end
-- Add effect of new attribute node to `playerAttr` for further iterations
if neededAttr ~= nil then
playerAttr[neededAttr].base = playerAttr[neededAttr].base + defaultAttrNodeValue
playerAttr[neededAttr].total = playerAttr[neededAttr].base * playerAttr[neededAttr].mult
end

return autoAttributeConfig[neededAttr] and autoAttributeConfig[neededAttr].id or 1, playerAttr
end

-- Analyzes a `finalModList` from a path with respect to effects on `dex`/ `int` / `str` for use in `GetAutoAttribute`
function PassiveSpecClass:GetTempPathAttributeResults(modList, cachedAttrResults)
local attrResults = cachedAttrResults or { dex = { }, int= { }, str = { } }
for attr, _ in pairs(attrResults) do
local attrUpper = attr:gsub("^%l", string.upper)
attrResults[attr].base = (attrResults[attr].base or 0) + modList:Sum("BASE", nil, attrUpper)
attrResults[attr].inc = (attrResults[attr].inc or 0) + modList:Sum("INC", nil, attrUpper)
attrResults[attr].more = (attrResults[attr].more or 1) * modList:More(nil, attrUpper)
end

return attrResults
end
33 changes: 28 additions & 5 deletions src/Classes/PassiveTreeView.lua
Original file line number Diff line number Diff line change
Expand Up @@ -325,15 +325,15 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents)
build.buildFlag = true
elseif hoverNode.path and not shouldBlockGlobalNodeAllocation(hoverNode) then
-- Handle allocation of unallocated nodes
if hoverNode.isAttribute and not hotkeyPressed then
build.treeTab:ModifyAttributePopup(hoverNode)
if hoverNode.isAttribute and not hotkeyPressed and not (spec.autoAttributeConfig and spec.autoAttributeConfig.enabled) then
build.treeTab:ModifyAttributePopup(hoverNode)
else
-- the odd conditional here is so the popup only calls AllocNode inside and to avoid duplicating some code
-- same flow for hotkey attribute and non attribute nodes
-- same flow for hotkey attribute, automatic attributes, and non-attribute nodes
if hotkeyPressed then
processAttributeHotkeys(hoverNode.isAttribute)
end
spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath)
spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath, hotkeyPressed)
spec:AddUndoState()
build.buildFlag = true
end
Expand Down Expand Up @@ -367,7 +367,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents)
end
spec:SwitchAttributeNode(hoverNode.id, spec.attributeIndex or 1)
end
spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath)
spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath, true) -- passing `true` because both right-click and hotkey have priority over auto attribute allocation
spec:AddUndoState()
build.buildFlag = true
end
Expand Down Expand Up @@ -1390,6 +1390,13 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, incSmallPassi
end
end

-- Attribute Allocation hints
if node.isAttribute then
tooltip:AddSeparator(14)
self:AddAutoAttributeConfigHintToTooltip(tooltip, node, build)
tooltip:AddSeparator(14)
end

-- Reminder text
if node.reminderText then
tooltip:AddSeparator(14)
Expand Down Expand Up @@ -1534,6 +1541,22 @@ function PassiveTreeViewClass:AddGlobalNodeWarningsToTooltip(tooltip, node, buil
end
end

-- Helper function to add information about currently active auto attribute allocation config
function PassiveTreeViewClass:AddAutoAttributeConfigHintToTooltip(tooltip, node, build)
if not node.isAttribute then return end
local config = build.spec.autoAttributeConfig

if config and config.enabled then
local hintTxt = colorCodes.TIP .. "Automatic Attribute Allocation is " .. colorCodes.POSITIVE .. "enabled^7"
local configTxt = "^7Weights: "
configTxt = configTxt .. colorCodes.STRENGTH .. "Str: ^7" .. (config.str.weight or 0) .. (config.str.useMaxVal and (" ^8[max: " .. (config.str.max or "0") .. "]") or "") .. " ^7| "
configTxt = configTxt .. colorCodes.DEXTERITY .. "Dex: ^7" .. (config.dex.weight or 0) .. (config.dex.useMaxVal and (" ^8[max: " .. (config.dex.max or "0") .. "]") or "") .. " ^7| "
configTxt = configTxt .. colorCodes.INTELLIGENCE .. "Int: ^7" .. (config.int.weight or 0) .. (config.int.useMaxVal and (" ^8[max: " .. (config.int.max or "0") .. "]") or "") .. "^7"
tooltip:AddLine(14, hintTxt)
tooltip:AddLine(14, configTxt)
end
end

function PassiveTreeViewClass:DrawAllocMode(allocMode, viewPort)
local rgbColor
if allocMode == 0 then
Expand Down
Loading