Skip to content

Stale GlobalCache trigger data survives loading a new build when recalculation is invoked directly; produces stable but wrong trigger rates/DPS #9947

Description

@TheCityVault

Check version

  • I'm running the latest version of Path of Building and I've verified this by checking the changelog

Check for duplicates

  • I've checked for duplicate open and closed issues by using the search function of the issue tracker

Check for support

  • I've checked that the behaviour is supposed to be supported. If it isn't please open a feature request instead (Red text is a feature request).

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>

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions