Skip to content

Commit 4186552

Browse files
jay9297claude
andauthored
fix(export): address adversarial review findings in PR #60 PoE2 build export (#76)
* fix(export): address adversarial review findings in PoE2 build export CRITICAL - PassiveSpec: include nodeNotes in CreateUndoState/RestoreUndoState so Ctrl+Z and tree copy no longer silently erase per-node notes - BuildExportPoE2: replace io.popen('ls -d ...') in dirExists with os.rename(path,path) — eliminates the Windows crash risk and the shell injection vector from unescaped path content HIGH - BuildExportPoE2: remove warnedNoStringId module-level global; surface the "no stringId in tree.lua" warning as a third return value from Export/WriteFile so each call site gets fresh state and the UI can display it prominently (ImportTab now shows colorCodes.WARNING status) - BuildExportPoE2: sort `order` before emitting in buildPassives and sort SlotMap keys before iterating in buildItems to produce deterministic JSON output regardless of LuaJIT hash table iteration order MEDIUM - BuildExportPoE2: PresetNextLevels anyHas now checks `levelMin or levelMax` (was levelMax-only), preventing double-seeding of an entry that already has only levelMin set - BuildExportPoE2: autoBracket uses m_max(lo, ...) for hi so hi >= lo is guaranteed even for n > 99 sets - SkillsTab: add local m_floor alias; fix clampLvl fallback to use it instead of the global math.floor; fix level-bracket callbacks to set self.modFlag instead of self.build.modFlag LOW - PassiveTreeView: gate the per-node note popup and tooltip on hoverNode.alloc — notes only make sense on allocated nodes; tooltip shows existing note content when present - BuildExportPoE2: replace factually-incorrect "typo in gameId" comment on gemIdFor with an accurate description Tests: add cases for levelMin-only anyHas, Export warning return value https://claude.ai/code/session_01EvGnnyxkoMawLmWWqCTxxX * fix(export): address ai-review follow-up findings on PR #76 - ImportTab: fix safePath gsub no-op ('^' -> '^^' to actually escape caret characters for PoB's color-code renderer) - BuildExportPoE2: make dirExists locale-independent by checking only ok == true from os.rename; error strings are locale-sensitive and cannot reliably distinguish ENOENT from EACCES - Tests: add Export test covering the passiveWarning non-nil path (allocated node with no stringId triggers the degraded-export warning) https://claude.ai/code/session_01EvGnnyxkoMawLmWWqCTxxX * test(export): cover autoBracket hi>=lo invariant for n>=100 sets Adds an Export test that places 100 passive specs with one allocated node in the first spec, then asserts the emitted level_interval has lo <= hi. Regresses the underflow that existed before the m_max fix (n=100, i=1 gave hi=floor(1/100*99)=0 < lo=1). https://claude.ai/code/session_01EvGnnyxkoMawLmWWqCTxxX --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c83ed1 commit 4186552

6 files changed

Lines changed: 103 additions & 44 deletions

File tree

spec/System/TestBuildExportPoE2_spec.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ describe("TestBuildExportPoE2", function()
9696
assert.are.equals(1, newEntry.levelMin)
9797
assert.are.equals(30, newEntry.levelMax)
9898
end)
99+
100+
it("Treats levelMin-only entry as having a level set", function()
101+
local existing = { { id = 1, levelMin = 1 } }
102+
local newEntry = { id = 2 }
103+
BuildExportPoE2.PresetNextLevels(existing, newEntry)
104+
-- anyHas=true but maxLvl=0 (no levelMax), so new entry gets [1,30]
105+
assert.are.equals(1, newEntry.levelMin)
106+
assert.are.equals(30, newEntry.levelMax)
107+
-- Must NOT re-seed the existing entry (it already had levelMin set)
108+
assert.is_nil(existing[1].levelMax)
109+
end)
99110
end)
100111

101112
describe("NextLoadoutBracket", function()
@@ -251,6 +262,37 @@ describe("TestBuildExportPoE2", function()
251262
local json2 = BuildExportPoE2.Export(build)
252263
assert.are.equals(json1, json2)
253264
end)
265+
266+
it("Returns nil warning for an empty build", function()
267+
local _, err, warning = BuildExportPoE2.Export(build)
268+
assert.is_nil(err)
269+
-- An empty build has no passives so no stringId check fires.
270+
assert.is_nil(warning)
271+
end)
272+
273+
it("Returns warning when allocated nodes lack stringId", function()
274+
-- Simulate a node without stringId to trigger the degraded-export path.
275+
build.treeTab.specList[1].allocNodes = { [1] = {} }
276+
local _, err, warning = BuildExportPoE2.Export(build)
277+
assert.is_nil(err)
278+
assert.is_not_nil(warning)
279+
assert.is_truthy(warning:find("stringId"))
280+
end)
281+
282+
it("autoBracket never produces hi < lo for n >= 100 specs", function()
283+
-- With n=100, i=1: old code gave hi=floor(1/100*99)=0 < lo=1.
284+
-- The m_max fix ensures hi >= lo.
285+
build.treeTab.specList[1].allocNodes = { [1] = {} }
286+
for i = 2, 100 do
287+
build.treeTab.specList[i] = { id = i, allocNodes = {} }
288+
end
289+
local root = BuildExportPoE2.BuildTable(build)
290+
local first = root.passives[1]
291+
if type(first) == "table" and first.level_interval then
292+
assert.is_true(first.level_interval[1] <= first.level_interval[2],
293+
"autoBracket produced hi < lo: " .. tostring(first.level_interval[1]) .. " > " .. tostring(first.level_interval[2]))
294+
end
295+
end)
254296
end)
255297

256298
describe("WriteFile", function()

src/Classes/ImportTab.lua

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,14 @@ end
400400

401401
function ImportTabClass:DoPoE2Export(Exporter, path)
402402
local function doWrite()
403-
local ok, err = Exporter.WriteFile(self.build, path)
403+
local ok, err, warning = Exporter.WriteFile(self.build, path)
404404
if ok then
405-
self.poe2ExportStatus = colorCodes.POSITIVE .. "Saved to " .. path
405+
local safePath = path:gsub("%^", "^^")
406+
if warning then
407+
self.poe2ExportStatus = colorCodes.WARNING .. "Saved (warning): " .. warning
408+
else
409+
self.poe2ExportStatus = colorCodes.POSITIVE .. "Saved to " .. safePath
410+
end
406411
else
407412
self.poe2ExportStatus = colorCodes.NEGATIVE .. (err or "Export failed")
408413
main:OpenMessagePopup("Export Failed", err or "Unknown error")

src/Classes/PassiveSpec.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2392,7 +2392,8 @@ function PassiveSpecClass:CreateUndoState()
23922392
weaponSets = weaponSets,
23932393
hashOverrides = copyTable(self.hashOverrides, true),
23942394
masteryEffects = selections,
2395-
treeVersion = self.treeVersion
2395+
treeVersion = self.treeVersion,
2396+
nodeNotes = copyTable(self.nodeNotes),
23962397
}
23972398
end
23982399

@@ -2408,6 +2409,7 @@ function PassiveSpecClass:RestoreUndoState(state, treeVersion)
24082409
end
24092410
end
24102411
self:ImportFromNodeList(nil, classId, ascendClassId, state.secondaryAscendClassId, state.hashList, state.weaponSets, state.hashOverrides, state.masteryEffects, treeVersion or state.treeVersion)
2412+
self.nodeNotes = state.nodeNotes or {}
24112413
self:SetWindowTitleWithBuildClass()
24122414
end
24132415

