From 03c7c8f6a3bce5251b2ed1c728acb326b4e3289f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 3 Oct 2026 16:43:38 +0200 Subject: [PATCH 1/4] Fix saved rolls shifting on talismans saved before 2.66 Since the 3.29 data, ParseRaw adds a talisman's base enchant when the item text has none. Greatwolf and Black Maw Talismans saved before 2.66 have no enchant line, so ItemsTab:Load counted a line that the saved ModRange ids never included, and each line got the roll of the next one (Eyes of the Greatwolf: 20% physical damage reduction instead of 24%). Skip the enchant lines added from the base when numbering the saved ids. --- spec/System/TestItemParse_spec.lua | 29 +++++++++++++++++++++++++++++ src/Classes/Item.lua | 1 + src/Classes/ItemsTab.lua | 6 ++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 66bc1cdb394..9184f64e050 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -54,6 +54,35 @@ describe("TestItemParse", function() data.itemBases[baseName] = nil end) + it("keeps saved rolls on their lines for a talisman saved before its base enchant", function() + local baseName = "Test Talisman" + data.itemBases[baseName] = { + type = "Amulet", + subType = "Talisman", + tags = { amulet = true, talisman = true }, + req = { }, + enchant = "+10 to Strength", + } + loadBuildFromXML([[ + + + + +Rarity: RARE +Old Save +Test Talisman +Implicits: 1 ++(10-20) to Dexterity + + + + +]]) + data.itemBases[baseName] = nil + + assert.are.equals(1, build.itemsTab.items[1].implicitModLines[1].range) + end) + it("Two-Toned Boots", function() local item = new("Item"):Item(raw("", "Two-Toned Boots")) assert.are.equals("Two-Toned Boots (Armour/Energy Shield)", item.baseName) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 1618e93e018..8614c251151 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -1626,6 +1626,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) crafted = true, implicit = true, enchant = true, + addedFromBase = true, extra = extra, modList = modList or { }, modTags = self.base.enchantModTypes and self.base.enchantModTypes[enchantIndex] or { }, diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba58..7ec3f4b4850 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1254,11 +1254,13 @@ function ItemsTabClass:Load(xml, dbFileName) -- Maybe it is? Maybe it isn't? Maybe up is down? Maybe good is bad? AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -- Sorry, cluster jewels are making me crazy(-ier) for _, list in ipairs{item.buffModLines, item.enchantModLines, item.scourgeModLines, item.implicitModLines, item.explicitModLines, item.crucibleModLines} do - if id <= #list then + -- Enchants added from the base data were not in the saved item text, so saved ids skip them + local savedLineCount = list[1] and list[1].addedFromBase and 0 or #list + if id <= savedLineCount then list[id].range = range break end - id = id - #list + id = id - savedLineCount end end end From 50f95311b39dbe386e9562ca3f96bca554cd2981 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 3 Oct 2026 18:08:06 +0200 Subject: [PATCH 2/4] Fix doubled "Has 1 Socket" on old Black Maw Talismans Since the 3.29 data, the Black Maw Talisman base carries "Has 1 Socket" as an enchant, and ParseRaw adds a base enchant when the item text has none. Items that carry the same line as an implicit (talismans saved before 2.66, the Night's Hold unique text, legacy items pasted from the game) got it twice: the item had two sockets, and the warning for too many gems in its slot no longer fired. Skip a base enchant line whose text is already an active implicit. --- spec/System/TestItemParse_spec.lua | 15 +++++++++++++++ src/Classes/Item.lua | 31 +++++++++++++++++++----------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 9184f64e050..afa613f7794 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -83,6 +83,21 @@ Implicits: 1 assert.are.equals(1, build.itemsTab.items[1].implicitModLines[1].range) end) + it("does not add a talisman base enchant already saved as an implicit", function() + local baseName = "Test Talisman" + data.itemBases[baseName] = { + type = "Amulet", + subType = "Talisman", + tags = { amulet = true, talisman = true }, + req = { }, + enchant = "+10 to Strength", + } + local item = new("Item"):Item("Rarity: Normal\n" .. baseName .. "\nImplicits: 1\n+10 to Strength") + data.itemBases[baseName] = nil + + assert.are.equals(0, #item.enchantModLines) + end) + it("Two-Toned Boots", function() local item = new("Item"):Item(raw("", "Two-Toned Boots")) assert.are.equals("Two-Toned Boots (Armour/Energy Shield)", item.baseName) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 8614c251151..4fb5748fdb3 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -1618,19 +1618,28 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end if self.base and self.base.enchant and #self.enchantModLines == 0 then + -- Items saved before their base gained this enchant carry it as an implicit (Black Maw Talisman) + local activeImplicitLines = { } + for _, modLine in ipairs(self.implicitModLines) do + if self:CheckModLineVariant(modLine) then + activeImplicitLines[modLine.line] = true + end + end local enchantIndex = 1 for line in self.base.enchant:gmatch("[^\n]+") do - local modList, extra = modLib.parseMod(line) - t_insert(self.enchantModLines, { - line = line, - crafted = true, - implicit = true, - enchant = true, - addedFromBase = true, - extra = extra, - modList = modList or { }, - modTags = self.base.enchantModTypes and self.base.enchantModTypes[enchantIndex] or { }, - }) + if not activeImplicitLines[line] then + local modList, extra = modLib.parseMod(line) + t_insert(self.enchantModLines, { + line = line, + crafted = true, + implicit = true, + enchant = true, + addedFromBase = true, + extra = extra, + modList = modList or { }, + modTags = self.base.enchantModTypes and self.base.enchantModTypes[enchantIndex] or { }, + }) + end enchantIndex = enchantIndex + 1 end end From 3f443a7fc66ae936d942696f7afa2dd76df4559e Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 2 Oct 2026 22:51:50 +0200 Subject: [PATCH 3/4] Fix pasted item rolls changing when loading builds saved before 2.66 Before 2.66, PoB could not parse the in-game "value(min-max)" format and saved a pasted line such as "14(9-21)% increased maximum Life, Mana and Global Energy Shield" as is, with the default range in its ModRange. ParseRaw now reads the roll from that value, but ItemsTab:Load then applied the saved ModRange over it, so the line loaded as 15% instead of 14%, and saving the build kept the wrong value. Lines whose range comes from their written value keep it on load. # Conflicts: # src/Classes/ItemsTab.lua --- spec/System/TestItemParse_spec.lua | 20 ++++++++++++++++++++ src/Classes/Item.lua | 1 + src/Classes/ItemsTab.lua | 5 ++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index afa613f7794..b04717b234e 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -930,6 +930,26 @@ describe("TestAdvancedItemParse #item", function() assert.are.equals("Commissioned 150720 coins to commemorate Chitus", seedLine) end) + it("keeps the roll written in a saved value(min-max) line over its ModRange", function() + loadBuildFromXML([[ + + + + +Rarity: UNIQUE +Crown of the Inward Eye +Prophet Crown +Implicits: 0 +{range:0.5}14(9-21)% increased maximum Life, Mana and Global Energy Shield + + + + +]]) + + assert.are.equals(0.416667, build.itemsTab.items[1].explicitModLines[1].range) + end) + it("preserves independently rolled values on the same modifier line", function() local item = new("Item"):Item([[ Rarity: Unique diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 4fb5748fdb3..0dcb16adafe 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -1181,6 +1181,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) line = advancedCopyLine:gsub("(%-?%d+%.?%d*)%(%-?%d+%.?%d*%-%-?%d+%.?%d*%)", "%1") elseif bestPrecisionRange <= 1 and bestPrecisionRange >= 0 then modLine.range = bestPrecisionRange + modLine.rangeFromValue = true end end local rangedLine = itemLib.applyRange(line, 1, catalystScalar, modLine.corruptedRange) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 7ec3f4b4850..baecc509184 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1257,7 +1257,10 @@ function ItemsTabClass:Load(xml, dbFileName) -- Enchants added from the base data were not in the saved item text, so saved ids skip them local savedLineCount = list[1] and list[1].addedFromBase and 0 or #list if id <= savedLineCount then - list[id].range = range + -- Versions that could not parse "value(min-max)" lines saved them as is with a default ModRange + if not list[id].rangeFromValue then + list[id].range = range + end break end id = id - savedLineCount From b0ddd85298980b28f3fbdbfe8c1ef1e73a43a983 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sun, 4 Oct 2026 01:26:59 +0200 Subject: [PATCH 4/4] Fix saved rolls shifting when old mod lines are now item properties PoB 2.66 and older saved in-game property lines such as "Intangibility: 5%" and "Memory Strands: 58", and flask lines such as "Lasts 4.00 Seconds", as mod lines with a ModRange entry each. 2.67 reads them as item properties or skips them, so ItemsTab:Load applied every later ModRange one line too early: an Entropy Grip saved in 2.66 loaded its "+(10-13) to all Attributes" roll at 0.5 instead of 1. Save writes at most one ModRange per mod line, so more entries than lines means the saved positions no longer match. The entries are then ignored and the {range:} tags of the item text are kept. The line count leaves out talisman enchants added from the base data, as the ModRange id loop already does; both now use countSavedModLines. --- spec/System/TestItemParse_spec.lua | 22 ++++++++++++++++++++++ src/Classes/ItemsTab.lua | 21 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index b04717b234e..0bb61b3f6fb 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -1053,6 +1053,28 @@ Implicits: 0 assert.are.equals(1, #item.explicitModLines) end) + it("keeps saved rolls when a property was saved among the modifier lines", function() + loadBuildFromXML([[ + + + + +Rarity: RARE +Pain Shell +Vaal Regalia +Implicits: 0 +Intangibility: 7% +{range:0}+(10-13) to all Attributes + + + + + +]]) + + assert.are.equals(0, build.itemsTab.items[1].explicitModLines[1].range) + end) + it("preserves cluster jewel enchants from advanced copy", function() newBuild() runCallback("onFrame") diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index baecc509184..40ca711bebb 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1218,6 +1218,11 @@ function ItemsTabClass:Load(xml, dbFileName) self.itemSets = { } self.itemSetOrderList = { } self.tradeQuery.statSortSelectionList = { } + -- Enchants from the base data are only added to an item text without enchants and were not in the saved text, + -- so saved ModRange ids skip the whole list + local function countSavedModLines(modLines) + return modLines[1] and modLines[1].addedFromBase and 0 or #modLines + end for _, node in ipairs(xml) do if node.elem == "Item" then local item = new("Item"):Item("") @@ -1243,10 +1248,21 @@ function ItemsTabClass:Load(xml, dbFileName) item.hasAltVariant5 = true item.variantAlt5 = tonumber(node.attrib.variantAlt5) end + local modRangeCount = 0 + for _, child in ipairs(node) do + if type(child) == "table" and child.elem == "ModRange" then + modRangeCount = modRangeCount + 1 + end + end + local ignoreModRanges for _, child in ipairs(node) do if type(child) == "string" then item:ParseRaw(child) - elseif child.elem == "ModRange" then + -- Save writes at most one ModRange per mod line. More entries than lines means some saved lines are + -- no longer mod lines (e.g. Intangibility before 2.67), so the ids no longer match their lines and the + -- {range:} tags of the item text are kept instead + ignoreModRanges = modRangeCount > countSavedModLines(item.enchantModLines) + #item.scourgeModLines + #item.implicitModLines + #item.explicitModLines + #item.crucibleModLines + elseif child.elem == "ModRange" and not ignoreModRanges then local id = tonumber(child.attrib.id) or 0 local range = tonumber(child.attrib.range) or 1 -- This is garbage, but needed due to change to separate mod line lists @@ -1254,8 +1270,7 @@ function ItemsTabClass:Load(xml, dbFileName) -- Maybe it is? Maybe it isn't? Maybe up is down? Maybe good is bad? AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -- Sorry, cluster jewels are making me crazy(-ier) for _, list in ipairs{item.buffModLines, item.enchantModLines, item.scourgeModLines, item.implicitModLines, item.explicitModLines, item.crucibleModLines} do - -- Enchants added from the base data were not in the saved item text, so saved ids skip them - local savedLineCount = list[1] and list[1].addedFromBase and 0 or #list + local savedLineCount = countSavedModLines(list) if id <= savedLineCount then -- Versions that could not parse "value(min-max)" lines saved them as is with a default ModRange if not list[id].rangeFromValue then