Skip to content

fix ResolveVariant's arithmetic (HS-1552) - #11

Merged
baughj merged 3 commits into
mainfrom
hs-1552-variant-elements
Oct 24, 2026
Merged

baughj merged 3 commits into
mainfrom
hs-1552-variant-elements

Conversation

@baughj

@baughj baughj commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

This PR fixes the defects in Variant.ResolveVariant listed on HS-1552.

Changes

  1. Physical. * binds tighter than ??, so a variant without <Physical> set value, durability and weight to 1. A missing multiplier now keeps the base value.
  2. Elements. A variant now inherits each element it leaves at None, and replaces one it sets. Previously the non-nullable element properties made every variant with <StatModifiers> overwrite both elements, and the else branch cleared the defensive element.
  3. Damage. SmallMin was computed twice, and SmallMax was never scaled.
  4. Flags. The variant's flags used to replace the base item's when the base had any, and were dropped when it had none. They are now a union.
  5. Tags. The variant's tags are added only where the base lacks them. A base item with no tags already worked, because Tags is initialized to an empty list. The bug was that a tag on both items was listed twice.
  6. Restrictions. <Level Min> on a variant threw when the base item had no <Restrictions>, and Item.ProcessAll swallowed the exception. A base level with no Min now counts as 1, which is how the server treats it, so <Level Max="99"/> and a missing <Level> resolve the same way.
  7. HideBoots. The copy is removed; it was a no-op on a clone of the base item.
  8. Optional inputs and overflow (found in review). A base item with no <Equipment>, or a weapon with no <Damage>, threw and lost its variants. A scaled damage value over ushort.MaxValue wrapped: on net10, 80000 became 14464. Damage is now clamped before the cast.

Not changed: the "clamp once at the end" item. ProcessAll and VariantGroup.ResolveVariants apply exactly one variant to a base item, so each value is multiplied and clamped once.

A 0 damage multiplier stays a literal ×0, by decision. So the world fix has to give an explicit 1 for every damage bound it means to leave unchanged, because an omitted bound deserializes to 0.

What changes in real data

I built every variant with main and with this branch, and compared each field:

world (4545 variants) ceridwen (415)
Flags gained from the base item 4247 373
SmallMax changed 213 20
Defensive element restored 56 —
Value / durability (test clutter at Value="1234567", now clamped) — 8

The flags restored in world are Enchantable 2280, Consecratable 2224, Tailorable 1575, Smithable 292, Bound 70, UniqueInventory 70 and MasterOnly 66. For example, Magic Saffian Boots goes from Depositable, Exchangeable, Vendorable to that set plus Enchantable and Consecratable. Today the four process flags are read only by SkillAnalyze.lua, for display.

This depends on WLD-39. Smithable variants write <Damage> as child elements, so all four multipliers deserialize to 0. On main, the unscaled SmallMax was the one damage value those weapons kept (Great Scythe 0/0/0/381). With this change it scales by that 0 as well, so the world-data fix should land together with this one.

Tests

  • VariantResolution has 17 cases, including the three element cases. Every test for a fixed defect was run against the code before its fix and failed there; the rest cover behaviour that already worked.
  • The full suite passes 124 of 124 against ceridwen HEAD, built in a clean worktree from the pushed commit. Against world data the same 24 fixture tests fail on main and on this branch, because the suite targets ceridwen's entities. XmlStoreTests.Contains (not touched here) is flaky: it draws castable names from 0-999, so they collide about 0.3% of the time.
  • After the review changes, every variant in world and ceridwen resolves byte-identically to the first fix commit.

Review

  • Codex did a cold review, then re-read after each round, and signed off with no findings at any severity. Its only finding (×0 damage from WLD-39) is decided above.
  • /code-review (high): its null guards, damage clamp, level default and simplifications were applied; its XSD-default change was left for later.
  • /simplify: four cleanup passes, applied where behaviour stays identical.

Refs: HS-1552, WLD-39

- ResolveVariant tested the variant's element properties against null,
  but they are non-nullable and default to None. Any variant with
  StatModifiers therefore replaced both of the item's elements with
  its own, None unless it set one. A variant without StatModifiers
  took the else branches, where the offensive branch assigned the
  defensive element, and so cleared it.
- An element the variant leaves at None is now inherited from the base
  item; one it sets replaces the base item's.
- In world data this restores the defensive element on 56 variants of
  12 elemental belts, such as every Enchant1 variant of Fire Belt. No
  variant sets an element to None explicitly, so none relied on
  clearing one.

Refs: HS-1552
- Physical: `*` bound tighter than `??`, so a variant without
  <Physical> set value, durability and weight to 1; a missing
  multiplier now keeps the base value
- Damage: SmallMin was computed twice and SmallMax never scaled
- Flags: the variant's flags replaced the base item's when it had any,
  and were dropped when it had none; they are now a union, so a variant
  keeps Bound, MasterOnly, Tailorable and the rest
- Tags: the variant's tags are added only where the base lacks them;
  before, a tag on both was listed twice
- Restrictions: a variant with <Level Min> on a base item with no
  <Restrictions> threw, and Item.ProcessAll buried the exception
- drop the HideBoots copy, a no-op on a clone of the base item

Refs: HS-1552, WLD-39
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.12%. Comparing base (8e79faf) to head (0022e35).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #11      +/-   ##
==========================================
+ Coverage   62.90%   63.12%   +0.22%     
==========================================
  Files          43       43              
  Lines        1531     1524       -7     
  Branches      351      346       -5     
==========================================
- Hits          963      962       -1     
  Misses        457      457              
+ Partials      111      105       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- a base item with no <Equipment>, or a weapon with no <Damage>, threw
  and lost its variants; scaling now skips them
- a scaled damage value over ushort.MaxValue wrapped (80000 became
  14464 on net10); it is clamped before the cast
- a base level with no Min counts as 1, as the server treats it, so
  <Level Max="99"/> and a missing <Level> resolve alike
- element overrides move inside the StatModifiers block, damage scales
  the clone's own values, and the dead Tags guards and the element
  helper go
- the element tests fold into VariantResolution as a theory

Refs: HS-1552
@Caeldeth

Copy link
Copy Markdown
Contributor

Review note, not a blocker for this PR.

StatModifiers operator + does not merge ElementalModifiers. The result keeps the base item's list and loses the variant's list. So a variant that gives an elemental resistance or augment produces an item without it. This was true before this PR, and this PR does not change it.

  • ceridwen var_resists1, var_resists2, var_augs1 and var_augs2 set <ElementalModifiers>. For example, the FireResist variant ("Smoldering") gives Fire resistance 0.03, and the resolved item has none.
  • No world variant group uses ElementalModifiers today.
  • CreatureBehaviorSet import uses the same operator (CreatureBehaviorSet.cs:148), so an imported set's elemental modifiers are lost too.

The fix is in StatModifiers.cs, not in ResolveVariant, so it has its own card: HXML-30.

@baughj
baughj merged commit f0807ad into main Oct 24, 2026
3 checks passed
@Caeldeth
Caeldeth deleted the hs-1552-variant-elements branch October 24, 2026 20:12

This branch was previously deployed

1 inactive deployment
deploy — 0022e353 Deployed Oct 23, 2026 by baughj via build (10.0.x) #125
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.

2 participants