Check version
Check for duplicates
Check for support
What platform are you running Path of Building on?
Windows
What is the behaviour in-game?
Not an in-game-vs-PoB discrepancy — this is a state-handling bug where PoB disagrees
with itself. Expected behaviour: re-loading a build and recalculating yields that
build's own values, identical to evaluating it in a fresh PoB process. For the
attached build A that is TotalDPS 5.4331, SkillTriggerRate 0.0090.
What is the behaviour in Path of Building?
GlobalCache.cachedData entries are keyed by skill UUIDs that can collide between two
different builds using similar trigger setups (e.g. two Cyclone + Cast On Critical
Strike + Ice Nova builds). Build.lua's own recalculation path wipes the cache first
(wipeGlobalCache() at the buildFlag branch, Build.lua:1184 in v2.65.0), but any
code path that calls calcsTab:BuildOutput() directly after switching builds in one
session — as headless/scripted users of HeadlessWrapper.lua commonly do — reads the
previous build's cached trigger-source data.
Concretely: load build A, then build B, then re-load A and recalculate directly — A
reports build B's numbers exactly (TotalDPS 5.4331 → 35.0928, a 6.5× error;
SkillTriggerRate fully substituted). Stable, reproducible, no crash, no warning. This
fires on the first direct recalculation after any build switch — on real builds we
first hit divergences as large as TotalDPS 5.43 → 3018. A single wipeGlobalCache()
before recalculating restores byte-exact correctness (verified against fresh-process
evaluations of the same XMLs).
Why it matters even in-app: any future in-app path that recalculates after a build
switch without passing through the buildFlag wipe inherits this; and the
scripted/headless ecosystem (which loads many builds per process for tooling) hits it
silently — the failure mode is plausible numbers, no diff signal.
Suggested fix directions (either suffices): key cachedData by a build-unique
component in the UUID; or wipe in SetMode("BUILD", ...) / Build:Init so any build
transition invalidates, independent of the caller.
Possibly related: #2385 — per-build cache ownership would structurally prevent this
leak class.
Secondary note (FYI, not a defect claim): while serializing calc-engine internals
for equivalence testing we also observed that trigger-skill construction assembles
some per-skill mod lists in hash-iteration order, so their array order differs
run-to-run (observed on an Arcanist Brand build; baseSkillModList numeric entries).
We found no user-visible effect — PoB's mod aggregation appears order-insensitive —
so this is offered only as an FYI for anyone comparing engine internals across runs.
How to reproduce the issue
Headless, deterministic; v2.65.0 (commit f9f4f3b4), stock HeadlessWrapper. Both
builds are synthetic test builds we constructed — attached below, no player data.
Run the attached driver from a stock checkout's src/:
luajit contamination_repro.lua buildA.xml buildB.xml
Verbatim output (verified on a clean v2.65.0 checkout):
A (true values) TotalDPS=5.4331 SkillTriggerRate=0.0089994464747016
B (true values) TotalDPS=35.0928 SkillTriggerRate=0.058127941857164
A (direct recalc, DIRTY) TotalDPS=35.0928 SkillTriggerRate=0.058127941857164
A (after wipeGlobalCache) TotalDPS=5.4331 SkillTriggerRate=0.0089994464747016
- buildA: Cyclone+CoC+Ice Nova (Shadow, unarmed, low attack rate); buildB: a
different Cyclone+CoC+Ice Nova build (sword + Faster Attacks, higher rate).
- Lines 1–2 print each build's true values (the driver applies the
wipeGlobalCache()
workaround there so the baselines are honest).
- Line 3 is the bug: reload A, recalculate directly — A reports build B's numbers.
- Line 4: one
wipeGlobalCache() restores byte-exact correctness.
The driver script and both build XMLs are attached in the Screenshots field below.
PoB for PoE1 build code
Build A:
eNqlU01v2zAMPbe_QtB9S5sBQwfYLRIPzQKsaQF3uxaszMRaZCkT6XT596MUpx_Zbb09keLjI_VUXP3pnNpiJBt8qc8_nmmF3oTG-lWpf9xff7jQV5enxR1we7uc9talzOXpSZGxcrhFV-ovUsYQV8g_D1SfHiT2CL6xXOpF8KiVcUC0gA5LXbfQhCetgAz6pnpJTIgEW69VB9bXwayRZzH0GxGn1dbi001o5F41-V7VWm3Ac4vB38CvEGehObR6jlv_Oj5KyufdJkTOsAJnKKN6bZ0jRZKZYUfT3de7utRLcIRa8sOFLGpi2G4xn3N9qcdpafDoUPpw7KU_uSBjT0OzU5PYhT7q49JUlYhPCul32ONYdva7B2d5V-qzf1i9rKjeoJHxd8YdBvpfCiBWt15V0bI14FTN0a7fRTk3qBZhC-_i8CYiEDbHwmjPWozy-tKb7REleB8RFey3m2nOh1eTg2LLTizTQvRIJE6Vy698Or5I1uyJMX4Daq9D7ODFx-PBt3PR-vmNYVNEPOnFj-IBPYiThllbUpTdxmKnQVrCNfKgrhjlXPZh8Eu7kvmK0fFP-wsQASlF
Build B:
eNqlVE1z2jAQPYdfsaN7S0J7SDt2MuAMlJkAGZy2x85GXkBFlqgkk_rfdyVMQtNbua324-3b1ZOy29-1hj05r6zJxdX7SwFkpK2UWefi6-P43bW4vellDxg2i9WoUTpGbnoXWbJB0550Lj5xWUC3pvDtCPXhB_ue0FQq5GJuDQmQGr2fY025uGtIKx8EoJdkquI1UmpsyQmoUZnSyi2FibPNjrkJ2Ct6ntmKs4rhfVEK2KEJG7Jmhj-tm9jq2OnFr8ypvx-JT-uddSGZBWrpk1VuldYePEcmVPtRe_dQ5mKF2pPgeJeQSA1lUHtK51Sfi0HcGT5p4j7BNdzfa8tTj2zVwtDVtunmOSmNVRH4IuN-xzUOeGW_GtQqtLm4_AfV8ILKHUkev5X6OND_QqAPsDBQOBWURA1lcGp7FuRUEsztHs_CMNIReqreEvPnoI55WHIwDAHltkPK-uki4u0fLB_NR0cEeLinVHrV3T8fIKigWXwbdIa8Z8lz8ongB9dR401s9QX9Zmxdja8PYtA9gCnz-_iX8KOH1W1Y2awm0ZHjholbZJR0G1iYHbVolxRe2MUzqOpwXiJvrv0M88VyNrzvLSOhCspn66qEHJMPM7FK05Jy8Z1wZw0wC8XRA6H0MFJ22kxhzUqt2Zn13_4GfwAraloJ
Screenshots
No screenshots — headless repro. The three repro files inline:
contamination_repro.lua
-- contamination_repro.lua
-- Repro for: stale GlobalCache trigger data survives loading a new build
-- when calcsTab:BuildOutput() is invoked directly (PoB v2.65.0).
-- Run from a stock PathOfBuilding checkout's src/ directory:
-- luajit contamination_repro.lua buildA.xml buildB.xml
-- Expected output: build A's reload (step 3) wrongly reports build B's
-- TotalDPS/SkillTriggerRate; after wipeGlobalCache() they are restored.
-- capture our args BEFORE booting: PoB itself consumes arg[1] as an
-- optional import URL (Modules/Main.lua), so it must not see our paths
local pathA, pathB = assert(arg[1], "arg1: buildA.xml"), assert(arg[2], "arg2: buildB.xml")
arg[1], arg[2] = nil, nil
package.path = package.path .. ";../runtime/lua/?.lua;../runtime/lua/?/init.lua"
package.cpath = package.cpath .. ";../runtime/?.dll"
dofile("HeadlessWrapper.lua")
local function readFile(p)
local f = assert(io.open(p, "r"), "cannot open " .. tostring(p))
local s = f:read("*a")
f:close()
return s
end
local function report(label)
local o = build.calcsTab.mainOutput
print(string.format("%-28s TotalDPS=%-12.4f SkillTriggerRate=%s",
label, o.TotalDPS or -1, tostring(o.SkillTriggerRate)))
end
local xmlA, xmlB = readFile(pathA), readFile(pathB)
-- steps 1-2: ground truth for both builds, with the workaround applied
-- (wipe before recalculating) so the printed values are the true ones
loadBuildFromXML(xmlA, "A"); wipeGlobalCache(); build.calcsTab:BuildOutput(); report("A (true values)")
loadBuildFromXML(xmlB, "B"); wipeGlobalCache(); build.calcsTab:BuildOutput(); report("B (true values)")
-- step 3: the bug -- reload A and recalculate DIRECTLY, as headless callers
-- do; the trigger data cached from B leaks into A's outputs
loadBuildFromXML(xmlA, "A"); build.calcsTab:BuildOutput(); report("A (direct recalc, DIRTY)")
-- step 4: the one-line mitigation restores correctness
wipeGlobalCache(); build.calcsTab:BuildOutput(); report("A (after wipeGlobalCache)")
buildA.xml
<?xml version="1.0" encoding="UTF-8"?>
<PathOfBuilding>
<Build level="90" targetVersion="3_0" bandit="None" className="Shadow" ascendClassName="Assassin" mainSocketGroup="1" viewMode="CALCS" pantheonMajorGod="None" pantheonMinorGod="None"/>
<Import/>
<Calcs/>
<Skills sortGemsByDPS="false">
<Skill mainActiveSkillCalcs="2" enabled="true" slot="Body Armour" mainActiveSkill="2">
<Gem level="20" quality="0" enabled="true" nameSpec="Cyclone"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Cast On Critical Strike"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Ice Nova"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Increased Critical Strikes"/>
</Skill>
</Skills>
<Tree activeSpec="1">
<Spec title="harness" treeVersion="3_28" clusterHashFormatVersion="2" classId="6" ascendClassId="1" nodes="">
</Spec>
</Tree>
<Items activeItemSet="1">
</Items>
<Config/>
</PathOfBuilding>
buildB.xml
<?xml version="1.0" encoding="UTF-8"?>
<PathOfBuilding>
<Build level="90" targetVersion="3_0" bandit="None" className="Duelist" ascendClassName="Slayer" mainSocketGroup="1" viewMode="CALCS" pantheonMajorGod="None" pantheonMinorGod="None"/>
<Import/>
<Calcs/>
<Skills sortGemsByDPS="false">
<Skill mainActiveSkillCalcs="2" enabled="true" slot="Body Armour" mainActiveSkill="2">
<Gem level="20" quality="0" enabled="true" nameSpec="Cyclone"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Cast On Critical Strike"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Ice Nova"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Increased Critical Strikes"/>
<Gem level="20" quality="0" enabled="true" nameSpec="Faster Attacks"/>
</Skill>
</Skills>
<Tree activeSpec="1">
<Spec title="harness" treeVersion="3_28" clusterHashFormatVersion="2" classId="4" ascendClassId="1" nodes="">
</Spec>
</Tree>
<Items activeItemSet="1">
<Item id="1">
Rarity: NORMAL
Rusted Sword
</Item>
<Slot name="Weapon 1" itemId="1"/>
</Items>
<Config/>
</PathOfBuilding>
Check version
Check for duplicates
Check for support
What platform are you running Path of Building on?
Windows
What is the behaviour in-game?
Not an in-game-vs-PoB discrepancy — this is a state-handling bug where PoB disagrees
with itself. Expected behaviour: re-loading a build and recalculating yields that
build's own values, identical to evaluating it in a fresh PoB process. For the
attached build A that is TotalDPS 5.4331, SkillTriggerRate 0.0090.
What is the behaviour in Path of Building?
GlobalCache.cachedDataentries are keyed by skill UUIDs that can collide between twodifferent builds using similar trigger setups (e.g. two Cyclone + Cast On Critical
Strike + Ice Nova builds).
Build.lua's own recalculation path wipes the cache first(
wipeGlobalCache()at thebuildFlagbranch,Build.lua:1184in v2.65.0), but anycode path that calls
calcsTab:BuildOutput()directly after switching builds in onesession — as headless/scripted users of
HeadlessWrapper.luacommonly do — reads theprevious build's cached trigger-source data.
Concretely: load build A, then build B, then re-load A and recalculate directly — A
reports build B's numbers exactly (TotalDPS 5.4331 → 35.0928, a 6.5× error;
SkillTriggerRate fully substituted). Stable, reproducible, no crash, no warning. This
fires on the first direct recalculation after any build switch — on real builds we
first hit divergences as large as TotalDPS 5.43 → 3018. A single
wipeGlobalCache()before recalculating restores byte-exact correctness (verified against fresh-process
evaluations of the same XMLs).
Why it matters even in-app: any future in-app path that recalculates after a build
switch without passing through the
buildFlagwipe inherits this; and thescripted/headless ecosystem (which loads many builds per process for tooling) hits it
silently — the failure mode is plausible numbers, no diff signal.
Suggested fix directions (either suffices): key
cachedDataby a build-uniquecomponent in the UUID; or wipe in
SetMode("BUILD", ...)/Build:Initso any buildtransition invalidates, independent of the caller.
Possibly related: #2385 — per-build cache ownership would structurally prevent this
leak class.
Secondary note (FYI, not a defect claim): while serializing calc-engine internals
for equivalence testing we also observed that trigger-skill construction assembles
some per-skill mod lists in hash-iteration order, so their array order differs
run-to-run (observed on an Arcanist Brand build;
baseSkillModListnumeric entries).We found no user-visible effect — PoB's mod aggregation appears order-insensitive —
so this is offered only as an FYI for anyone comparing engine internals across runs.
How to reproduce the issue
Headless, deterministic; v2.65.0 (commit
f9f4f3b4), stock HeadlessWrapper. Bothbuilds are synthetic test builds we constructed — attached below, no player data.
Run the attached driver from a stock checkout's
src/:Verbatim output (verified on a clean v2.65.0 checkout):
different Cyclone+CoC+Ice Nova build (sword + Faster Attacks, higher rate).
wipeGlobalCache()workaround there so the baselines are honest).
wipeGlobalCache()restores byte-exact correctness.The driver script and both build XMLs are attached in the Screenshots field below.
PoB for PoE1 build code
Screenshots
No screenshots — headless repro. The three repro files inline:
contamination_repro.lua
buildA.xml
buildB.xml