Skip to content

Item Generation (Modifier Rolling)

Summary: When an equipment item is created, UItemGenerationLibrary populates its FEquipmentFragment with rolled modifiers. Generation runs in a fixed order — implicit(s) → prefixes → suffixes — and (for weapons) appends weapon scaling. Explicit affixes (prefix/suffix) are chosen by weighted random from the base type's modifier pool, gated by a per-base-type spawn weight; implicits are NOT weighted — they are listed explicitly on the base type (ImplicitModifierIDs) and fetched by ID. Each rolled explicit modifier gets a tier (random among tiers unlocked at the item level) and a value rolled inside that tier's range; implicits are PINNED to the highest tier unlocked at the item level (FSH-364 ruling, 2026-07-23) — deterministic base signatures, only the value rolls. The entire pipeline can be driven by an FRandomStream, so the editor Roll Simulator and PIE produce identical items from the same seed.

Table of Contents


Design Status (settled 2026-07-23)

The Base Attribute layer is REMOVED (TestPass-D2 ruling). FItemBaseTypeDefinition no longer has a BaseAttributes map; the equip-time multi-attribute GE, tooltip line, stat-source rows, and the Item Manager editor section are all gone. Armor bases carry their armor through the armor_implicit pool definition instead (their ImplicitModifierIDs list exactly that one entry, so the single-pick implicit channel always yields it). Weapon "+1" base attributes (Ferocity/Clarity/Dread) were vestigial duplicates of existing implicits and were deleted without replacement.

Implicit tiers are PINNED (TestPass-D1 ruling): an implicit always takes the highest tier unlocked at the item level (CalculateHighestTierFromLevel), consuming no randomness; explicit affixes keep the random-among-unlocked roll.


Architecture Overview

┌──────────────────────────────────────────────────────────────────────┐
│  UItemGenerationLibrary::GenerateItemProperties(Manifest, Pool, Stream)│
│                                                                        │
│   Equipment fragment present?  ── no ──▶ return (nothing to roll)      │
│        │ yes                                                           │
│        ▼                                                               │
│   Resolve base type by BaseTypeID  ──▶  FItemBaseTypeDefinition        │
│        │                                                               │
│        ├─ bSupportsModifiers && PoolManager  ──▶  GenerateModifiers    │
│        │                                                               │
│        └─ ItemClass == Weapon  ──▶  ApplyWeaponScaling (own sub-seed)  │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│  GenerateModifiers  →  GenerateModifiersOfType (× 3, fixed order)      │
│                                                                        │
│    IMPLICIT  ┐   PREFIX  ┐   SUFFIX  ┐   (shared ExistingFamilies set  │
│              │           │           │    threads across all three to  │
│              ▼           ▼           ▼    block family duplicates)      │
│   ┌─────────────────┐   ┌──────────────────────────────────────────┐  │
│   │ Fetch by ID:    │   │ Weighted random from pool:               │  │
│   │ ImplicitModi-   │   │   GetFilteredModifiers (type/level/tags/ │  │
│   │ fierIDs + level │   │   family) → SelectWeightedRandomSeeded   │  │
│   │ window check.   │   │   (spawn-weight gate)                    │  │
│   │ NO weight gate. │   │                                          │  │
│   └────────┬────────┘   └────────────────────┬─────────────────────┘  │
│            └─────────────────┬────────────────┘                        │
│                              ▼                                          │
│   Per pick:  ModifierTier = CalculateTierFromLevelSeeded(level, stream) │
│              RolledValue  = GetRandomValueFromTier(tier, stream)        │
│                              ▼                                          │
│   Append to Implicits / Prefixes / Suffixes on FEquipmentModifiers     │
└──────────────────────────────────────────────────────────────────────┘

Core Concepts

Why Generation Exists

Generation turns a base type (the "what" — a Bastard Sword, a Plate Helm) into a rolled item (the "how good / what flavor"). It is the on-ramp to the Loot System and the foundation the Crafting System mutates. Per the project's no-rarity stance, the modifiers themselves carry an item's identity, so getting their selection and tiering right — and reproducible — is the system's whole job.

The Three Modifier Channels

Channel Source Selection Removable by crafting
Implicit Base type's ImplicitModifierIDs (explicit list) Fetched by ID, level-window check only — no spawn weight No (granted by base)
Prefix / Suffix Base type's modifier pool(s) Weighted random, spawn-weight gated Yes (craftable affixes)
Scaling WeaponScalingLibrary (weapons only) Weighted by scaling rank System-managed

Echo and Unique modifiers do not flow through this pipeline. Echo mods are rolled by the Remnant factory onto FRemnantItemState; unique mods are item-specific. GenerateModifiersOfType ensure-rejects any type other than Implicit/Prefix/Suffix. See Remnant System.


The Generation Pipeline

GenerateItemProperties is the single entry point used by every item-creating system (ItemSpawner, vendors, the editor sim subsystem). It:

  1. Bails immediately for non-equipment items (no FEquipmentFragment).
  2. Resolves the base type from BaseTypeID; logs an error and bails if missing.
  3. Stamps EquipmentFragment->BaseTypeID.
  4. If the base type bSupportsModifiers and a pool manager is supplied, runs GenerateModifiers.
  5. For weapons (ItemClass matches Weapon), runs ApplyWeaponScaling.
  6. For weapons, rolls the per-drop damage quality onto the FWeaponFragment (see Weapon Damage Curve & Quality Roll). Drawn last so adding this axis never shifted the modifier/scaling draws of a pre-existing seed.

GenerateModifiers then calls GenerateModifiersOfType three times in a fixed order — Implicit, Prefix, Suffix — passing a single ExistingFamilies array by reference through all three. Each successful roll adds its ModifierFamily to that set, so a later channel can never duplicate a family already taken by an earlier one (family-conflict avoidance across the whole item).

Modifier Count

The number rolled per channel comes from UModifierLibrary::DetermineModifierCountSeeded:

Channel Count rule
Implicit Max 1 (GetMaxModifierCount returns 1)
Prefix / Suffix RandRange(1, MaxCount) where MaxCount is 1 (iLvl 1–34), 2 (35–69), 3 (70+)

If the count resolves to 0 or the filtered candidate set is empty, that channel produces nothing.

Weapon Scaling Sub-Seed

WeaponScalingLibrary is seed-based (takes an int32), not stream-based. To keep the whole item deterministic under one stream, GenerateItemProperties derives a scaling sub-seed from the stream (RandomStream->RandRange(1, MAX_int32-1)); a null stream passes 0, which keeps the unseeded global path.

Weapon Damage Item-Level Curve & Quality Roll

Weapon damage has two axes beyond the authored manifest value (added 2026-07-24, feature/itemization-fsh-369-364):

Item-level curve. A manifest's BaseDamage is authored as the on-curve value at the config's anchor item level (currently 5 — where the slice's weapons were tuned). At damage-calculation time, UWeaponPropertiesLibrary::CalculateWeaponDamage multiplies by

M(iLvl) = (1 + growth * iLvl^exponent) / (1 + growth * anchorLevel^exponent)

with growth/exponent mirroring the enemy MaxHealth curve in EnemyScaling.json, so player damage tracks enemy HP by construction (flat TTK across levels). At current values, iLvl 1 lands ~0.5x and iLvl 40 ~5.9x. The curve lives in Config/Balance/ItemScaling.json, loaded by FItemScalingConfig — a static loader, deliberately not a subsystem, because the same curve must serve runtime generation, the editor Roll Simulator, and the BuildSweep commandlet (contexts without a shared GameInstance). Missing/unreadable config degrades to identity (multiplier 1, variance 0) with a one-time warning. Eternal.Balance.ReloadItemScaling re-reads it live.

Quality roll. At generation, each weapon draws a DamageQualityRoll in [1-v, 1+v] (qualityVariance, currently 0.15) stored on the FWeaponFragment — the "same base, higher roll" axis: two drops of the same base at the same item level differ by up to ±15% base damage. Seeded like everything else (same seed, same roll). Items generated before the axis existed keep the field default 1.0 and are unaffected.

Both multipliers are applied inside CalculateWeaponDamage — the one builder every consumer (tooltip, ExecCalc, Build Lab, sweep) reads — so displayed and dealt damage can never disagree.


Affix Selection & Spawn-Weight Gating

Explicit affixes (prefix/suffix) are chosen by weighted random without replacement from the base type's pool. The candidate set is built by UModifierPoolManager::GetFilteredModifiers, a four-stage chain:

All pool definitions
   │  FilterByType        keep only this channel (Prefix or Suffix)
   ▼  FilterByItemLevel   keep MinimumItemLevel ≤ iLvl ≤ MaximumItemLevel (0 = no cap)
   ▼  FilterByTags        keep only mods with a POSITIVE spawn weight for this base type's tags
   ▼  FilterByFamilyConflicts  drop families already taken by an earlier channel
Filtered candidates  ──▶  SelectWeightedRandomSeeded

The Spawn-Weight Gate

A modifier's SpawnWeights is a list of (ItemTag, Weight) entries. The gate (DoesItemMatchModifierTags) admits a modifier only if at least one of its spawn-weight tags is present on the base type and carries Weight > 0. The same data then sets relative frequency:

Step Function Effect
Eligibility DoesItemMatchModifierTags Mod with no positive-weight matching tag is filtered out entirely — it can never roll on this base type
Frequency CalculateSpawnWeightCalculateWeightFromTags Sums the weights of all matching tags; higher total = more likely to be picked
Pick SelectFromWeightedListInternal (stream-aware) Linear weighted draw over the summed weights; picked mod is removed (no replacement), then the next pick draws again

If, after filtering, every candidate has zero weight, selection falls back to uniform over the candidates so an item is never left empty. With no item tags supplied to the selector (the runtime case), selection is effectively uniform among the already-tag-filtered candidates.


Implicits Are Fetched by ID, Not Weighted

Implicit modifiers are authored on the base type, in FItemBaseTypeDefinition::ImplicitModifierIDs (a plain TArray<FString> of modifier IDs). They intentionally carry no SpawnWeights — selection is by explicit list, not by the weighted pool.

This matters because of how the two paths differ:

PREFIX / SUFFIX:  GetFilteredModifiers → FilterByTags drops anything with no
                  positive spawn weight → SelectWeightedRandomSeeded

IMPLICIT:         for each ID in base.ImplicitModifierIDs:
                      def = PoolManager->FindModifierByID(ID)
                      keep if MinimumItemLevel ≤ iLvl ≤ MaximumItemLevel
                  (NO spawn-weight gate — these have none by design)

If implicits were routed through the weighted path, FilterByTags would drop every one of them (they have no positive spawn weight), and the item would silently lose its implicit. That is exactly the bug fixed in 72a7975ed: after a content re-author, the implicit branch was still weight-gating, so no item rolled an implicit at all. The fix added the dedicated by-ID branch in GenerateModifiersOfType (with a level-window check only).

Authoring rule: an implicit must (a) be listed in the base type's ImplicitModifierIDs, and (b) resolve via FindModifierByID against a loaded pool (item pool, dungeon, echo, or auxiliary — all are indexed in the manager's master lookup). It must not rely on spawn weights.


Tier Selection

Every rolled modifier (implicit or explicit) is assigned a tier, then a value rolled inside that tier's range:

ModifierTier = PoolManager->CalculateTierFromLevel(iLvl, stream)
RolledValue  = Definition->GetRandomValueFromTier(ModifierTier, stream)
                    │ uses TiersValueRanges[Tier]  (per-tier Min..Max)

How the Tier Is Picked

UModifierPoolDataAsset::TierMap maps EModifierTier → minimum item level. For explicit affixes, CalculateTierFromLevelSeeded collects every tier whose threshold the item level meets, then picks randomly among the unlocked tiers (not the highest). Same item level can therefore yield different tiers across rolls — but the same seed yields the same tier.

Implicits do not roll a tier. The implicit channel passes bPinHighestTier into SelectRandomModifiersSeeded, which resolves CalculateHighestTierFromLevel — the best (lowest-enum) tier unlocked at the item level, a pure function of item level that consumes no stream draw. The Item Composer, Build Lab validation, and the composition specs all mirror the pin: an implicit can only be authored/seated/retiered at exactly the pinned tier, and BuildLoadoutValidation flags implicit values from any other tier's range.

