Skip to content

Cleave / Vaal Cleave: AreaOfEffectRadius differs between app launches (bimodal) — and both values are wrong: two statMap writers race for one skillData key #10027

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 calculation 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 value from the calculation in-game?

Per the stat semantics the game adds both radius components: base 20, plus the level-scaled radius growth (+11 at effective gem level 23), plus +1 per nearby enemy up to 10 (+1 at the config's 1 nearby enemy) — so the build below should show AreaOfEffectRadius 32 (v2.66.2 values).

What is the value from the calculation in Path of Building?

PoB shows 31 or 21 for the same build depending on the app launchskillData.radiusExtra has two writers (Cleave's own cleave_+1_base_radius_per_nearby_enemy_up_to_10 and the global active_skill_base_radius_+) and only the one that lands last in pairs() order survives; that order changes per process under LuaJIT's string-hash seeding. Both displayed values drop one real component. Observed 7×31 / 1×21 over 8 fresh launches on the v2.66.2 source archive (5/3 split on v2.65.0; seed-dependent, not 50/50).

How to reproduce the issue

  1. Import the build code below (Duelist, single Cleave gem L23/q20, Rusted Sword, config "# of nearby enemies" = 1).
  2. Note Calcs-tab AreaOfEffectRadius.
  3. Close and relaunch PoB, reload the same build; repeat a few times — the value flips between launches.

Headless: save the attached driver into a stock checkout's src/ and run luajit cleave_radius_repro.lua cleave_radius.xml several times (one process per run).

PoB for PoE1 build code

eNplU01T20AMPSe_YmfvbSD0QDs2TGJKmhkSmJi2x45iK8mW_XD3w5B_X-3aJpTepJWe9PTebHb9oiRr0TphdM7PP55xhroytdD7nH9_vP1wya-vxtkD-MP9bh6EjJWr8ShLMZPYosz5Z4J5sHv0P4ZRF7_obQu6Fj7na6ORs0qCc2tQmPObgFI4zxm4CnVdnCpdqwKhS1M9oV9YExpixlkr8HllauopZndFyVkD2h_Q6BX8NnZh6gH8-i702_dJpL1UjbE-hQXIyqWofBJSOuaoskDl5sebhzLnO5AOOdX7hkRqVnnRYsoTPjFDDVuJtMfbQPudNHTz3NRHNrPKBMvfQyMqDh5ltG8QcXrB2Z8AUvgjJWf_jdWkT9lgRfdLhLY7aJRN0sR4Rhe5GD5aRAbdwgQ57w-hhHnhJal4AKvROXKOmt_4Nr2MVgXn0X4Dd7g1VsHJ12nv45J4ffrHv_hCrDVZRLLwnhwtTNwio2SAJ4V7ajEu0b-yizkTdZdvwJISX9j6frOa3Y03kVDNymdj6zQ5Nnc3kdxJnJz_RGiMZmSJoOoyTUoOp-6kTGH0Tuy7dboJA1IF6UUjBdo1gt0ev2pUAkkcHdQW7WnQgM8m7__EX-yiDBU=

Screenshots

Versions

Found and mechanism-verified on v2.65.0; reproduced on an unmodified
v2.66.2 source archive
(current release, its own runtime/lua51.dll).
All four sites unchanged in v2.66.2: the merge loop
(Modules/CalcActiveSkill.lua:61), the last-wins extraction (:633-638),
the global writer (Data/SkillStatMap.lua:567), and both Cleave /
Vaal Cleave statMap entries (Data/Skills/act_str.lua:1805, 1977).
Cleave's per-nearby stat VALUE was rebalanced between the versions, so
the two displayed values differ per version — the flip is the same.

Reproduction

  1. Duelist, any one-handed axe/sword, a single Cleave gem at effective
    level ≥ 22
    (e.g. level-21+ gem, or 20 + "+1 to socketed gems"),
    any quality; Configuration: "# of nearby enemies" ≥ 1. (Import code
    and build XML below — exactly this build, L23 q20.)
  2. Note Calcs-tab AreaOfEffectRadius.
  3. Close and relaunch PoB, reload the same build. Repeat a few times:
    the radius flips across launches. The split is seed-dependent, not
    50/50 (observed 5/3 over 8 launches on v2.65.0; 7/1 on v2.66.2).

Headless, from a stock checkout's src/ (driver below; run it several
times — the coin is per-process). Verbatim from the v2.66.2 archive run:

run 1 : AreaOfEffectRadius=31  skillData.radiusExtra=11
run 2 : AreaOfEffectRadius=31  skillData.radiusExtra=11
run 3 : AreaOfEffectRadius=31  skillData.radiusExtra=11
run 4 : AreaOfEffectRadius=31  skillData.radiusExtra=11
run 5 : AreaOfEffectRadius=31  skillData.radiusExtra=11
run 6 : AreaOfEffectRadius=21  skillData.radiusExtra=1
run 7 : AreaOfEffectRadius=31  skillData.radiusExtra=11
run 8 : AreaOfEffectRadius=31  skillData.radiusExtra=11

On v2.66.2 with this build the two writers evaluate to 11 (global,
level-scaled at L23) and 1 (own, +1 × 1 nearby enemy); the game adds
both, so the expected radius is 20 + 11 + 1 = 32 — PoB shows 31 or
21 depending on the launch. (On v2.65.0 the same build flips 30 ↔ 31.)

Mechanism (to the line)

  • Cleave's own statMap maps
    cleave_+1_base_radius_per_nearby_enemy_up_to_10
    skill("radiusExtra", …, Multiplier:NearbyEnemies, limit 10, limitTotal); the tag is evaluated at SkillData extraction (on v2.66.2
    data: 1 × nearby, so 1 at nearby=1; on v2.65.0 data the stat value
    was larger and the limit capped it at 10).
  • The global SkillStatMap maps active_skill_base_radius_+
    skill("radiusExtra", nil) — level-scaled, 11 at effective L23
    (10 at L20/21 on v2.65.0, which is why low-level Cleave showed no
    flicker there: both writers agreed).
  • calcs.mergeSkillInstanceMods iterates for stat, statValue in pairs(stats) (CalcActiveSkill.lua:61); extraction is last-wins:
    activeSkill.skillData[value.key] = value.value
    (CalcActiveSkill.lua:633-638). Which writer lands last = pairs()
    order = per-process LuaJIT hash seed. One coin per launch.
  • Vaal Cleave shares the same statMap pair (same defect). A data scan of
    all 1,489 skill blocks (including all 212 transfigured variants, with
    SkillPart-tag co-application semantics) finds exactly one more
    structural site — StalkingPustule radius — which is unreachable in
    practice (its own-map stat never materializes for the item-granted
    version). Cleave of Rage is clean (its radius stat maps to an
    AreaOfEffect modifier, not a SkillData write).

Why tests never see this (worth stating in the fix's regression test)

Determinism here is conditional on identical execution history: any
harness that compares two runs with the same process/seed/history gets
the same pairs() order on both sides and passes — including A/B tests
of a candidate fix, which flicker identically on both legs. The coin
only shows across process boundaries. A regression test for this class
must compare across fresh processes (we run k-fresh-process
byte-comparisons; that is what caught it).

Fix directions (mindful of the hash-weight constraint from #9947)

You noted in #9947 that per-lookup key weight sank the build-unique
cache-key approach there. Both directions below are zero-cost per
lookup:

  1. Determinism: iterate the stats table in sorted key order in
    mergeSkillInstanceMods (one table.sort of ~40 short strings per
    skill instantiation; no change to any lookup path). This pins ONE
    branch reproducibly — we run exactly this patch in our harness and
    the entire snapshot corpus is byte-identical to unpatched PoB except
    the three collision sites.
  2. Correctness: the game adds both components (base 20 + level
    growth + per-nearby up-to-10), so beyond determinism the two writers
    should ACCUMULATE into radiusExtra (or use distinct keys summed at
    read), not overwrite. That is a game-behavior call we leave to you;
    determinism alone at least makes the displayed value stable and
    testable.
cleave_radius_repro.lua (repro driver — save into src/ and run)
-- cleave_radius_repro.lua
-- Repro for: Cleave/Vaal Cleave AreaOfEffectRadius is launch-dependent --
-- two statMap writers race for skillData.radiusExtra; pairs() order decides
-- which lands last, and LuaJIT's per-process string-hash seed decides
-- pairs() order (PoB v2.65.0).
-- Run from a stock PathOfBuilding checkout's src/ directory, SEVERAL times
-- (one process per run -- the coin is per-process):
--     luajit cleave_radius_repro.lua cleave_radius.xml
-- Expected: AreaOfEffectRadius flips across runs (v2.66.2 data: 31 <-> 21;
-- v2.65.0 data: 31 <-> 30). The split is seed-dependent, not 50/50.
-- Both values are wrong vs the game, which adds both components; see the
-- issue text.

-- capture our arg BEFORE booting: PoB itself consumes arg[1] as an
-- optional import URL (Modules/Main.lua), so it must not see our path
local pathBuild = assert(arg[1], "arg1: cleave_radius.xml")
arg[1] = nil

package.path = package.path .. ";../runtime/lua/?.lua;../runtime/lua/?/init.lua"
package.cpath = package.cpath .. ";../runtime/?.dll"
dofile("HeadlessWrapper.lua")

local f = assert(io.open(pathBuild, "r"), "cannot open " .. tostring(pathBuild))
local xml = f:read("*a")
f:close()

loadBuildFromXML(xml, "cleave-radius-repro")
wipeGlobalCache()
build.calcsTab:BuildOutput()

local o = build.calcsTab.mainOutput
local skillData = build.calcsTab.mainEnv.player.mainSkill.skillData
-- rerun this command several times: the pair flips across processes
print(string.format("AreaOfEffectRadius=%s  skillData.radiusExtra=%s",
	tostring(o.AreaOfEffectRadius), tostring(skillData.radiusExtra)))
cleave_radius.xml (minimal repro build — constructed, no player data)
<?xml version="1.0" encoding="UTF-8"?>
<PathOfBuilding>
	<Build level="90" targetVersion="3_0" bandit="None" className="Duelist" ascendClassName="None" mainSocketGroup="1" viewMode="CALCS" pantheonMajorGod="None" pantheonMinorGod="None"/>
	<Import/>
	<Calcs/>
	<Skills sortGemsByDPS="false">
		<Skill mainActiveSkillCalcs="1" enabled="true" slot="Body Armour" mainActiveSkill="1">
			<Gem level="23" quality="20" enabled="true" nameSpec="Cleave"/>
		</Skill>
	</Skills>
	<Tree activeSpec="1">
		<Spec title="harness" treeVersion="3_28" clusterHashFormatVersion="2" classId="4" ascendClassId="0" nodes="">
		</Spec>
	</Tree>
	<Items activeItemSet="1">
		<Item id="1">
Rarity: NORMAL
Rusted Sword
		</Item>
		<Slot name="Weapon 1" itemId="1"/>
	</Items>
	<Config>
		<Input name="multiplierNearbyEnemies" number="1"/>
	</Config>
</PathOfBuilding>

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions