Skip to content

v0.0.3: cut game-version bump cost to one command + a delta checklist#59

Merged
ackness merged 81 commits into
masterfrom
v0.0.3-dev
Jul 18, 2026
Merged

v0.0.3: cut game-version bump cost to one command + a delta checklist#59
ackness merged 81 commits into
masterfrom
v0.0.3-dev

Conversation

@ackness

@ackness ackness commented Jul 17, 2026

Copy link
Copy Markdown
Owner

What changed

The v0.0.1→v0.0.2 release diff was +101k/-25k lines, perceived as "10k+ lines of core code just to bump the game version". Dissection (docs/version-bump-architecture.md) showed ~92k was tool-generated JSON and only ~2-3k was human-written bump work — but that residue was real recurring toil. This branch eliminates the self-inflicted part, shrinks the discovery cost of the intrinsic part, and then uses the new tooling to finish the 0.5.4b engine adaptation itself.

Bump-cost architecture (the five planned items)

  • P0-1 — blessed test-pin snapshots (pobr_gamedata::test_pins): data-content count/float assertions move to data/<ver>/generated/test_pins.json, refreshed by POBR_BLESS_PINS=1 as regen-all's final step. Coverage baseline re-classified as a ratchet (direction assertion, manual raise).
  • P0-2 — vendor calc-delta report (pipeline/diff-vendor-calcs.sh): diffs two PoB2 vendor pins into a markdown triage list. Sample report for 0.21.0→0.22.0 committed (devs/docs/audits/vendor-delta-ce8bffab.md).
  • P1-3 — data/overlay-common/: all five hand-curated overlay domains (special_mods + buff_definitions + high_precision_mods + local_mods + vendor_name_aliases) now live version-independently and are inherited by every data version; per-version hand-porting is gone.
  • P1-4 — pipeline/bump-version.sh: the whole bump drill as one idempotent command; golden flip / vendor pin choice / engine adaptation stay explicit manual decisions.
  • P2-5 — .gitattributes: generated artifacts marked linguist-generated -diff.

0.5.4b engine adaptation finished with the new tooling (gap-map #2#5)

Final parity: all four panels at their ceiling — def 25-col 450/450 = 100%, def core-8 144/144 = 100%, offence 80/80 = 100%, dot 37/37 = 100% (both @5% and @10%), up from the Phase-0 migration baseline of 343/118/39/9. Every cell of all 18 real poe.ninja builds now matches PoB2.

How it was validated

  • Targeted gates after every merge: parity (incl. parity_no_regression ratchets), dualrun, skills, pobr-gamedata, precompile-mods, clippy -D warnings, cargo fmt --check.
  • P0-1 bless idempotence + perturbation test; P1-3 pre/post merged-load equivalence for both data versions; P0-2 real end-to-end run.
  • Every engine fix oracle-verified against vendored 0.5.4b PoB2 before implementation; per-fix before/after ratios in docs/adapting-to-0.5.4b.md.

Known risks / follow-up

  • The real acceptance test for the bump tooling is the next GGG patch.
  • No open parity leads on the 18-build panel. Remaining known ceilings are corpus-gated and annotated inline (ponytail markers): deflected-mitigation term, keystoneMap named grants, crossbow barrage else-branch, Extra-res exemption in pool conversion — each waits for a fixture that exercises it.

ackness added 30 commits July 16, 2026 22:57
… work

Break down the v0.0.1->v0.0.2 +101k diff: ~92k is generated data, ~5k
unrelated feature/cleanup work, and only ~2-3k human-written bump work.
Split that residue into intrinsic cost (engine formula drift, reducible
only in discovery) and self-inflicted cost (count-pins, per-version
overlay copying, half-scripted drill, generated diff noise), then rank
the v0.0.3 fixes: snapshot-blessed test pins, vendor calc-delta report,
overlay-common layer, one-command bump script, gitattributes.
Generated JSON (data/**/generated, extractor overlay output, recaptured
fixture meta.json) stays committed for runtime/deploy, but no longer
renders as reviewable diff noise. Hand-curated overlay special_mods.json
is explicitly exempted.
Data-content count pins (section counts, coverage floats, data-derived
values) break mechanically on every data regen and their repair carries
zero information. Introduce data/<ver>/generated/test_pins.json (flat
map: pin name -> JSON value) plus a small helper:

- test_pins::assert_pin(version_dir, name, actual) compares against the
  blessed snapshot and fails with a bless hint on mismatch;
- POBR_BLESS_PINS=1 writes the actual value back (insta-style, no new
  dependency; write is serialized in-process, bless via cargo test).

Structural guards (schemas, monotonic ratchets, invariants) stay as code
assertions by design.
Move hand-pinned literals that mirror current data content into
data/4.5.4.3/generated/test_pins.json:

- parser_rules real_data_section_counts: all section lengths, distinct
  form count and unsupported lists -> parser_rules.section_counts pin;
  the structural guard (core form ids present) stays in code.
- parser_rules handler_fallback_within_budget: the exact count (3) ->
  parser_rules.handler_fallbacks pin; the <=15 budget guard stays.
- minions zombie/spectre coefficients (life/damage/armour, e.g. the
  0.5.4b spectre life 3.0->2.2 rebalance) -> minions.* pins.

Initial snapshot blessed from the current data; values match the
previous literals exactly.
Remaining assertions that broke (or would break) on data bumps, fixed
without snapshots because their load-bearing meaning is version-stable:

- pobr-core any-attribute: assert the invariant (no net contribution:
  zero mods or leftover, never a concrete attribute mod) instead of the
  version-specific shape (empty mods vs leftover).
- precompile coverage baseline: the test asserted baseline ==
  committed ratio, turning a deliberate ratchet into a pin that broke
  whenever coverage rose. Assert the ratchet direction instead
  (committed >= baseline, same semantics as regen-check.sh); raising
  the baseline stays a human decision, like parity_no_regression.
- precompile --check repo test: derive the checked versions from
  pobr_data::{DATA_VERSION, GOLDEN_PARITY_DATA_VERSION} instead of a
  hand-pinned list (also stops sweeping stale data/ dirs).
- wasm tier degradation test: keep the deliberate 4.5.0.3.4 legacy-pack
  fixture pin but skip gracefully if that pack is ever removed.
Run the migrated pin tests with POBR_BLESS_PINS=1 at the end of
regen-all.sh so a data regen refreshes generated/test_pins.json in the
same pass (soft_step: failures are recorded, not fatal). Uses cargo
test (single process; the helper serializes writes in-process) rather
than nextest, which runs one process per test and would race on the
snapshot file.
bump-version.sh chains the whole drill that previously lived in memory
notes: patch query, .dat download, tree/vendor alignment, regen-all
(which now blesses test pins), CURRENT + DATA_VERSION advance, zh-CN and
web data sync, then a targeted verification pass. Golden flip, vendor
pin choice, and engine adaptation stay explicit manual decisions printed
in the final summary.
… layer

Load special_mods.json in two layers: version-independent curated
data/overlay-common/special_mods.json merged under version-specific
data/<ver>/overlay/special_mods.json. Version entries override common by
id, others append; both absent yields None (unchanged soft-degrade).

Adds overlay_common_path() helper (sibling of the version dir; resolves to
overlay-common/<rel> for the in-memory wasm backend). Covers inherit /
override-by-id / common-only / both-absent behaviors in load_special_mods.

Part of P1-3 (docs/version-bump-architecture.md): curated entries carry
forward to new data versions without manual per-version copying.
special_mods is read in several places outside the gamedata loader; each
must see the version-independent overlay-common layer so the migration is
behavior-preserving:

- precompile-mods check.rs: prepend overlay-common in the compile set;
  precompile.rs test harness mirrors overlay-common into the isolated tmp
  (its GameData-based coverage recompute resolves the sibling layer there).
- sync-pob-catalog extract dedup + special_mods_patterns test: read
  overlay-common so re-extraction dedups against curated entries and pattern
  validation covers them.
- pobr-core parser support + special_mods_gate + repo compile smoke test:
  merge overlay-common under the version layer (same id-override semantics as
  the loader) since pobr-core cannot depend on pobr-gamedata.
- web sync-data.mjs: copy data/overlay-common into the web bundle and list it
  in the manifest so the wasm in-memory backend inherits it.
- regen-all.sh: drop special_mods from the per-version carry-forward list;
  overlay-common replaces that manual step.
- docs/contributing-mods.md: document overlay-common as where curated entries
  go.

Part of P1-3 (docs/version-bump-architecture.md).
133 special_mods entries present and identical in both 4.5.0.3.4 and 4.5.4.3
are version-independent curated fixes; move them to
data/overlay-common/special_mods.json. The 16 entries unique to 4.5.4.3 stay
in its version overlay; 4.5.0.3.4's overlay file (all-shared, now empty) is
removed and its 133 entries are inherited from overlay-common via the loader
merge.

Merged load per version is entry-equivalent to the pre-migration originals
(4.5.4.3: 149, 4.5.0.3.4: 133; ids + content identical, no duplicates).

Part of P1-3 (docs/version-bump-architecture.md).
Add diff-vendor-calcs.sh: compares two PoB2 vendor pins and emits a
markdown triage list, turning post-patch engine adaptation into a
checklist instead of parity-drop archaeology.

Report sections:
- Calc module diff: Modules/Calc*.lua + ModParser.lua diffstat + folded
  hunks (collapse to stat-only past MAX_HUNK_LINES).
- Data module diff: Data/ + Modules/Data*.lua ranked by churn, no hunks.
- Extracted overlay diff: run extract-lua (special-mods/parser-rules/
  uniques) against both pins; entity-level add/remove/change per
  collection via json_entry_diff.py.

Details:
- Each pin is a shallow git checkout in .cache/vendor-delta/<sha>/;
  codeload tarballs strip the working tree the headless extractor's
  HeadlessWrapper bootstrap needs (yields "modLib.parseMod missing").
  Retries transient fetch EOF; writes a per-pin .pob2-version.txt for
  extract-lua --version-file.
- Soft-degrade (regen-all.sh convention): an extract that fails on the
  old pin is noted, the report still generates.
- Ignore /.cache/; document usage in pipeline/README.md.
- Include sample report a82a33b -> ce8bffab (PoB 0.21.0 -> 0.22.0).
Move buff_definitions, high_precision_mods, local_mods and vendor_name_aliases
into version-independent data/overlay-common/ (all four were byte-identical
across the two shipped data versions, so both version copies are removed).

Loaders inherit the common layer:
- buff_definitions: two-layer id-merge like special_mods (common base +
  version-specific override/append).
- high_precision_mods / local_mods: single-object domains, version-first with
  overlay-common fallback via new GameData::load_overlay_or_common.
- vendor_name_aliases has no runtime loader (doc source-of-truth asset only).

Extract paths::merge_by_key generic helper, shared by special_mods and
buff_definitions, replacing the special_mods-local merge copy.

Content-preserving (pure file moves); parity_no_regression unchanged.
- regen-all.sh step 6: drop the per-version carry-forward loop; all four
  curated overlay domains now live in data/overlay-common/ (inherited at load).
- version-bump-drill.sh 3b: resolve check-buff-refs --defs to the version
  overlay if present, else the overlay-common copy.
- extract_parser_rules.rs: update vendor_name_aliases source-of-truth pointer.
- contributing-mods.md: document the four additional common-layer domains.
Phase 0 ignored deflection_matches_golden with a ~0.60x DeflectionRating
gap on monk-martial-artist-twister. Re-triage (fresh pob2-oracle run,
2026-07-17) shows the gap was entirely downstream of the Evasion gap
closed by the Mageblood work (6685c30): rating derives from
Evasion x EvasionGainAsDeflection% and the formula itself
(CalcDefence.lua:48-54 / :1516-1522) is unchanged in 0.5.4b.

Current numbers vs 4.5.4.3 golden: monk rating 22267.8 vs 22312.08
(0.998x), huntress 5664.9 vs 5666.7, warrior 0.84 exact, sorceress 0.
No engine change needed; stale 0.5.0-era comment values and drifted
vendor line refs (0.21.0 -> 0.22.0) refreshed alongside.
The one genuine deflection-side gap left after re-triage: wolf-pack
DeflectChance 48 vs 60 (0.80x) with Evasion already exact. Oracle
defenceModList pins the missing source to Skill:SupportRefractionPlayerTwo
- the Refraction I/II support's Refractive Plating buff grants
EvasionGainAsDeflection BASE 20 (vendor sup_str.lua:5984/6023,
support_tempered_valour_deflection_rating_%_of_evasion_rating).

Implementation, following the player-buff stat-map channel:
- translate_tag gains a MultiplierThreshold arm (ModStore.lua:429-459).
  thresholdVar form is admitted only for vars verified to have zero
  setters vendor-wide (RefractionMinimumValour -> threshold statically 0,
  matching vendor GetMultiplier-on-unset semantics); vars with real
  setters (AttritionCullSeconds) stay Unsupported.
- player-buff ModName allowlist gains EvasionGainAsDeflection (consumer:
  calc_deflection, CalcDefence.lua:1516).
- pob2-oracle defenceModList name set extended with the deflection family.

Parity: wolf-pack DeflectChance 0.80x -> 1.00x, pathfinder 0.93x -> 1.00x;
def 25-col baseline ratcheted 405 -> 407 @5% and 428 -> 429 @10% (honest
re-measurement, old baselines noted in the constant comments). The same
buff's ArmourAppliesTo<El>DamageTaken payloads remain reported (EHP-side
lead: wolf-pack TotalEHP 0.76x -> 0.81x).
# Conflicts:
#	docs/adapting-to-0.5.4b.md
….5.4b #3)

The consumption chain already existed end-to-end (calc::taken::
armour_applies_pct -> build_mitigation_ctx -> per-type DamageReduction /
MaximumHit / EHP, mirroring CalcDefence.lua:2361-2368), and tree-sourced
percentages flow through mod_parser. The only gap was the player-buff
stat-map allowlist: Refractive Plating's second stat key
(support_tempered_valour_%_armour_to_apply_to_elemental_damage,
sup_str.lua:6019-6021, ArmourAppliesTo{Fire,Cold,Lightning}DamageTaken
BASE 30 with the same GlobalEffect + MultiplierThreshold tags as the
already-admitted deflection payload) hit UnknownModName.

Oracle attribution (defenceModList name set extended with the AppliesTo
family) pins wolf-pack at tree 84 + buff 30 = 114% per element,
<El>EffectiveAppliedArmour = 18580 * 1.14 = 21181.2.

Parity: wolf-pack Fire/Cold/LightMaxHit 0.94x -> 0.96x (three new @5%
hits) and TotalEHP 0.81x -> 0.88x; no other build moved. def 25-col
baseline ratcheted 407 -> 410 @5% (429 @10% unchanged; honest
re-measurement, old baselines noted in the constant comments). The EHP
remainder is not this channel: Armour itself 0.98x, ChaosMaxHit 0.87x
(oracle ChaosEffectiveAppliedArmour = 0), Life 1.11x.
…wLife condition (0.5.4b #4a)

0.5.4b added the LifeReservePercentPerSpirit mechanic (vendor
CalcDefence.lua:248-254): Atziri's Communion converts a supported
persistent skill's Spirit reservation into a Life percent reservation
(constant stat skill_reserves_X_life_permyriad_per_spirit_instead_of_spirit
= 66 -> 0.66%/spirit). Heavily-reserved builds then auto-enter Low Life
(CalcDefence.lua:335-350: unreserved/max <= LowPoolThreshold 0.35 ->
condList["LowLife"]), unlocking the 'while on Low Life' mod family
(tree nodes + Direstrike support buff, whose stat-map entry already
carried Condition:LowLife but the condition was never set).

Three consumption-point fixes, all generic:
- xml_build: gems serialized with only nameSpec (lineage supports like
  Atziri's Communion carry no skillId/gemId) are no longer dropped;
  GemSkillRef gains name_spec and stage_build_view resolves it against
  granted_effects by normalized name (PoB2 SkillsTab nameSpec lookup).
- spirit_reservation_modifiers: when a group support carries the
  permyriad stat, the skill's spirit flat (incl. ExtraSpirit) converts
  to a LifeReservedPercent INC mod (Life-pool inc/more/efficiency
  names, vendor round-2), spirit reservation zeroed. Eternal Rage:
  155 x 0.66 x 0.9 = 92.07% -> LifeReserved 270 (golden 270).
- CalculationSession::bridge_low_pool_conditions (orchestrator stage
  6e): mirrors the vendor low-pool loop for Life; explicit config
  conditionLowLife stays authoritative.

Oracle evidence (huntress-ritualist-bow-shot, 0.5.4b): damageModList
shows +60 (Tree:56453) and +70 (Skill:SupportDirestrikePlayerTwo)
attack Damage INC gated on Condition:LowLife = exactly the missing
130 INC (PoBR pool scale 3.48 vs oracle 4.78 across all damage types);
mainOutput LifeUnreserved 23 / SpiritUnreserved 10 vs PoBR's old
293 / -130.

Parity: ritualist TotalDPS 0.68x->0.99x, TotalDotDPS 0.60x->1.00x,
SpiritUnres/LifeUnres exact; abyssal-lich (also wears Communion)
TotalDPS 0.62x->0.83x. Aggregates below re-ratcheted in #4b commit.
…ster-socket jewels (0.5.4b #4b)

0.5.4b introduced the Voices unique jewel ('Allocates 2 Sinister Jewel
sockets'; vendor ModParser.lua 0.22.0 delta: GrantedPassive
SinisterJewelSockets, consumed by PassiveSpec.lua:1067-1090 which
allocates the first N sinister sockets in voices_jewel_slot1..5 alias
order). PoBR's tree-jewel gate only admitted jewels whose socket node
is in the allocated set, so jewels sitting in the never-tree-allocated
sinister sockets were silently dropped.

parse_items_and_slots now scans allocated-socket jewels for the
allocates-N line and treats the first N sinister socket node ids
(pinned from vendor TreeData/0_5/tree.lua sinister=true +
aliasPassiveSocket: 62152, 26178, 23960, 39087, 3367) as allocated for
the jewel filter. Older tree versions have no source for this mod, so
behavior there is unchanged.

Oracle evidence: ritualist critModList includes Item:18 Phoenix
Splinter (+18 INC CritMultiplier) and Item:23 Oblivion Sliver (+13 INC
CritChance, +19 INC CritMultiplier) socketed at 62152/26178 - exactly
PoBR's missing 13 INC chance / 37 INC multiplier (27.839->29.1 chance,
4.37->4.74 mult, both now exact). Titan and abyssal-lich also wear
Voices and pick up their sinister jewels.
…anary

Communion/LowLife + Voices sinister sockets land honest gains, recorded
per ratchet discipline (old -> new, all measured):
- offensive @5% 46 -> 52, @10% 55 -> 59
- dot @5% 11 -> 13, @10% 13 -> 18
- defensive 25-col @5% 410 -> 413, @10% 429 -> 434
- defensive core-8 @5% 138 -> 140
- panel offence @5% 27 -> 38, @10% 30 -> 39

canary_fire_spell_armour (druid-oracle-ember-fusillade) re-verified at
TotalDPS 1.00x (548742 vs golden 549423) - the gap-map ~0.60x entry had
already been closed by earlier fixes without the canary being revisited;
un-ignored.
ORACLE_EXTRA_STATS="CooldownRecovery,Speed" dumps the per-source mod
list for arbitrary vendor stat names (same shape as critModList) under
extraModList. Zero cost when unset. Used to pin the deadeye grenade
Speed gap to three tree nodes (21077/354/48429, 15% CDR each, SkillType
159-gated) without hand-bisecting the build XML.
… (0.5.4b #4c)

vendor 0.22.0 added 'not grantedEffect.fromItem' to the ModParser gem-
name registration loop (ModParser.lua:6423, in the 0.21->0.22 delta):
MeleeGrenadeLauncherPlayer (name "Grenade", fromItem) no longer
registers in skillNameList, so the modFlagList phrases 'grenade' and
'for grenade skills' are live SkillType.Grenade tags again. PoBR's
extractor still carried the 0.21-era runtime facts from PR#53
(VENDOR_DEAD_FLAG_PHRASES dropped 'for grenade skills';
VENDOR_SKILLNAME_PREEMPTED rewrote 'grenade' to an inert SkillName tag),
so grenade-scoped mods silently under-applied.

Evidence:
- run-parsemod (0.5.4b vendor): '15% increased Cooldown Recovery Rate
  for Grenade Skills' -> CooldownRecovery INC 15 + SkillType 159, empty
  leftover; '12% increased Grenade Damage' likewise.
- oracle extraModList: deadeye is missing exactly Tree:21077/354/48429
  (3 x 15 CDR, SkillType-gated) -> INC -18 vs vendor +27, cooldown
  6.098s vs 3.937s (Speed 0.164 vs golden 0.254).

Changes: empty both extractor override tables (kept with a dated note
and the re-audit method), regen mod_parser_rules.json (exactly the two
grenade entries change) + parsed_mods.json + test_pins snapshot.

Parity: deadeye Speed 0.65x->1.00x, TotalDPS 0.53x->0.83x (remaining
gap is the pre-existing per-hit shortfall documented in pob2_parity.rs);
gemling Speed/AvgDamage/TotalDPS/CombinedDPS all 1.00-1.04x. Ratchets:
off @5% 52->56, @10% 59->62; dot 13->14 / 18->19; panel 38->40 / 39->41.
The stale embedded deadeye sample (pre-0.5.4b export) no longer gets a
TotalDPS assertion - its Speed calibration is invalidated by the CDR
rebalance; AverageDamage tightened 0.55->0.20 (measured 1.04x). The
authoritative DPS gate is the ninja_parity 0.5.4b fixture golden.
Adds the #4 section (three sub-fixes: Communion/LowLife chain, Voices
sinister sockets, grenade phrase un-preemption), the honest aggregate
deltas, and the decomposed remaining offence clusters (attack
AverageDamage family, titan bifurcated-crit, frost-bomb pre-existing
cooldown gap, ritualist low-life EHP calibre note). Includes cargo fmt
fixups on the #4a code.
…0.5.4b #5)

Vendor 0.22.0 added a GlobalEffect/Buff tag to Blazing Critical's
support_blazing_crits_gain_%_fire_damage_with_attacks_on_critical_hit
(sup_int.lua:959), turning its 15% DamageGainAsFire (Attack +
Condition:CritRecently) from a dead skill-local mod into a global
player buff. Two generic consumption points:

- stat_map_engine: admit DamageGainAsFire in the player-buff mod-name
  allowlist (consumer = the existing gain-as matrix in calc::damage).
- support_buff_specs: judge support compatibility against additional
  granted effects too (mirrors buff_skill_specs) -- the compatible host
  for Blazing Critical under Charged Staff is the hidden Attack
  additional effect ChargedStaffShockwavePlayer (act_int.lua:3387).

Ignite scales quadratically with the fire portion (chance ~ fire/
threshold, magnitude ~ fire): flicker TotalDotDPS 0.05x->0.72x,
spirit-walker 0.23x->0.87x, monk-twister 0.44x->0.98x (its
AverageDamage/TotalDPS also 0.60x->0.96x).
off 56->58 @5% (62->64 @10%), dot 14->16 @5% (19->21 @10%);
def/panel unchanged (CritRecently is combat-gated). Per-cell triage of
the remaining dot misses recorded at the constants: all leftovers are
either squared pass-through of the out-of-scope attack AvgDamage hit
gaps or pre-0.5.4b items (smith Infernal Cry uptime gain, frost-bomb,
essence-drain WithDotDPS).
ackness added 27 commits July 17, 2026 19:30
…urce

vendor Item.lua:1923-1928 folds every bare 'Adds N to M <type> Damage' line
on a weapon into weaponData (local: it only fires when attacking with that
weapon). PoBR only treated the physical variant as local; elemental/chaos
lines leaked into the global added-damage bucket and got multiplied by the
skill's added-damage effectiveness even for skills that never use the
weapon. warrior-titan-shield-wall: Nebuloch's 'Adds 30 to 52 Chaos damage'
became a phantom 104-180 chaos base on Shield Wall (oracle ChaosMinBase 0)
=> TotalDPS 1.05x; warrior-smith-of-kitava: Fortified Hammer's 'Adds 3 to
55 Lightning' likewise => AverageDamage 1.02x. The overestimate was
previously masked by the missing warcry gain.

inject_items now drops bare typed adds (all five types) from a weapon slot
whose contribution is not consumed by the main skill (non-weapon attacks
like Shield Wall, spells, inactive off-hand). Active weapon sources keep
the existing global-injection approximation (numerically equal to the
vendor weaponData fold; deadeye/twister pinned at 1.00x, druid spell
unaffected -- its spear's bare adds are attack-gated and never reached the
spell).

titan TotalDPS 1.05x -> 1.01x, smith AverageDamage 1.02x -> 1.00x.
Remaining titan ~0.9% decomposes into the known shield-armour recompute
divergence (rolled 'Armour: 1736' vs vendor recompute 1725 feeding Shield
Wall's per-15-shield-armour phys base; same root as the Armour 0.99x panel
row) plus crit-chance rounding (11.28 vs 11.2476) -- defence-side channel,
not an offence bug.
- Baselines re-pinned to the post-#10 dashboard: offensive 76->78/80 @5%
  and @10% (deadeye AverageDamage+TotalDPS enter the band), dot 31->34 @5%
  / 34->36 @10% (deadeye ignite chain follows the Point Blank fix;
  smith/titan dot back in band after the local-adds leak removal).
- POBR_ONLY_BUILD=<substring> filter for the ninja dashboard so POBR_DBG_*
  channels stay readable on a single build (never set in CI).
- POBR_DBG_BASES now also dumps generic-bucket MORE contributions
  (POBR_POOL_MORE), symmetric with the existing INC dump.
# Conflicts:
#	crates/pobr-build/tests/parity/ninja_parity.rs
…evel

Vendor uses monsterAllyLifeTable for non-hostile minions
(CalcPerform.lua:1046, floor semantics), not the enemy monsterLifeTable
PoBR was reading. Promote the ally table to a Rust const
(MONSTER_ALLY_LIFE_TABLE, deduping the literal previously inlined in the
catalog Default impl) and switch derive_minion_base_stats to it.

Oracle pin (wolf-pack): ally[44]=2938 x minionData.life 1.1 = 3231 base
life, matching the vendor Life BASE mod exactly.
…-list

Two narrow statmap channels for the companion defence layer (#12):

- map_minion_life_stat: translate 'MinionModifier LIST { mod = Life ... }'
  statmap payloads (e.g. Loyalty's
  support_trusty_companion_minion_life_+%_final -> Life MORE -30) into
  minion-side inner modifiers, normalising vendor 'Life' to the engine
  life-pool name 'MaximumLife'. First batch admits inner Life only
  (BASE/INC/MORE); anything else is skipped, never mis-applied.
- admit TakenFromCompanionBeforeYou through the player-buff allow-list
  (Loyalty / Wild Protector carry
  companion_takes_%_damage_before_you_from_support with
  GlobalEffect/Buff/unscalable, SkillStatMap.lua:2559-2561).
Implements the vendor companion allies layer
(CalcDefence.lua:2961-2965 mitigation, :493-495/:525-531 EHP drain,
:3656-3663 max-hit poolProtected fold; CalcPerform.lua:3323-3370 life
pool):

- Actor::is_companion marks damageable-companion minions at spawn
  (granting skill has SkillType.Companion and not MinionsAreUndamagable);
  add_minion_from_def/session wrapper take the flag.
- spawn_minions now feeds summons the effective gem level (+N to Level
  of ... bonuses + support-granted levels, vendor
  minionLevelTable[activeEffect.level]) and appends group-scoped support
  minion payloads via map_minion_life_stat (Loyalty -30% more life).
- perform runs perform_minions before fill_mechanics (vendor computes
  minion life pools before player defence) and inject_companion_life
  writes TotalCompanionLife BASE into the player ModDb (Override-aware,
  vendor CalcPerform.lua:3364-3370).
- build_pool_state emits the 'companion' AllyLayer consumed by the
  existing shared EHP/max-hit allies machinery. The deflected-hits term
  (TakenFromCompanionBeforeYouFromDeflected x DeflectChance) is not
  modelled: no source in the 18-build corpus and the wolf-pack oracle
  pins mitigation at exactly 10 (pure hits term).

Design note: no companion actor abstraction - the existing minion actor
plus the generic AllyLayer machinery express the whole mechanic; the
only new state is one bool on Actor and one BASE mod on the player db.

Oracle pins (mercenary-tactician-wolf-pack): TotalCompanionLife 2262 =
floor(ally[44] 2938 x 1.1) x 0.7; CompanionAllyDamageMitigation 10;
<Type>TotalHitPool 3826.67 (PoBR now 3817.0 = 0.9975, remainder is the
pre-existing Mana 761.2-vs-770 gap).

Parity: huntress-spirit-walker-twister closes exactly (5x MaxHit +
TotalEHP 0.89-0.90x -> 1.00x); wolf-pack MaxHit 0.82->0.90x, ChaosMaxHit
0.72->0.80x, TotalEHP 0.74->0.83x (residual is a uniform per-type
taken-multiplier gap, not the companion layer). Baselines ratcheted:
def 425->431 @5%, 434->439 @10%; only these two builds moved in the
18-build per-cell diff.
…or parity)

Vendor's <X>TakenHit chain (CalcDefence.lua:2402) uses calcs.armourReduction
— the *integer-rounding* variant (Common.lua round = floor(x+0.5)) — while
only takenHitFromDamage (:437) uses the fractional armourReductionF. PoBR's
taken_hit_per_type used the fractional value everywhere, so the exported
PhysicalDamageReduction never matched PoB2's always-integer golden at small
magnitudes.

druid-oracle-ember-fusillade PhysDR 4.23 -> 4.00 (1.06x -> 1.00x) and
ranger-deadeye-explosive-grenade PhysDR 2.39 -> 2.00 (1.20x -> 1.00x).
Def 25-col 431 -> 433 @5%, 439 -> 440 @10%.
Vendor doActorLifeManaSpirit (CalcDefence.lua:92-95) deducts
min(Sum(BASE, res..ConvertTo{EnergyShield,Armour,Evasion}), 100) percent
from the Life/Mana pool base. PoBR's defence matrix already credited the
receiving side (ES gains ceil(base*rate/100), :1364) but the pool-side
deduction was explicitly deferred and never landed, so builds with
'N% of Maximum Life Converted to Energy Shield' kept full Life.

witch-abyssal-lich-detonate-dead allocates 0_5-tree 'Enhanced Barrier'
(node 44299, 5% Life -> ES; oracle extraModList pins
LifeConvertToEnergyShield BASE 5 from Tree:44299): Life 1782.98 -> 1693.83
vs golden 1694 (1.05x -> 1.00x), LifeUnreserved follows. Def 25-col
433 -> 435 @5%, core-8 142 -> 143.
…le round

0.5.4b vendor (CalcDefence.lua:229-239) adds
blasphemy_base_spirit_reservation_per_socketed_curse x instances into
pool.Spirit.baseFlat *before* the one efficiency-scaled round; PoBR still
used the old per-instance round-then-multiply (round(60/1.1)=55 x3 = 165
vs vendor round(180/1.1) = 164, oracle spiritReservedBreakdown pins 164).

sorceress-chronomancer-essence-drain SpiritUnreserved 6 -> 7 (exact).
Def 25-col 435 -> 436 @5%, 441 @10%. Skills-suite pin updated 110 -> 109.
Vendor gems carry a second quality-stat table (altQualityStats) that only
applies when the build has 'Gem Quality grants Socketed Skills an
additional effect' (Gemling ascendancy notable -> GemlingQuality flag,
ModParser.lua:3353 / CalcSetup.lua:835 / CalcTools.lua:147-152). The
extract-lua gem-quality channel never transcribed those rows, so gemling
lost e.g. Mirage Archer's alt base_reservation_efficiency_+% x2 and
Eternal Rage's alt base_spirit_reservation_efficiency_+% x0.75.

- extractor: emit altQualityStats rows with alt:true; QualityStat gains
  a serde-default alt flag (old data unchanged); regenerated
  data/4.5.4.3/overlay/gem_quality_stats.json.
- BuildData::effect_stats/unselected_set_stats exclude alt rows
  (byte-identical behavior for every existing caller); new
  alt_quality_stats accessor returns trunc(rate x quality) alt values.
- spirit reservation consumer: detect the flag from allocated/anointed
  nodes (gemling_quality_flag) and fold alt efficiency stats in; also
  admit the spirit-specific base_spirit_reservation_efficiency_+% id.

mercenary-gemling-legionnaire SpiritUnreserved -60 -> -18 (exact; oracle
pins Mirage Archer 60->37 eff 62%, Eternal Rage 100->81 eff 23% at
effective quality 31). Def 25-col 436 -> 437 @5%, 441 -> 442 @10%.
…tion

What changed
- setup_enemy injects the vendor actor-init conditional pair on the enemy
  modDB: Damage INC -10 / DamageTaken INC 10 gated on EnemyIntimidated
  (CalcSetup.lua:73-77).
- env_finalize stage 7.5 bridges an enemy-db Condition:Intimidated FLAG
  (e.g. item mod "Enemies in your Presence are Intimidated") into cfg
  EnemyIntimidated (vendor ModStore GetCondition semantics; allow-list =
  Intimidated only).
- Orchestrator seeds EnemyInPresence true (CalcPerform.lua:524 default;
  PresenceRadius vs enemyDistance not modelled).

Why
- wolf-pack's uniform ~10% per-type taken-multiplier gap: oracle pins
  <X>EnemyDamageMult = 0.9 from the Intimidated base mod lit by the body
  armour's corrupted implicit; PoBR had the flag in the enemy db but no
  consumer. The max-hit end divisor and EHP damage-in multiplier were
  already wired - only the mod was missing.

Validation
- wolf-pack Phys/Fire/Cold/Light MaxHit 0.90x -> 1.00x, PhysDR 65.69 ->
  68.03 (oracle 68), TotalEHP 0.83x -> 0.98x. No other build's cells move
  (per-cell 18-build diff).
What changed
- pobr_data::Item gains a corrupted flag, parsed from the item text
  'Corrupted' marker line in both item_text parse paths (struct literal
  sites updated with corrupted: false).
- stage_inject_jewels: when a unique jewel carries 'N% increased Effect of
  Jewel Socket Passive Skills containing Corrupted Magic Jewels' (The
  Adorned; the text wraps across two physical lines in build XML), every
  corrupted MAGIC tree-socketed jewel's mods are parsed and value-scaled by
  1 + N/100 before injection, using vendor ScaleAddList rounding
  trunc(round(v * scale, 2)) (ModStore.lua:70-79; CalcSetup.lua:944-948 +
  :1342-1347).

Why
- wolf-pack Mana 761.2 vs 770: six corrupted magic 'Rallying Ruby of
  Valour' jewels (+Int/+Dex/+chaos res enchants) must scale x1.97
  (oracle: +5 Int evaluates to 9, +4 Dex to 7). Int 135 -> 139 closes
  Mana exactly; uncapped chaos resist 72 -> 81 closes the ChaosMaxHit tail.

Not modelled (no corpus source): sinister/containJewelSocket socket
exemptions, unscalable payloads.

Validation
- wolf-pack Mana/ManaUnres 770.00 exact, ChaosMaxHit 17008 vs 17007,
  TotalEHP 129929 vs 130326 (1.00x).
What changed
- item_rolled_defence: when the base item is known, all three defence
  components (armour/evasion/energy shield) are recomputed as
  round((base + localFlat) * (1 + localInc/100) * (1 + quality/100))
  (Item.lua:1994-1996), instead of ES only. Display-line fallback remains
  for unknown bases.

Why
- titan Armour 0.985x: three 0.5.4b-buffed Runeforged bases whose item-text
  'Armour:' lines lag the base DB (gloves 96 vs 101, helmet 192 vs 284,
  boots 58 vs 100; oracle ArmourOn<Slot> pins each; helmet recompute
  169*1.40*1.20 = 284 exact). Same family as the earlier ES-only fix
  (c705fe4). Note the #10 'shield 1736 vs 1725' hypothesis was wrong -
  oracle armourData.Armour is exactly 1736.

Validation
- titan Armour 39545.68 vs 39546 (0.99x -> 1.00x), ES/ESRecoveryCap 55
  exact, Phys/Fire/Cold/Light/Chaos MaxHit exact. Per-item round() also
  lands pathfinder Evasion 0.98x -> 1.00x and twister DeflectChance
  0.97x -> 1.00x exact. No cell regresses fleet-wide.
- enemyMitigation.dealtMods: per-mod source dump of enemy outgoing-Damage
  scalers (INC/MORE over Damage/<Type>Damage/ElementalDamage) - the feeds
  of <X>EnemyDamageMult (Enfeeble / Intimidated-style).
- ORACLE_DUMP_ITEMS="2,17": dumps listed build items' parsed modLines,
  evaluated modList, quality, armourData and base armour - provenance
  diffs for jewel-effect scaling and item defence recompute. Zero cost
  when unset.
- def 25-col: 431 -> 437 @5%, 439 -> 445 @10% (six wolf-pack cells:
  5x MaxHit + TotalEHP). off/dot/core-8 unchanged.
- docs/adapting-to-0.5.4b.md: #13 section - three root causes (enemy
  Intimidated base pair, The Adorned corrupted-magic-jewel scaling,
  Runeforged base-armour display-line lag) with oracle evidence and
  vendor line refs; oracle tooling notes.
The #12 companion work switched summon life to monsterAllyLifeTable with
vendor floor semantics (CalcPerform.lua:1046); two minion tests still
pinned the enemy monsterLifeTable expectation and slipped through the
merge gate (the pobr-core run output was truncated by the tee log).
…quirement snapshot

Two provisioning gaps left Masterwork's armour mods evaluating to zero on
warrior-smith-of-kitava-shield-wall (both mods parsed fine, their inputs
were never provided):

1. Multiplier:AllocatedConnectedNotable — vendor counts allocated notables
   flagged applyToArmour=true in tree.lua (PassiveSpec.lua:1753,
   CalcSetup.lua:840-841). The flag never existed in PoBR tree data: it is
   now transcribed by the --tree-coords backfill (PassiveNodeDef.apply_to_armour,
   serde-default) and counted in inject_per_x_multipliers. Smith allocates
   8 of the 12 flagged notables -> +200x8 = 1600 flat Armour.
2. StrRequirementsOn<slot> — vendor snapshots per-slot item attribute
   requirements (CalcPerform.lua:1848-1857) for 'Gain Armour equal to 150%
   of total Strength Requirements of Equipped Boots, Gloves and Helmet'
   (PercentStat). Base-item requirements were absent from PoBR data (.dat
   bundle unavailable) — extended the extract-bases vendor channel with
   req = { str/dex/int } (BaseItemDef.req_str/dex/int via overlay merge)
   and provision <Attr>RequirementsOn<slot> cfg.stats per equipped item.
   reqMult (GlobalAttributeRequirements) held at 1 until a fixture needs it.

smith Armour 37386 -> 49096.16 vs golden 49096 (0.76x -> 1.00x exact);
PhysMaxHit/Fire/Cold/LightMaxHit all land exact (0.91-0.93x -> 1.00x),
ChaosMaxHit 16124 vs 16123. Def 25-col 437 -> 442 @5% / 442 -> 444 @10%,
core-8 143 -> 144/144 (100%).
Vendor's EHP mitigation layer uses EffectiveAverageBlockChance = mean of
(attack, projectile, spell, spell-projectile) block (CalcDefence.lua:1067),
where SpellProjectileBlockChance = max(spellBlock+proj part, ProjectileBlock)
(:1013). PoBR averaged only (attack + spell)/2, so shield builds with zero
spell block were undercounted: smith 13.65% vs vendor 20.475%.

calc_block already computed all four raw categories — exposed the two
effective projectile variants through BlockResult/OutputTable and switched
the EHP average to the 4-way mean.

warrior-smith-of-kitava TotalEHP 91338 -> 99177 vs golden 99167.69 (0.92x
-> 1.00x) and warrior-titan TotalEHP 54483 -> 58898 vs 58904.30 (0.92x ->
1.00x). Def 25-col 442 -> 444 @5% / 444 @10%.
Def 25-col 431 -> 444 @5% (439 -> 444 @10%), core-8 142 -> 144/144 (100%).
Six clusters closed; the 6 remaining @5% misses are all
mercenary-tactician-wolf-pack (task #13's lane). Reason chain in the
baseline comments.
# Conflicts:
#	crates/pobr-build/tests/parity/ninja_parity.rs
#	docs/adapting-to-0.5.4b.md
…nodes

Reverts the 75a348e mechanism (inactive weapon-set nodes merged back into
the radius-jewel grant geometry). That change was based on a misreading:
in vendor, EVERY mod on an allocMode != 0 node - the node's own stats and
radius-jewel grants alike - gets 'Condition: WeaponSet<N>' appended
(CalcSetup.lua:222-223; the jewel-source branch :224-227 only fires for
allocMode-0 nodes), so grants on inactive-set notables are dead.

Oracle critModList Tabulate on gemling pins exactly one '7 @ Tree:32763'
(INC total 71 -> CritChance 5 x 1.71 = 8.55, matching the 0.5.4b golden).
PoBR previously granted 6x +7 -> 10.30 (1.20x overshoot).

gemling: CritChance 1.20x -> 1.00x exact; the same jewel's Small-node
grants shrink too -> AvgDamage/TotalDPS 1.04x -> 0.97x (in band);
TotalDotDPS 1.10x -> 0.95x (in band; residual is purely downstream of the
pre-existing AvgDamage under-estimate via ignite chance x stack potential,
0.9754^2 = 0.951 exact). Other 17 builds unaffected.
…kills only eat SkillSpeed

wolf-pack Speed read 1.43 vs vendor 1.00. Two stacked causes:

1. Main-skill selection: Wolf Pack (Minion+Companion, neither Attack nor
   Spell) failed the is-damage-skill filter, so mainSocketGroup=4 yielded
   nothing and the fallback scan hijacked the main skill to the Blasphemy
   group's Temporal Chains (0.7s cast -> Speed 1.43). Vendor's
   socketGroupSkillList has no damage filter. Fix: when
   pick_group_main_skill misses on the DESIGNATED group, fall back to the
   mainActiveSkill-chosen active gem (pick_group_chosen_active; Meta shells
   still excluded, cross-group scan still damage-only).

2. Speed bucket: with Wolf Pack as main (castTime 1s), the 'untyped skill
   eats all three speed buckets' fallback consumed the weapon's '12%
   reduced Attack Speed' -> 0.88. Vendor mod-matching requires cfg to carry
   ModFlag.Attack/Cast for those mods; untyped skills eat neither.
   speed_names_for now returns only SkillSpeed for non-attack/non-spell.

Oracle mainOutput pins Speed=1, Time=1, CastRate=1 (statSet 'Minion Info',
player TotalDPS 0). wolf-pack Speed now 1.00x; all other builds' main
skills are attack/spell typed - no behavior change.

Also adds a POBR_DBG_SPEED probe (main skill id + speed factor dump).
…ecord #15

Offensive 78->80 (gemling CritChance, wolf-pack Speed), dot 36->37
(gemling TotalDotDPS back in band), panel offensive ratchet 42/43 ->
45/47. def stays 450/450, core-8 144/144. All 18-build cells now hit at
5% - zero per-cell regressions by construction.

Documents #15 root causes in docs/adapting-to-0.5.4b.md and corrects the
stale 75a348e comment (its 6x+7 claim matched the pre-0.5.4b golden;
current oracle/golden pin 1x+7 = 8.55).
… with #7

memory_backend_matches_dir_backend only staged the version directory, so
the P1-3 overlay-common layer was silently absent from the in-memory
backend (production wasm bundles it via web sync-data). Stage it under
the overlay-common/ key prefix the loader resolves.

Two unsupported_specials tests still pinned the pre-#7 also_grants_guard
phantom modeling; guard lines now assert loud unsupported reporting and
no hit-pool extension, matching PoB2. The wasm suite was in no wave's
merge gate, which is how both slipped through.
@ackness
ackness marked this pull request as ready for review July 18, 2026 15:05
@ackness
ackness merged commit ff1d07d into master Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant