Skip to content
Closed
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ src/Data/TimelessJewelData/*.bin
# Simplegraphic Debugging
runtime/imgui.ini
runtime/SimpleGraphic/SimpleGraphic.log

buildcode.txt
main.go
Empty file added debug_args.lua
Empty file.
1 change: 1 addition & 0 deletions jsons/items.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions jsons/passives.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion runtime/lua/xml.lua
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ local function composeNode(frag, node, lvl)
t_insert(frag, '<')
t_insert(frag, node.elem)
if node.attrib then
for key, val in pairs(node.attrib) do
local sortedKeys = {}
for key in pairs(node.attrib) do
t_insert(sortedKeys, key)
end
table.sort(sortedKeys)
for _, key in ipairs(sortedKeys) do
local val = node.attrib[key]
Comment on lines +120 to +126
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change alters XML serialization ordering (attributes now sorted). Given xml.ComposeXML is a core utility, it would be good to add a small busted test covering deterministic attribute ordering (and that invalid attrib keys still return an error message rather than throwing), to prevent regressions.

Copilot uses AI. Check for mistakes.
if val then
if type(key) ~= "string" then
return "invalid xml tree (attribute name in <"..node.elem.."> is not a string)"
Comment on lines +120 to 129
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

composeNode now sorts node.attrib keys before validating they are strings. If a non-string attribute key ever appears, table.sort(sortedKeys) can throw (attempt to compare number with string / invalid order) rather than returning the existing descriptive error message. Consider validating keys (and maybe values) while building sortedKeys, returning the same error string before sorting, or sorting with a comparator that assumes strings only after validation.

Copilot uses AI. Check for mistakes.
Expand Down
8 changes: 7 additions & 1 deletion src/Classes/CalcsTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,13 @@ function CalcsTabClass:Load(xml, dbFileName)
end

function CalcsTabClass:Save(xml)
for k, v in pairs(self.input) do
local sortedInputKeys = { }
for k in pairs(self.input) do
t_insert(sortedInputKeys, k)
end
table.sort(sortedInputKeys)
for _, k in ipairs(sortedInputKeys) do
local v = self.input[k]
local child = { elem = "Input", attrib = {name = k} }
if type(v) == "number" then
child.attrib.number = tostring(v)
Expand Down
16 changes: 14 additions & 2 deletions src/Classes/ConfigTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,13 @@ function ConfigTabClass:Save(xml)
local child = { elem = "ConfigSet", attrib = { id = tostring(configSetId), title = configSet.title } }
t_insert(xml, child)

for k, v in pairs(configSet.input) do
local sortedInputKeys = { }
for k in pairs(configSet.input) do
t_insert(sortedInputKeys, k)
end
table.sort(sortedInputKeys)
for _, k in ipairs(sortedInputKeys) do
local v = configSet.input[k]
if v ~= self:GetDefaultState(k, type(v)) then
local node = { elem = "Input", attrib = { name = k } }
if type(v) == "number" then
Expand All @@ -722,7 +728,13 @@ function ConfigTabClass:Save(xml)
t_insert(child, node)
end
end
for k, v in pairs(configSet.placeholder) do
local sortedPlaceholderKeys = { }
for k in pairs(configSet.placeholder) do
t_insert(sortedPlaceholderKeys, k)
end
table.sort(sortedPlaceholderKeys)
for _, k in ipairs(sortedPlaceholderKeys) do
local v = configSet.placeholder[k]
local node = { elem = "Placeholder", attrib = { name = k } }
if type(v) == "number" then
node.attrib.number = tostring(v)
Expand Down
8 changes: 7 additions & 1 deletion src/Classes/ItemsTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1157,7 +1157,13 @@ function ItemsTabClass:Save(xml)
for _, itemSetId in ipairs(self.itemSetOrderList) do
local itemSet = self.itemSets[itemSetId]
local child = { elem = "ItemSet", attrib = { id = tostring(itemSetId), title = itemSet.title, useSecondWeaponSet = tostring(itemSet.useSecondWeaponSet) } }
for slotName, slot in pairs(self.slots) do
local sortedSlotNames = { }
for slotName in pairs(self.slots) do
t_insert(sortedSlotNames, slotName)
end
table.sort(sortedSlotNames)
for _, slotName in ipairs(sortedSlotNames) do
local slot = self.slots[slotName]
if not slot.nodeId then
t_insert(child, { elem = "Slot", attrib = { name = slotName, itemId = tostring(itemSet[slotName].selItemId), itemPbURL = itemSet[slotName].pbURL or "", active = itemSet[slotName].active and "true" }})
else
Expand Down
25 changes: 22 additions & 3 deletions src/Classes/PassiveSpec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,15 @@ function PassiveSpecClass:Save(xml)
for nodeId in pairs(self.allocNodes) do
t_insert(allocNodeIdList, nodeId)
end
table.sort(allocNodeIdList)
local masterySelections = { }
for mastery, effect in pairs(self.masterySelections) do
local masteryKeys = { }
for mastery in pairs(self.masterySelections) do
t_insert(masteryKeys, mastery)
end
table.sort(masteryKeys)
for _, mastery in ipairs(masteryKeys) do
local effect = self.masterySelections[mastery]
t_insert(masterySelections, "{"..mastery..","..effect.."}")
end
xml.attrib = {
Expand All @@ -203,7 +210,13 @@ function PassiveSpecClass:Save(xml)
local sockets = {
elem = "Sockets"
}
for nodeId, itemId in pairs(self.jewels) do
local jewelNodeIds = { }
for nodeId in pairs(self.jewels) do
t_insert(jewelNodeIds, nodeId)
end
table.sort(jewelNodeIds)
for _, nodeId in ipairs(jewelNodeIds) do
local itemId = self.jewels[nodeId]
-- jewel socket contents should not be saved unless they contain a valid jewel
if itemId > 0 then
local socket = { elem = "Socket", attrib = { nodeId = tostring(nodeId), itemId = tostring(itemId) }}
Expand All @@ -216,7 +229,13 @@ function PassiveSpecClass:Save(xml)
elem = "Overrides"
}
if self.hashOverrides then
for nodeId, node in pairs(self.hashOverrides) do
local overrideNodeIds = { }
for nodeId in pairs(self.hashOverrides) do
t_insert(overrideNodeIds, nodeId)
end
table.sort(overrideNodeIds)
for _, nodeId in ipairs(overrideNodeIds) do
local node = self.hashOverrides[nodeId]
local override = { elem = "Override", attrib = { nodeId = tostring(nodeId), icon = tostring(node.icon), activeEffectImage = tostring(node.activeEffectImage), dn = tostring(node.dn) } }
for _, modLine in ipairs(node.sd) do
t_insert(override, modLine)
Expand Down
177 changes: 171 additions & 6 deletions src/HeadlessWrapper.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
-- This wrapper allows the program to run headless on any OS (in theory)
-- It can be run using a standard lua interpreter, although LuaJIT is preferable

-- Store command line arguments before they get modified by the loading process
local originalArgs = {}
if arg then
for i = -5, 10 do
originalArgs[i] = arg[i]
end
end

-- Callbacks
local callbackTable = { }
Expand Down Expand Up @@ -89,12 +96,12 @@ function IsKeyDown(keyName) end
function Copy(text) end
function Paste() end
function Deflate(data)
-- TODO: Might need this
return ""
local zlib = require("zlib")
return zlib.deflate()(data, "finish")
end
function Inflate(data)
-- TODO: And this
return ""
local zlib = require("zlib")
return zlib.inflate()(data, "finish")
end
Comment on lines 98 to 105
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HeadlessWrapper’s Deflate/Inflate now hard-require the Lua module zlib. This repo already depends on lzip for DEFLATE (e.g. src/UpdateCheck.lua), but does not appear to ship a zlib Lua binding; on environments where zlib isn’t installed this will crash at runtime (and headless mode is used in CI). Consider switching to the existing lzip binding (or pcall(require, ...) fallback) and ideally requiring the module once rather than on every call.

Copilot uses AI. Check for mistakes.
function GetTime()
return 0
Expand Down Expand Up @@ -176,6 +183,22 @@ function require(name)
if name == "lcurl.safe" then
return
end
-- Provide a basic utf8 stub for headless mode
if name == "lua-utf8" then
return {
len = function(s) return #s end,
sub = function(s, i, j) return string.sub(s, i, j) end,
char = function(...) return string.char(...) end,
byte = function(s, i) return string.byte(s, i) end,
find = function(s, pattern, init, plain) return string.find(s, pattern, init, plain) end,
gmatch = function(s, pattern) return string.gmatch(s, pattern) end,
gsub = function(s, pattern, repl, n) return string.gsub(s, pattern, repl, n) end,
match = function(s, pattern, init) return string.match(s, pattern, init) end,
reverse = function(s) return string.reverse(s) end,
upper = function(s) return string.upper(s) end,
lower = function(s) return string.lower(s) end,
}
Comment on lines +186 to +200
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The require override always returns the stub table for lua-utf8, even if a real lua-utf8 module is available in the runtime. This can silently break any code relying on correct UTF-8 semantics (the stub uses byte-based #/string.sub, etc.). Prefer attempting l_require("lua-utf8") first (pcall) and only falling back to the stub when the module can’t be loaded.

Copilot uses AI. Check for mistakes.
end
return l_require(name)
end

Expand Down Expand Up @@ -214,6 +237,148 @@ function loadBuildFromJSON(getItemsJSON, getPassiveSkillsJSON)
runCallback("OnFrame")
local charData = build.importTab:ImportItemsAndSkills(getItemsJSON)
build.importTab:ImportPassiveTreeAndJewels(getPassiveSkillsJSON, charData)
-- You now have a build without a correct main skill selected, or any configuration options set
-- Good luck!
-- Try to select the first main skill group if available
if build and build.skillsTab and build.skillsTab.socketGroupList and #build.skillsTab.socketGroupList > 0 then
build.mainSocketGroup = 1
build.skillsTab:SetActiveSkill(1, 1)
runCallback("OnFrame") -- Recalculate stats
end
-- You now have a build with a main skill selected (if available)
end

-- Check if JSON files were provided as command line arguments using original args
local itemsJSONPath, passivesJSONPath

-- Check original arguments for JSON files
if originalArgs[1] and originalArgs[2] then
itemsJSONPath = originalArgs[1]
passivesJSONPath = originalArgs[2]
-- print("Found JSON files in arguments - loading items and passives data...")

-- Read the JSON files
local itemsFile = io.open(itemsJSONPath, "r")
local passivesFile = io.open(passivesJSONPath, "r")

if itemsFile and passivesFile then
local itemsJSON = itemsFile:read("*all")
Comment on lines +258 to +263
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If only one of the two JSON files opens successfully, the other handle is left open because the code only closes files inside the if itemsFile and passivesFile branch. Consider closing any handle that did open before taking the error path (or open/read each file in its own pcall/helper).

Copilot uses AI. Check for mistakes.
local passivesJSON = passivesFile:read("*all")
itemsFile:close()
passivesFile:close()

-- print("Calling loadBuildFromJSON...")
local success, error_msg = pcall(function()
loadBuildFromJSON(itemsJSON, passivesJSON)
end)
Comment on lines +249 to +271
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file now executes a fairly large JSON-loading/export routine at module top-level when two CLI args are present. That introduces non-trivial side effects in HeadlessWrapper.lua (which is used by CI and potentially as a library) and includes a lot of commented-out debug logging. Consider moving this functionality into a dedicated script/module (or guarding it behind an explicit flag) to keep the wrapper focused on providing the headless environment and helper functions.

Copilot uses AI. Check for mistakes.

if not success then
-- print("Warning: loadBuildFromJSON encountered an error:", error_msg)
-- print("Continuing to check what data was loaded...")
end

-- print()
-- print("Build loading completed (with or without errors). Checking build data...")

-- Print some information from the build object to verify it's working
-- print("\n=== BUILD OBJECT VERIFICATION ===")
if build then
-- print("✓ build global object exists")
-- print("build type:", type(build))

-- Check if build.spec exists (passive tree)
if build.spec then
-- print("✓ build.spec exists (passive tree)")
if build.spec.nodes then
local nodeCount = 0
for _ in pairs(build.spec.nodes) do
nodeCount = nodeCount + 1
end
-- print(" - Number of passive nodes:", nodeCount)
end
if build.spec.allocNodes then
-- print(" - Number of allocated nodes:", #build.spec.allocNodes)
end
else
-- print("✗ build.spec not found")
end

-- Check if character data exists
if build.characterLevel then
-- print("✓ Character level:", build.characterLevel)
end
if build.characterName then
-- print("✓ Character Name: ", build.characterName)
end
if build.characterClass then
-- print("✓ Character class:", build.characterClass)
end

-- Check if items exist
if build.itemsTab and build.itemsTab.items then
local itemCount = 0
for _ in pairs(build.itemsTab.items) do
itemCount = itemCount + 1
end
-- print("✓ Items loaded in build.itemsTab.items, count:", itemCount)

-- Show a few example items
local count = 0
for k, item in pairs(build.itemsTab.items) do
if count < 3 and item.name then
-- print(" - Item " .. (count + 1) .. ":", item.name, "(" .. (item.baseName or "unknown base") .. ")")
end
count = count + 1
if count >= 3 then break end
end
elseif build.itemsTab and build.itemsTab.list then
local itemCount = 0
for _ in pairs(build.itemsTab.list) do
itemCount = itemCount + 1
end
-- print("✓ Items loaded in build.itemsTab.list, count:", itemCount)
else
-- print("✗ Items not found or not loaded")
end

-- Print some build calculation results if available
if build.calcsTab and build.calcsTab.buildOutput then
-- print("✓ Build calculations available")
local output = build.calcsTab.buildOutput
if output.Life then
-- print(" - Total Life:", output.Life)
end
if output.EnergyShield then
-- print(" - Total Energy Shield:", output.EnergyShield)
end
if output.TotalDPS then
-- print(" - Total DPS:", output.TotalDPS)
end
end

-- Try to export build code (requires working Deflate function)
local buildCode = common.base64.encode(Deflate(build:SaveDB("code"))):gsub("+","-"):gsub("/","_")
print(buildCode)
-- local f = io.open("/home/alexander/dev/investigations/PathOfBuilding/buildcode.txt", "w")
-- if f then
-- f:write(buildCode)
-- f:close()
-- -- print("Build code written to buildcode.txt")
-- else
-- -- print("Failed to open buildcode.txt for writing!")
-- end
else
-- print("✗ build global object does not exist!")
end
-- print("=== END BUILD VERIFICATION ===\n")
else
-- print("Error: Could not open JSON files")
if not itemsFile then
-- print(" - Could not open items file: " .. itemsJSONPath)
end
if not passivesFile then
-- print(" - Could not open passives file: " .. passivesJSONPath)
end
end
else
-- print("No JSON files provided as command line arguments")
-- print("Usage: luajit HeadlessWrapper.lua <items.json> <passives.json>")
end
14 changes: 9 additions & 5 deletions src/Modules/Build.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1896,11 +1896,15 @@ function buildMode:SaveDB(fileName)
t_insert(dbXML, node)
end

-- Call on all savers to save their data in their respective sections
for elem, saver in pairs(self.savers) do
local node = { elem = elem }
saver:Save(node)
t_insert(dbXML, node)
-- Call on all savers to save their data in their respective sections (fixed order for deterministic output)
local saverOrder = {"Config", "Notes", "Party", "Tree", "TreeView", "Items", "Skills", "Calcs", "Import"}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saverOrder duplicates the keys already defined in self.savers during Init. This creates a sync risk: adding/removing a saver requires updates in two places or the section may stop being saved. Consider defining a single ordered list once (used to build self.savers and to iterate here), or deriving the order from self.savers with a stable predefined list constant.

Suggested change
local saverOrder = {"Config", "Notes", "Party", "Tree", "TreeView", "Items", "Skills", "Calcs", "Import"}
local saverOrder = {}
for key in pairs(self.savers) do
t_insert(saverOrder, key)
end
t_sort(saverOrder)

Copilot uses AI. Check for mistakes.
for _, elem in ipairs(saverOrder) do
local saver = self.savers[elem]
if saver then
local node = { elem = elem }
saver:Save(node)
t_insert(dbXML, node)
end
end

-- Compose the XML
Expand Down
Loading