Which Pool Decides the Tier

The pool manager loads several pools, and not all of them define tiers. UModifierPoolManager::CalculateTierFromLevel walks LoadedPools and uses the first pool with a non-empty TierMapnot simply the first pool:

Pool kind Has TierMap? Decides tier?
Item loot pool Yes ✅ first non-empty wins
Sealed Echo / Greed (blessing) pools Empty ❌ skipped

Sealed pools are scanned under the same primary asset type but carry empty TierMaps, and load order differs between the runtime and editor-sim managers. Selecting "first pool" blindly (the pre-72a7975ed behavior) was order-dependent and could pick a tier-less pool. If no loaded pool has a TierMap, the manager logs a warning and falls back to Tier1.

Not Every Modifier Has Tiers: Granted-Ability-Only Procs

Tiering assumes a modifier has a rolled value — a TargetProperty attribute plus a TiersValueRanges map. Granted-ability-only proc affixes do not. Introduced with the proc-affix pass (989bb8d9f, ignite-on-crit; extended by the 2026-07-02 pass), this modifier shape sets FModifierDefinition::GrantedAbility (an ability granted to the ASC on equip, removed on unequip) and carries no TargetProperty and no TiersValueRanges:

Field Normal (rolled) modifier Granted-ability-only proc
TargetProperty Set (an attribute in TagsToAttributes) Empty
TiersValueRanges Per-tier Min..Max, rolled Empty — no value rolled
GrantedAbility None Set (e.g. GA_OnHit_IgniteOnCrit, GA_OnBlock_Bleed)

Because there is no value axis, these mods are not tieredGenerateModifiersOfType still stamps a ModifierTier from the pool, but GetRandomValueFromTier has no range to read, so no magnitude is applied; the ability's own logic (guaranteed-on-condition, e.g. ignite on crit / bleed on block) is the whole effect. The scaling axis is the trigger's uptime (crit rate, block uptime), not a rolled number.

The itemization import validator (UModifierPoolValidator) skips its tier-coverage check for this shape — the guard is !TargetProperty.IsValid() && GrantedAbility — so a proc affix with an empty TiersValueRanges is not flagged as "rolls 0.0". The ImportJson authoring path likewise admits an entry with no TargetProperty as long as GrantedAbility is set.


Determinism & Seeding

The whole pipeline accepts an optional FRandomStream*. Null stream = global RNG (FMath::RandRange); a supplied stream makes every random decision — count, affix pick, tier, value, weapon-scaling sub-seed — reproducible.

Caller Stream? Result
UItemSpawner (live drops, vendors) Null Global RNG — each drop is fresh
UEternalCheatManager::GiveItem <ID> [iLvl] [Seed] FRandomStream(Seed) when Seed ≠ 0 Reproducible item from a seed
Editor Roll Simulator (UEditorItemSimSubsystem) Seeded stream Sim output equals in-game output for the same seed

This is the contract that makes the Itemization Tooling credible: the simulators call the runtime generation code, seeded with an FRandomStream, rather than reimplementing it — so a Roll Simulator result and a PIE roll from the same seed are identical. Before 72a7975ed, tier selection used unseeded FMath::RandRange even on the seeded path, so same-seed editor-sim vs PIE values mismatched; CalculateTierFromLevelSeeded closed that desync.

The Send-to-PIE path (GiveSimulatedItem) transfers an already-rolled manifest rather than re-rolling from a seed, so the item the designer simulated is the exact item that spawns. See Send-to-PIE.


Historical Confusion (resolved): the "+1" Base Attribute Line

(FSH-312 recurring QA misread — the layer that caused it is now deleted.) Items used to show a static +N Base Attribute line (from FItemBaseTypeDefinition::BaseAttributes) directly above the rolled implicit, which QA read as "the implicit only rolls +1". The 2026-07-23 rulings removed the layer entirely and pinned implicit tiers, so an item now shows only rolled modifiers: a pinned-tier implicit plus random-tier explicit affixes. If a stray "+1"-style line ever reappears, it is a regression, not a design layer.


Authoring Rule: Weapon Damage Modifiers Are Local (2026-07-01)

Flat and Increased weapon damage affixes only function on the weapon itself (PoE-style local modifiers). CalculateWeaponDamageBreakdown reads the attacking weapon's own FEquipmentFragment::GetLocalModifiers() — a "+18 Fire Damage" roll on a ring would display in the tooltip and silently do nothing to attacks. Do not author flat/local weapon-damage affixes into non-weapon modifier pools.

Non-weapon slots express offense through the global families instead: Stats.Offensive.Increased.* (per-type Increased %), crit chance/damage, ailment chance, Stats.Offensive.AilmentDealt.*, and conditional-vs-status. Those are attribute-backed and work from any slot. If "caster gloves" style global flat damage is ever wanted, it needs new Flat*Damage attributes folded into the breakdown builder — a deliberate feature, not a pool-authoring change (decision recorded 2026-07-01: local-only for MVP; GDD Modifier Anatomy carries the same rule).


Authoring Conventions (Modifier Pool)

Migrated from the retired Tools/Python/modifier_content_table.py docstring (2026-07-02). The pool's full state now lives in Tools/ModifierPool.json (export/sync workflow — see Itemization Tooling); these are the rules that keep new entries functional, not just well-formed.

Tier Thresholds & the UNIQUE-Tier Trap

The item pool's TierMap is Tier5 @ ilvl 1, Tier4 @ 15, Tier3 @ 30, Tier2 @ 45, Tier1 @ 60.

Never author a Unique threshold into TierMap. Tier selection picks randomly among unlocked tiers (see Tier Selection), so a Unique: 100 row would let ilvl-100 items roll tier Unique — and any modifier without a UNIQUE entry in its TiersValueRanges then rolls 0.0 (engine trap; this was live in the pre-content-sprint data). Legacy attribute mods keep a dormant UNIQUE: 100–200 value range for unique-item generation — that is the only sanctioned use of the Unique tier in ranges.

Operation Conventions per Attribute Bucket

Derived from EternalAttributeSet TagsToAttributes and the DamageBuckets/ConditionalModifiers seam (4f8048c2d):

Bucket Scope / Op Why
Stats.Offensive.Increased.* Global / Flat The attribute itself is the additively-summed percentage — an Increased op would double-apply
ConditionalDamage.Vs* Conditional / Flat, empty WearerTagRequirements These gate on the victim's Status.* tags in ExecCalc_Damage; wearer reqs are for wearer-state conditionals only (RequireTags must all be present, IgnoreTags all absent)
Multiplier attributes (DamageDealt, DamageTaken, StaminaCostMultiplier) Global / Increased GAS multiplicative bias-1.0 SumMods; a negative roll = reduction

Local-Scope Routing: Damage Is Field-Based, Poise/Stamina Still String-Matched

As of 2026-07-24 (feature/balance-curve-retune), the damage families route through explicit definition fields — Scope / Operation / TargetProperty / ModifierFamily (IsLocalFlatDamageModifier / IsLocalDamagePercentModifier in WeaponPropertiesLibrary) — and the old DoesModifierAffectStat("Damage") description-inspection path is retired for them. Per-type damage lanes compute (curve-scaled base + local flats) × local %-increased stack: flat joins the base before percent multiplies, so flat + percent is a combo, not competitors. All 8 damage-type families participate.

String inspection survives for the non-damage local stats (poise, stamina cost), so wording there is still load-bearing (cleanup tracked as its own card):

  • The poise mod's family/description must avoid the substring Damage/damage or it leaks into the weapon Damage stat — hence family PoisePercent and the wording "Poise Break".
  • Local percent mods must have Percent in the ModifierFamily name.

Flat Weapon-Damage Tiers Are Fraction-Baked

Flat damage affix tiers are authored as fractions of curve-scaled base damage, then baked into the absolute TiersValueRanges players see — the affix's share of a hit holds constant across the descent instead of decaying into noise:

Field (on FModifierDefinition) Role
TierValueFractions TMap<EModifierTier, FFloatInterval> — per-tier min/max fraction of reference base (authoring source of truth)
FractionReferenceBaseDamage Reference weapon base damage the fractions multiply (e.g. 13 for 1H, 21 for 2H families)

The bake (Eternal.Modifiers.BakeFlatDamageTiers console command) computes each tier's bracket base as reference × item-level curve at the tier's TierMap unlock level and writes the absolute ranges (min width: a 2-value range). Rolled items store absolute values as before — nothing at generation/runtime reads fractions; the bake is an authoring-time step, re-run when fractions, the reference, or the curve change. FlatDamageBakeContract.spec recomputes the baked ranges through the same loaders. Stored loadout/preset rolls survive a re-bake via Eternal.Modifiers.MigrateFlatDamageRolls (snap-to-nearest-baked-edge, never re-roll); Eternal.Modifiers.FixIllegalAuthoring repairs authored rolls/implicits that violate tier legality.

Do not hand-edit TiersValueRanges on fraction-authored definitions — the ranges are derived, and the next bake clobbers the edit. Edit the fraction and re-bake. Enforced: the content validator (on-save / pre-push / CI) errors when a baked range disagrees with its authored fraction, naming the fix command; FlatDamageBakeContract.spec pins the same contract in the test suite.

Negative-Roll Wording

Word descriptions without "reduced" so the minus sign reads naturally ("-14% Stamina Cost of Attacks") — FormatDescription performs no abs() on the rolled value.


Public Contracts

UItemGenerationLibrary

Method Parameters Purpose
GenerateItemProperties (FItemManifest&, UModifierPoolManager*, FRandomStream* = nullptr) Main entry point: rolls modifiers + weapon scaling onto an equipment manifest
GenerateModifiers (FEquipmentFragment&, const FItemGenerationParams&, UModifierPoolManager*, FRandomStream*) Rolls implicit → prefix → suffix with shared family tracking
ApplyWeaponScaling (FEquipmentFragment&, const FItemBaseTypeDefinition&, int32 ItemLevel, int32 RandomSeed = 0) Appends weapon scaling modifiers (seed-based; 0 = global)

UModifierPoolManager

Method Parameters Purpose
GetFilteredModifiers (ItemTags, iLvl, ModifierType, ExistingFamilies) Type → level → tag (spawn-weight) → family filter chain
SelectRandomModifiersSeeded (FilteredPool, Count, iLvl, FRandomStream*) Weighted draw without replacement; assigns tier + value per pick
CalculateTierFromLevel (iLvl, FRandomStream* = nullptr) Picks tier from the first pool with a non-empty TierMap
FindModifierByID (ModifierID) Master-lookup resolve (used by the implicit-by-ID branch)

UModifierPoolDataAsset

Member Type Purpose
TierMap TMap<EModifierTier, int32> Tier → minimum item level (empty on sealed pools)
CalculateTierFromLevelSeeded (iLvl, FRandomStream*) Random tier among those unlocked at iLvl; null stream = global RNG

Key Data Fields

Field On Role
ImplicitModifierIDs FItemBaseTypeDefinition Explicit implicit list (by-ID, no spawn weight). The channel rolls one pick from this list — a base wanting a guaranteed line (e.g. armor bases → armor_implicit) must list exactly that one entry
SpawnWeights FModifierDefinition Per-tag eligibility + frequency gate for explicit affixes
TiersValueRanges FModifierDefinition Per-tier Min..Max for the rolled value — empty on granted-ability-only procs
GrantedAbility FModifierDefinition Ability granted on equip (proc affix). When set with no TargetProperty, the mod rolls no value and is not tiered

Source References

Class / Concept Location
Generation entry + per-channel rolling Private/Inventory/Items/Generation/ItemGenerationLibrary.cpp
Generation public API + FItemGenerationParams Public/Inventory/Items/Generation/ItemGenerationLibrary.h
Filter chain, weighted selection, count, spawn-weight math Private/Inventory/Modifiers/ModifierLibrary.cpp
Pool manager (filter / select / tier source / lookup) Private/Inventory/Modifiers/ModifierPoolManager.cpp, Public/Inventory/Modifiers/ModifierPoolManager.h
Tier roll + TierMap Private/Inventory/Data/ModifierPoolDataAsset.cpp, Public/Inventory/Data/ModifierPoolDataAsset.h
FModifierDefinition, FRolledModifier, FEquipmentModifiers, enums Public/Inventory/Modifiers/ModifierDefinitions.h
ImplicitModifierIDs, BaseAttributes, base type def Public/Inventory/Data/ItemBaseTypeDataAsset.h
Live-drop caller (null stream) Private/Inventory/Items/ItemSpawner.cpp
Seeded / send-to-PIE callers Private/Debug/EternalCheatManager.cpp
Editor seeded reuse Private/Shared/Simulation/EditorItemSimSubsystem.cpp (ProjectEternalEditor)

  • Item System — manifests, fragments, the item object lifecycle generation feeds
  • Loot System — what selects base types / item levels and triggers generation on drop
  • Crafting System — mutates already-generated affixes (reroll / add / remove, influence reagents)
  • Conditional Modifiers — modifier-definition variants that roll through this same pipeline
  • Itemization Tooling — Roll Simulator / Send-to-PIE; same seeded code path

Recent Changes

Date Change Impact
2026-07-24 Flat weapon affixes rejoin the damage builder + fraction-baked tiers (FSH-435, feature/balance-curve-retune) Damage-family local mods route on explicit Scope/Operation/TargetProperty/Family fields (string inspection retired for damage; survives for poise/stamina). Per-type lanes = (curve base + local flats) × local %-increased. Flat tiers authored as TierValueFractions × FractionReferenceBaseDamage, baked to absolute ranges via Eternal.Modifiers.BakeFlatDamageTiers; migration (MigrateFlatDamageRolls) + authoring repair (FixIllegalAuthoring) commands; FlatDamageBakeContract.spec guards the bake. See fraction-bake section
2026-07-24 Weapon item-level damage curve + per-drop quality roll (feature/itemization-fsh-369-364) New curve & quality section: manifest BaseDamage = on-curve value at the anchor item level (5); CalculateWeaponDamage multiplies by the ItemScaling.json curve (enemy-HP shape, iLvl 40 ≈ 5.9x) and the weapon's seeded DamageQualityRoll (±15%, drawn last in generation so pre-existing seeds keep their draws; pre-existing items default 1.0). FItemScalingConfig static loader + Eternal.Balance.ReloadItemScaling
2026-06-17 Initial authoring of the item-generation / modifier-rolling doc Documents the shipped pipeline (implicit-by-ID → weighted prefix/suffix → seeded tier/value) and the 72a7975ed fix: implicits were silently weight-gated to nothing (now fetched by ImplicitModifierIDs), and seeded tier selection used unseeded RNG (now CalculateTierFromLevelSeeded, with the tier source being the first pool with a non-empty TierMap). Includes the FSH-312 "+1 is the Base Attribute line, not the implicit" clarification and a Design-Status caveat that the base-attribute layer may be removed.
2026-07-23 Implicit tier pinning + Base Attribute layer removal (FSH-364 / TestPass-D1+D2, feature/itemization-fsh-369-364) Implicits pin to the highest tier unlocked at the item level (CalculateHighestTierFromLevel, no stream draw); explicit affixes keep the random roll. BaseAttributes deleted end-to-end (struct field, equip GE, tooltip, stat sources, Item Manager section); armor bases fold their armor into the new armor_implicit (sole entry in their ImplicitModifierIDs); vestigial weapon/shield "+1" lines deleted without replacement. Composer/Build-Lab/Roll-Sim mirrors updated.
2026-07-02 Granted-ability-only proc affixes documented (989bb8d9f) Added Not Every Modifier Has Tiers: the proc-affix shape sets GrantedAbility with no TargetProperty and no TiersValueRanges, so it rolls no value and is not tiered (guaranteed-on-condition; e.g. ignite-on-crit, bleed-on-block). The import validator skips tier-coverage for it (!TargetProperty.IsValid() && GrantedAbility); GrantedAbility added to the Key Data Fields table.