src/Classes/PassiveTreeView.lua

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,9 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents)
539539
elseif treeClick == "RIGHT" then
540540
-- User right-clicked on a node
541541
if hoverNode then
542-
if IsKeyDown("SHIFT") then
543-
-- Shift+Right-Click: open a popup to edit the per-node author note
544-
-- (consumed by the PoE2 .build export as the node's additional_text).
542+
if IsKeyDown("SHIFT") and hoverNode.alloc then
543+
-- Shift+Right-Click on an allocated node: edit the per-node author
544+
-- note emitted into the PoE2 .build export as additional_text.
545545
local nodeId = hoverNode.id
546546
local title = "Note: " .. (hoverNode.dn or hoverNode.name or "Passive")
547547
main:OpenNoteEditPopup(title, spec.nodeNotes[nodeId], function(text)
@@ -1999,13 +1999,16 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, incSmallPassi
19991999
tooltip:AddLine(14, colorCodes.TIP.."Tip: Hold Ctrl to hide this tooltip.")
20002000
tooltip:AddLine(14, colorCodes.TIP.."Tip: Press Ctrl+C to copy this node's text.")
20012001
end
2002-
-- Per-node author note (Shift+Right-Click to set/edit) emitted into the PoE2 .build export.
2003-
if node.id and build.spec and build.spec.nodeNotes then
2002+
-- Per-node author note (Shift+Right-Click on an allocated node to set/edit).
2003+
if node.id and node.alloc and build.spec and build.spec.nodeNotes then
20042004
local existing = build.spec.nodeNotes[node.id]
2005-
tooltip:AddSeparator(10)
2006-
tooltip:AddLine(14, colorCodes.TIP.."Shift + Right-Click to add a build note (PoE2 .build export)")
20072005
if existing and existing ~= "" then
2006+
tooltip:AddSeparator(10)
20082007
tooltip:AddLine(14, "^7Note: "..existing)
2008+
tooltip:AddLine(14, colorCodes.TIP.."Shift + Right-Click to edit (PoE2 .build export)")
2009+
else
2010+
tooltip:AddSeparator(10)
2011+
tooltip:AddLine(14, colorCodes.TIP.."Shift + Right-Click to add a build note (PoE2 .build export)")
20092012
end
20102013
end
20112014
end

src/Classes/SkillsTab.lua

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ local pairs = pairs
77
local ipairs = ipairs
88
local t_insert = table.insert
99
local t_remove = table.remove
10+
local m_floor = math.floor
1011
local m_min = math.min
1112
local m_max = math.max
1213

@@ -1441,7 +1442,7 @@ function SkillsTabClass:OpenSkillSetManagePopup()
14411442
return function(buf)
14421443
local n = tonumber(buf)
14431444
if not n then return nil end
1444-
n = math.floor(n)
1445+
n = m_floor(n)
14451446
if n < 0 then n = 0 end
14461447
if n > 100 then n = 100 end
14471448
return n
@@ -1455,7 +1456,7 @@ function SkillsTabClass:OpenSkillSetManagePopup()
14551456
local set = selectedSet()
14561457
if set then
14571458
set.levelMin = clampLvl(buf)
1458-
self.build.modFlag = true
1459+
self.modFlag = true
14591460
end
14601461
end)
14611462
controls.lvlMin.tooltipText = "Lowest character level this skill set applies to in the exported .build (1-100). Leave blank to auto-split across skill sets."
@@ -1464,7 +1465,7 @@ function SkillsTabClass:OpenSkillSetManagePopup()
14641465
local set = selectedSet()
14651466
if set then
14661467
set.levelMax = clampLvl(buf)
1467-
self.build.modFlag = true
1468+
self.modFlag = true
14681469
end
14691470
end)
14701471
controls.lvlMax.tooltipText = "Highest character level this skill set applies to in the exported .build (1-100). Leave blank to auto-split across skill sets."

