# Weapon OD — recompute from the VTank loot profile (0–10 scale) **Date:** 2026-07-15 **Status:** Approved (user directed the rebuild) ## Problem The shipped OD (`od.go`) ports UtilityBelt's unbounded "over-retail damage" float (−16 … +43). The user's actual in-game OD is a **0–10** (casters 0–7) integer they see on ident. Investigation (this session) proved: - Mag-Tools has **no** OD calculation. Its `ItemInfoPrinter` prints the item info **plus the matching VirindiTank loot-rule name** (`GetLootRuleInfoFromItemInfo`). - The user's `VirindiTank\Loot.utl` defines a ladder of rules named `(H) Axe (OD +10)`, `(OD +9)`, … per weapon bucket. Mag-Tools prints whichever matched — **that** is the OD the user sees. The thresholds are the same across all the user's profiles (user-confirmed), so one table drives every weapon and caster. ## The rule (decoded from Loot.utl) Each bucket is `OD = clamp(stat − baseline, 1, cap)`, where the bucket is matched by **weapon skill + mastery + (optional) weapon-name filter**, and `stat` is a raw captured value. Ladders step by 1 per tier. - **Melee** (object_class 1): `stat = MaxDamage` (int key `218103842`), cap 10. 27 buckets (skill `218103840`, mastery `353`, name filter, baseline) — see the table in the plan. Example: 2H Cleaving baseline 45 → Tetsubo (maxdmg 85) = OD **+10** (matches in-game); Heavy Axe baseline 74. - **Crossbow** (object_class 9, mastery `353`=9): `stat = int key 218103839` ("effective missile damage"), baseline **77**, cap 10 (OD+10=87 … OD+1=78, verified on Fire Compound Crossbow k839=89 → +10). - **Casters** (object_class 31): `stat = ElementalDamageVersusMonsters` (double key `152`), baseline **1.18**, ×100, cap **7**. War bucket: skill `159`=34, name `Baton|Sceptre|Staff`; Void bucket: skill 43, name `Nether`. OD+7 at elemVsMon 1.25, OD+1 at 1.19. Frost Baton (1.40) → +7. - **No match / stat below baseline+1 → NULL** (item shows no OD, exactly like in-game when no OD rule matches). ## Deferred: Bows & Thrown (mastery 8 / 10) Their OD rules threshold a **computed decimal** damage stat (bow OD+10 = 78.6, thrown = 85.3) on a key not reliably identifiable from the profile alone (bow `218103839` values 45–70 don't reach 78.6; the rule uses a DoubleValKeyGE the `.utl` encodes indirectly). To avoid showing a *wrong* number (the very problem we're fixing), **bows and thrown get NULL OD** in this pass. Reopened once the user supplies one in-game bow OD reading to calibrate the stat. ## Design - **`od.go` rewritten** around an ordered bucket table (extracted from `Loot.utl`, committed as data): `{objectClass, skill, mastery, nameAlts[], statKey, baseline, cap}`. `computeOD` finds the first bucket whose objectClass+skill+mastery+name match, then `OD = clamp(stat − baseline, 1, cap)`; returns null if none match or stat ≤ baseline. Name match = any alternative is a substring of the item name (VTank substring semantics). - **Type**: `od_rating` stays `DOUBLE PRECISION NULL` but now holds an integer 1–10 (or 1–7). Frontend already renders it; drop the `+`/decimals for the new integer (show plain `7`, `10`, `—`). - Search `min_od`/`max_od`/sort unchanged. Backfill via existing `POST /admin/backfill-od`. - The best-values table, spell-effect dicts, and variance math in the old `od.go` are **deleted** (wrong metric). ## Testing Go golden tests from real items: Tetsubo → 10, Frost Baton → 7, Fire Compound Crossbow → 10, a mid melee → its expected tier, a below-baseline weapon → null, a bow → null (deferred). Then live backfill + spot-checks; the column must read 0–10/0–7 and the user confirms against ident. ## Out of scope - Bows/thrown OD (deferred, null). - The loot-rule name filters are reproduced as substring lists; exotic renamed items may differ from a live VTank match — acceptable.