src/Modules/BuildExportPoE2.lua

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ local function autoBracket(i, n)
6868
return presetBrackets[n][i]
6969
end
7070
local lo = (i == 1) and 1 or (m_floor((i - 1) / n * 99) + 1)
71-
local hi = (i == n) and 100 or m_floor(i / n * 99)
71+
local hi = (i == n) and 100 or m_max(lo, m_floor(i / n * 99))
7272
return { lo, hi }
7373
end
7474

@@ -152,9 +152,9 @@ function M.PresetNextLevels(existingEntries, newEntry)
152152
local maxLvl = 0
153153
local anyHas = false
154154
for _, entry in ipairs(existingEntries or {}) do
155-
if entry.levelMax then
155+
if entry.levelMin or entry.levelMax then
156156
anyHas = true
157-
if entry.levelMax > maxLvl then maxLvl = entry.levelMax end
157+
if entry.levelMax and entry.levelMax > maxLvl then maxLvl = entry.levelMax end
158158
end
159159
end
160160
if not anyHas then
@@ -178,11 +178,13 @@ local POE2_APP_ID = "2694490"
178178
local POE2_RELATIVE = "Documents" .. "/" .. "My Games" .. "/" .. "Path of Exile 2" .. "/" .. "BuildPlanner"
179179

180180
local function dirExists(path)
181-
local handle = io.popen('ls -d "' .. path .. '" 2>/dev/null')
182-
if not handle then return false end
183-
local result = handle:read("*a")
184-
handle:close()
185-
return result and result:match("%S") ~= nil
181+
-- os.rename(x, x) is a POSIX-guaranteed no-op when x exists; we only
182+
-- trust the true-return because error strings are locale-dependent and
183+
-- cannot be used to distinguish ENOENT from EACCES reliably. False
184+
-- negatives (directory exists but isn't renameable) are acceptable here
185+
-- since this function only determines a suggested default path.
186+
local ok = os.rename(path, path)
187+
return ok == true
186188
end
187189

188190
local function tryProtonPath(baseSteam)
@@ -227,8 +229,9 @@ end
227229
-- and should not be emitted into the PassiveSkills table the loader looks up.
228230
local MAX_NORMAL_NODE_ID = 65536
229231

230-
local warnedNoStringId = false
231-
232+
-- buildPassives returns (outTable, warningString|nil).
233+
-- warningString is non-nil when tree.lua lacks stringId fields, meaning the
234+
-- in-game BuildPlanner may not recognise the numeric passive ids.
232235
local function buildPassives(build, brackets)
233236
local specList = build.treeTab and build.treeTab.specList
234237
if not specList then return {} end
@@ -278,15 +281,12 @@ local function buildPassives(build, brackets)
278281
end
279282
end
280283
end
281-
-- The PoE2 BuildPlanner expects PassiveSkills.Id strings (e.g. "projectiles18",
282-
-- "AscendancyMercenary2Notable5"). PoB's tree.lua only carries numeric ids
283-
-- until src/Export/Scripts/passivetree.lua is re-run against GGPK to emit -- cspell:ignore passivetree
284-
-- the stringId field. Warn once when that hasn't happened — the file will
285-
-- still write but the loader probably won't recognise the passive ids.
286-
if sawMissingStringId and not sawStringId and not warnedNoStringId then
287-
warnedNoStringId = true
288-
ConPrintf("[PoE2Export] tree.lua has no stringId fields; passive ids will be numeric and may not be recognised by the in-game BuildPlanner. Regenerate tree.lua via src/Export/Scripts/passivetree.lua to fix.")
284+
local passiveWarning = nil
285+
if sawMissingStringId and not sawStringId then
286+
passiveWarning = "tree.lua has no stringId fields — passive ids are numeric and may not be recognised by the in-game BuildPlanner. Regenerate tree.lua via Export/Scripts/passivetree.lua to fix."
287+
ConPrintf("[PoE2Export] " .. passiveWarning)
289288
end
289+
table.sort(order)
290290
local out = {}
291291
for _, nodeId in ipairs(order) do
292292
local m = merged[nodeId]
@@ -301,12 +301,11 @@ local function buildPassives(build, brackets)
301301
t_insert(out, entry)
302302
end
303303
end
304-
return out
304+
return out, passiveWarning
305305
end
306306

307-
-- The auto-generated Gems.lua has a typo in ~486 entries' `gameId` field
308-
-- ("Metadata/Items/Gem/" vs the canonical "Metadata/Items/Gems/"). The table
309-
-- KEY is correct; `gemInstance.gemId` stores that key, so prefer it.
307+
-- Prefer the Gems.lua table key (gemId) over the gemData.gameId field;
308+
-- the key is always authoritative whereas gameId may differ in some entries.
310309
local function gemIdFor(gem)
311310
if gem and gem.gemId then return gem.gemId end
312311
return gem and gem.gemData and gem.gemData.gameId or nil
@@ -470,7 +469,11 @@ local function buildItems(build, brackets)
470469
local itemSet = itemsTab.itemSets[setId]
471470
local interval = brackets and brackets[setIdx] or nil
472471
if itemSet then
473-
for pobSlotName, mapping in pairs(M.SlotMap) do
472+
local slotNames = {}
473+
for k in pairs(M.SlotMap) do t_insert(slotNames, k) end
474+
table.sort(slotNames)
475+
for _, pobSlotName in ipairs(slotNames) do
476+
local mapping = M.SlotMap[pobSlotName]
474477
local slotEntry = itemSet[pobSlotName]
475478
if slotEntry and slotEntry.selItemId and slotEntry.selItemId ~= 0 then
476479
local item = itemsTab.items[slotEntry.selItemId]
@@ -535,28 +538,31 @@ function M.BuildTable(build)
535538
itemBrackets = bracketsFor(build.itemsTab.itemSetOrderList, function(id) return getItemSet(build.itemsTab, id) end)
536539
end
537540

538-
root.passives = buildPassives(build, treeBrackets)
541+
local passiveWarning
542+
root.passives, passiveWarning = buildPassives(build, treeBrackets)
539543
root.skills = buildSkills(build, skillBrackets)
540544
root.items = buildItems(build, itemBrackets)
541-
return root
545+
return root, passiveWarning
542546
end
543547

544-
--- Returns (jsonString, nil) on success, or (nil, errorMessage) on failure.
548+
--- Returns (jsonString, nil, warning) on success, or (nil, errorMessage) on failure.
549+
--- warning is a non-nil string when the export succeeded but passive ids are
550+
--- numeric (tree.lua has no stringId fields) and may not be recognised in-game.
545551
function M.Export(build)
546-
local root = M.BuildTable(build)
552+
local root, passiveWarning = M.BuildTable(build)
547553
-- Force array-ness on the three top-level lists even when empty so the
548554
-- loader sees `[]` instead of `{}`.
549555
local state = { indent = true, level = 0 }
550556
local json, err = dkjson.encode(root, state)
551557
if not json then return nil, "JSON encode failed: " .. tostring(err) end
552-
return json
558+
return json, nil, passiveWarning
553559
end
554560

555561
-- Writes the exported build to disk. Caller should confirm overwrite with the
556562
-- user before calling this — no existing-file check is performed here.
557-
--- Returns (path, nil) on success.
563+
--- Returns (path, nil, warning) on success; (nil, errMsg) on failure.
558564
function M.WriteFile(build, path)
559-
local json, err = M.Export(build)
565+
local json, err, warning = M.Export(build)
560566
if not json then return nil, err end
561567
-- Best-effort: ensure the target directory exists.
562568
local dir = path:match("^(.*[/\\])")
@@ -565,7 +571,7 @@ function M.WriteFile(build, path)
565571
if not f then return nil, "Couldn't open '" .. path .. "': " .. tostring(fileErr) end
566572
f:write(json)
567573
f:close()
568-
return path
574+
return path, nil, warning
569575
end
570576

571577
return M

0 commit comments

Comments
 (0)