commit 6859d73285fc3c62fcb6d1d43ae648996d3220cf Author: ShempError Date: Mon Aug 3 09:47:19 2026 +0200 DopingControl v0.6.1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0591240 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,208 @@ +# Changelog + +All notable changes to DopingControl. Versions are the ones actually +released here — a new feature raises the middle number and resets the last +one, the last number is for fixes without new features. + +## v0.6.1 + +A fix release. Everything here is something that was supposed to work +already — most of it found by running the addon in a real 26-man raid and +then checking what it had actually read. + +### Resistances of other players are readable at last + +The Resistances tab shipped in v0.6.0 and showed `?` for everyone but you. +That was not a display bug: this client is never told other players' +resistances. Both read paths return `0` for a raid member standing next to +you, fully loaded — including their armour value, which is the giveaway +that the field is simply not transmitted rather than genuinely zero. + +The numbers are now **reconstructed from the equipped items' tooltips** — +the same route the Hit tab already takes — and `Equip: +N All Resistances` +counts on all five schools. On top of that comes the **racial bonus**, +which is written in no item: Dwarf +10 Frost, Gnome +10 Arcane, Night Elf +and Tauren +10 Nature, Undead +10 Shadow. Goblin and High Elf get nothing +added, because no resistance racial for them could be confirmed — an +invented bonus would be worse than a missing one. + +This stays a near-range reading. A raid member the client has not loaded +has no readable equipment at all, and their column stays `?`. + +### The weapon-buff column could never report a missing buff + +For another player, the client returns *nothing* for the weapon +enchantment when there is no imbue — not an empty string, as the code +expected. So everyone without a weapon buff was shown as `?` instead of as +a gap, and was left out of the gap count, the footer and the whisper. +Measured in a 26-man raid: **14 of 25** players were mislabelled that way — +precisely the case a buff checker exists to report. + +The fix distinguishes by readability: if the main hand can be read and +there is no imbue, that is a gap. If the player cannot be read, it stays +unknown. Unknown is still never silently turned into missing. + +### Consumables that were being discarded + +Six buffs were tracked in the tables but never recognised, because the +aura carries a different name than the item: **Elixir of the Giants**, +**Elixir of Greater Agility**, **Elixir of Fortitude**, **Strike of the +Scorpok**, **Rage of Ages** and **Elixir of Greater Frost Power**. In one +raid, `Elixir of the Giants` alone was thrown away 38 times. + +Aura names also arrive from the client with trailing spaces on occasion, +which defeated the name match; they are trimmed now. + +### "Gaps only" no longer empties Hit and Resistances + +Those two tabs are numeric — no cell there can be *missing*, so the filter +hid every row. It now also counts the unknown cells, which is what there is +to see on those tabs. + +### The coverage number belongs to the tab it is shown on + +The pill counted the players whose **auras** could be read — and showed +that number on every tab. Auras are readable raid-wide, equipment and hit +only nearby, resistances only for yourself. In the same raid the pill +claimed `26/26` while the Hit tab really knew 15 of 26 and the Resistances +tab 1 of 26. + +Each tab now reports its own reading, and says which one it is: +`26/26 readable` on the aura tabs, `15/26 gear read`, `15/26 hit read`, +`1/26 resist read`. The report line in chat carries the same number. As a +side effect the word "readable" now means one thing instead of two. + +## v0.6.0 + +### Hit + +A new **Hit** tab, and with it the addon's second numeric tab after +Resistances. Eight columns: melee, ranged, and one per spell school — +because talents and items raise schools individually, so one "spell hit" +number would be wrong for most casters. + +There is **no hit API in 1.12**. The number is assembled from what can +actually be read: + +- **items** — parsed out of the equipped items' tooltips +- **set bonuses** — counted **once per set**, only when the piece + threshold is met (the bonus line is printed on every piece of the set, + so adding it per item would multiply it) +- **hit auras** — buffs that grant hit +- **talents** — for your own character, and for other players over the + inspect protocol this server ships (see below) +- **weapon skill** — every point above 300 is worth 0.2% hit against a + +3-level boss + +The cell tooltip shows that as a plain **addition**: one row per source, +values in a right-aligned column, so the total can be checked by adding it +up. Sources that contribute nothing are still listed at `+0.0%` — a missing +row would look like a forgotten source. A source that could not be **read** +shows `?`, never `+0.0%`: unknown is not zero. + +The boss's requirement is stated as one flat number (`Cap vs a +3-level +boss`). It does not move with your weapon skill — skill counts on the +player's side of the comparison, where it belongs. + +Caps: melee 8% at 300 weapon skill, dual-wield white swings 27%, spell 16%. +A cell at or above its cap is marked green. + +### Reading other players' talents + +Talents raise hit by up to several percent, so a hit number without them is +systematically low. WoW 1.12 has no foreign-talent channel and this server +transmits none either — but it ships **its own inspect protocol**, a plain +addon-message conversation: we ask by name, the target's client answers with +its talent tree. + +DopingControl uses exactly that, for the players the scan already shows. +One request per player, at most one every five seconds, and each name at +most once per half hour. It can be switched off entirely with +`/dc talents off`. + +### Weapon skill books + +A quest weapon-skill book grants +3 skill — about 0.6% hit — and **cannot be +detected on another player**: no channel reports foreign weapon skills. So +it is entered by hand, in a new **Skill books** options tab: a grid with one +row per scanned player and one column per weapon skill, with a checkbox +wherever that class could own a book for it. + +### Whisper on the Hit tab + +The hit tab now offers the per-player whisper button, unlike the other +display tab: being short of the hit cap is something that player can fix, +a resistance total is not. The whisper carries both numbers — +`Melee hit 5.0/8.0` — so the reader knows the distance, not just the fact. + +It only ever mentions the columns that **role** is asked about: a melee +player is never told about his shadow hit, and healers are never whispered +at all (heals cannot miss). Every column stays visible with its own cap +marking on the tab — the display hides nothing, only the whisper is +selective. + +### Class buffs + +A sixth slot: **Emerald Blessing**. It ships with no expectation default — +it is a raid quest reward almost nobody carries — so its column stays +hidden until you enable it per role and class. Its +1% spell hit counts on +the Hit tab either way. + +### Tables + +- **Alternating row shading** in every table (main window, both options + grids), so a row can be followed across a wide grid. +- The **options window width follows the selected tab** instead of being + fixed to the widest one. +- The options column headers now use the **same header tiles as the main + window** — three-letter label over a sub-line, in a bordered strip. + +### Fixed + +- **Could not scroll to the bottom** of the main window once every role + group was expanded. The content height was right; the scrollbar was + never told about it, so it kept the maximum it had when the group was + still collapsed. +- An unreadable equipment row was labelled **"out of range"** even when + distance was not the cause. It now states what was actually observed — + that no equipment slot returned an item link — and prints the measured + distance separately. +- **"Not expected for role X"** was shown for cells decided by class or by + the item (a shaman has no ranged enchant slot; an empty exemptable slot), + and pointed at an options switch that cannot change them. Those cells now + give the real reason. +- The hit-column header claimed the number was summed from items alone, + while it also contained auras and talents. + +### Test mode + +`/dc test` now simulates a **40-player raid** in eight subgroups instead of +25 players, including deliberately unreadable players and a completely +unreadable group. + +## v0.5.2 + +New **Resistances tab**: display-only totals for all five schools per +player with a readable-players raid average in the footer. Equipment +semantics finalized: an empty slot counts as a gap unless it is one that may +legitimately be empty (off hand, ranged), the expectation grid governs +enchants only, and neck/rings joined the enchant logic (enchantable +on this server; expectation defaults to off). Expectation storage now +distinguishes a deliberate uncheck from a never-seen slot, so slots added in +future versions appear automatically. + +## v0.5.1 + +The Equipment tab now covers all sixteen slots: neck and both rings joined +as item tiles (quality border, tooltip, empty slot counts as a gap — no +enchant exists for them in vanilla, so they carry no corner marker). + +## v0.5.0 + +First public release: the four-tab readiness matrix (Consumables / Class +buffs / Debuffs / Equipment), the HAS–MISSING–UNKNOWN honesty model with +coverage pill and last-known display, the five-role system with +auto-suggestion and per-character confirmation, the role × class +expectations grid, per-player whispers with cooldown, the raid report with +mandatory preview, ready-check and window-open scan automation, and the +deterministic test mode. diff --git a/DopingControl.toc b/DopingControl.toc new file mode 100644 index 0000000..380c04e --- /dev/null +++ b/DopingControl.toc @@ -0,0 +1,32 @@ +## Interface: 11200 +## Title: DopingControl +## Notes: Raid consumables, buffs, debuffs, resistances, hit and enchant checker +## Version: 0.6.1 +## SavedVariables: DopingControlDB + +core\const.lua +core\config.lua +data\consumables.lua +data\classbuffs.lua +data\enchants.lua +data\expectations.lua +data\hit.lua +data\talenthit.lua +data\resist.lua +core\model.lua +core\roles.lua +core\sim.lua +scan\talents.lua +scan\reach.lua +scan\aura.lua +scan\gear.lua +scan\resist.lua +scan\hit.lua +scan\engine.lua +core\probe.lua +ui\widgets.lua +ui\matrix.lua +ui\options.lua +ui\report.lua +features\minimap.lua +core\init.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c6095e --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# DopingControl + +A raid consumables, class buff, debuff, resistance, hit and enchant checker for +**TurtleWoW / WoW 1.12.1 (Vanilla)**. + +One window that answers, before the pull: who has their flask and elixirs up, who is missing +which class buff, who is carrying which debuff, who is short of the hit cap, and whose gear +went into the raid unenchanted. +Its defining trait is **honesty about what it cannot see** — a player the addon cannot read is +shown as *unknown* and is **never counted as missing**, anywhere. Requires +[SuperWoW](https://github.com/balakethelock/SuperWoW) for full raid reads; degrades gracefully +without it. + +The full readiness matrix on a simulated raid + +*The full readiness matrix (shown here in test mode on a simulated raid): buff icons +in the cells, ready pills per role group, gaps first, unreadable players marked instead of +blamed.* + +## Screenshots + + + + + + + + + + + + + + + + + +
+ Class buffs tab
+ Class buffs — six slots, the buffer's duty +
+ Debuffs tab
+ Debuffs — columns only for what is present +
+ Resistances tab
+ Resistances — totals + raid average +
+ Hit tab
+ Hit — melee, ranged and one column per school +
+ Hit breakdown tooltip
+ Hit breakdown — every source on its own line +
+ Equipment tab
+ Equipment — item tiles, enchant corner markers +
+ Options panel
+ Options — expectations per role and class +
+ Skill books options tab
+ Skill books — one column per weapon skill +
+ School hit tooltip
+ School columns — what only that school gets +
+ +*(Click any thumbnail for full size.)* + +## The six tabs + +**Consumables** — eleven slots per player: Flask, Food buff, Attack power, Strength, Agility, +Spell power, Mana regen, Armor, Stamina, **Fire protection**, and the temporary weapon buff +(oils / stones). Cells show the **actual buff icon** the player has, not just a checkmark — a +Flask of the Titans and a lesser protection potion look different because they are different. + +**Class buffs** — the six raid buffs: Arcane Intellect, Mark of the Wild, Power Word: +Fortitude, Shadow Protection, Emerald Blessing, Soulstone. Emerald Blessing ships with **no** +expectation default — it is a raid quest reward almost nobody carries — so its column stays +hidden until you enable it per role and class, while its +1% spell hit still counts on the Hit +tab. This tab deliberately has **no whisper button**: a +missing class buff is the *buffer's* duty to fix, and whispering the unbuffed player would nag +the wrong person. + +**Debuffs** — a warning signal rather than a checklist. Columns are **built dynamically** from +what is actually on the raid: a debuff that nobody has gets no column, and a raid with no +debuffs shows none. The sum column carries the count per player, and clean players stay quiet — +no green wall of "nothing wrong here". + +**Resistances** — a display-only tab: five fire/nature/frost/shadow/arcane columns showing each +player's current total, no gaps, no expectation checkboxes, no whisper button. The footer shows +the raid average across readable players only. The client is never told another player's +resistances, so for everyone but yourself the total is **reconstructed from their equipped +items** plus the racial bonus (Dwarf frost, Gnome arcane, Night Elf and Tauren nature, Undead +shadow). That makes it a near-range reading: a raid member the client has not loaded shows `?`, +never a made-up number. + +**Equipment** — sixteen slots. Eleven armor slots (head, shoulders, back, chest, wrist, hands, +legs, feet, main hand, off hand, ranged) are enchant slots, checked present/missing with a +corner marker for the enchant state. Neck and both rings can be enchanted on this server (a +custom feature beyond vanilla WoW) and join the same enchant logic — the expectation defaults to +off, so enable it per role/class in the options grid where it matters. Both trinkets are item +tiles instead: the equipped item is shown regardless of any expectation. Empty slots count as a gap everywhere on this tab **except +the two that may legitimately be empty**: an empty off hand (you carry a two-hander) and an +empty ranged slot have nothing to enchant and are marked *not expected* instead. The +expectation grid only governs whether the *enchant* question is tracked. Slots where no enchant exists for that item or class are marked +*not expected* rather than red. Hovering a tile shows the **native item tooltip**, so you see +the exact enchant — not merely that one is present. + +**Hit** — melee, ranged and one column **per spell school**, because talents and items raise +schools individually: one "spell hit" number would be wrong for most casters. There is no hit +API in 1.12, so the number is assembled from the equipped items' tooltips, set bonuses, hit +auras, talents and weapon skill. The cell tooltip shows that as a plain **addition** — one row +per source, values in a right-aligned column, so the total can be checked by adding it up. A +source that contributes nothing is still listed at `+0.0%`; a source that could not be *read* +shows `?`, never a zero. The boss's requirement is one flat number (8% melee at 300 weapon +skill, 27% for dual-wield white swings, 16% spell) and does not move with your weapon skill — +skill counts on the player's side, where it belongs. Cells at or above the cap turn green. + +Two things about that number are worth stating plainly: + +- **Set bonuses count once per set**, not once per piece, and only when their piece threshold + is met. Their tooltip line is printed on every piece of the set, which is why a naive per-item + sum would multiply them. +- **Talents of other players are read**, over the inspect protocol this server ships — one + addon message per player, paced and repeated at most once per half hour, and switchable off + with `/dc talents off`. Until an answer arrives, the talent row shows `?` and the total says + it will rise. + +A quest weapon-skill book (+3 skill, about 0.6% hit) is the one input that **cannot** be read +off another player — no channel reports foreign weapon skills. It is ticked by hand in the +**Skill books** options tab: one row per scanned player, one column per weapon skill. + +## Honest by design + +The addon distinguishes three data states — **HAS**, **MISSING** and **UNKNOWN** — plus a +fourth, display-only one: *not expected*, for a slot the player is not asked to fill. + +- **UNKNOWN is never counted as MISSING** — not in cells, not in sums, not in ready pills, not + in reports. A player the addon cannot read produces no accusation. +- A **coverage pill** in the titlebar shows how much of the raid is actually readable right now, + so you know how much the numbers are worth before you act on them. +- **Last-known display:** when a previously scanned player becomes unreadable, their stale data + is still shown — half-transparent, with its age. This is display-only; a stale cell still + *counts* as UNKNOWN everywhere. + +A wrong number on a raid-readiness check is worse than no number. The addon would rather say +"I don't know" than guess. + +## Roles and expectations + +Players are grouped into five roles — **Tanks / Melee / Ranged / Casters / Healers** — because a +healer without a Mongoose elixir is not a problem and a tank without armor elixirs is. + +- Roles are **auto-suggested** with a stated reason (visible on the badge's tooltip). +- **Left-click** a role badge to confirm the suggestion; **right-click** cycles through the + roles plausible for that class. Confirmed roles are saved per character name. +- An **unassigned counter** in the titlebar shows how many players still run on suggestions. + +What each role is expected to bring is fully configurable — as a **role × class grid** in the +options panel, since a caster's "spell power" means something different for a mage than for a +shadow priest. Every role is configurable per performing class, and each role row has a bulk +toggle to switch a whole expectation on or off at once. + +## Actions + +- **Per-player whisper button** — tells a player exactly what they are missing on the current + tab. 30-second cooldown per player, and offered only for *readable* players with actual gaps; + the addon never whispers anyone based on data it does not have. On the **Hit** tab it carries + both numbers (`Melee hit 5.0/8.0`) and only mentions the columns that role is asked about — a + melee player is never told about his shadow hit, and healers are never whispered at all + (heals cannot miss). +- **Raid report with preview** — composes a summary for raid chat and shows it to you first. + **Nothing is ever sent without an explicit click.** +- **Scanning** — on demand (`/dc scan` or the toolbar button), automatically when the window + opens if there is no scan yet or the last one is over a minute old (never in test mode), and + automatically on a ready check. The two ready-check reactions — scan, and open the window — + can be switched off separately; the window-open scan is not optional. + +## Test mode + +`/dc test` swaps the live group for a **deterministic simulated 40-player raid** — same roster, +same gaps, every time. The full UI (all six tabs, roles, pills, report preview) can be +inspected and configured without being in a group, and all chat output is disabled while test +mode is on, so nothing simulated can ever reach another player. + +## Install + +**Option A — download (simplest):** + +1. Grab **`DopingControl-vX.Y.Z.zip`** from + [**Releases**](https://github.com/ShempError/DopingControl/releases). + *(Use the Release zip, not "Code → Download ZIP" — that names the folder + `DopingControl-main`, which WoW won't load.)* +2. Extract the **`DopingControl`** folder into `Interface\AddOns\`. +3. Restart the client. + +**Option B — git (auto-updatable):** + +``` +cd Interface/AddOns +git clone https://github.com/ShempError/DopingControl.git DopingControl +``` + +`main` is the stable release channel — git-based addon managers stay current with a +`git pull`. + +## Usage + +| Command | Does | +| --- | --- | +| `/dc` or `/doping` | toggles the main window | +| `/dc scan` | scans the group now | +| `/dc options` | opens the options panel (also: right-click the minimap button) | +| `/dc report` | opens the raid report preview | +| `/dc test` | toggles test mode (simulated 40-player raid, chat output disabled) | +| `/dc probe [range ]` | dumps raw scan-API returns to a file for debugging data-source gaps (SuperWoW required) | +| `/dc talents [on\|off\|clear]` | shows how many players' talents are in, or stops asking for them | +| `/dc unknown` | lists buff names the scanner saw but could not classify — useful for reporting gaps in the data tables | + +The minimap button toggles the window on left-click, opens options on right-click, and can be +dragged to reposition. + +## Requirements + +- A **TurtleWoW** client (WoW 1.12.1) with + **[SuperWoW](https://github.com/balakethelock/SuperWoW)** — used for buff *identity* (knowing + which buff a cell shows, not just its icon) and for reading other players' equipment. +- Optional: **UnitXP_SP3** — distance diagnostics; flags players out of scan range. Distance + never changes any cell state or sum. +- Optional: **nampower** — when present, its aura descriptor supplies the spell **IDs** of other + players' buffs and debuffs, which the tooltip path does not deliver on this client; both + sources are merged. Without it, buff identity rests on names alone. + +**Degrades gracefully:** without SuperWoW, cells for other players show *not readable* instead +of a guess, and the coverage pill tells you so. Your own character remains fully readable +either way. + +## Version history + +See [CHANGELOG.md](CHANGELOG.md). The short form: + +- **v0.6.1** — fixes: other players' resistances are reconstructed from their gear (the tab + showed `?` for everyone but you), the weapon column can report a missing imbue again, six + consumables whose aura name differs from the item name are matched, "gaps only" no longer + empties Hit and Resistances, and the coverage number belongs to the tab it is shown on. +- **v0.6.0** — the **Hit tab** (melee, ranged, per-school), hit assembled from items, set + bonuses, auras, talents and weapon skill with the tooltip showing the addition line by line; + other players' talents read over this server's inspect protocol; the **Skill books** options + tab; alternating row shading; scrolling fixed once every group is expanded. +- **v0.5.2** — the **Resistances tab**; equipment semantics finalized (empty slots always count, + the grid governs enchants only, neck/rings enchantable on this server). +- **v0.5.1** — the Equipment tab covers all sixteen slots. +- **v0.5.0** — first public release. + +## Licence + +MIT. diff --git a/core/config.lua b/core/config.lua new file mode 100644 index 0000000..70ba276 --- /dev/null +++ b/core/config.lua @@ -0,0 +1,210 @@ +-- DopingControl core/config.lua +-- SavedVariables defaults + fill-in: single DEFAULTS table, per-field fill-in +-- with nil-checks (NEVER 'or' for booleans -- would flip saved false), +-- copy-on-assign for table values so editing a live value can never mutate +-- the defaults. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- SavedVariables root (the client replaces this global at ADDON_LOADED; +-- core/init.lua then calls DC.EnsureDefaults(DopingControlDB)). +DopingControlDB = DopingControlDB or {} + +-- ------------------------------------------------------------------ +-- Defaults +-- +-- Two keys intentionally have NO entry here (a nil value cannot live in a +-- Lua table): +-- framePos -- default nil = "no saved position, use the anchor +-- default"; written by the UI on drag as +-- { point=..., relPoint=..., x=..., y=... }. +-- expectations -- default nil = "not initialized yet"; EnsureDefaults +-- special-cases it below: the saved shape +-- is PER CLASS (db.expectations[role][class][slotId]), +-- materialized/migrated from the DC.DEFAULT_EXPECT seed +-- by DC.EnsureExpectations. +-- ------------------------------------------------------------------ +DC.DEFAULTS = { + testMode = false, -- /dc test: simulated raid + showMinimapButton = true, + minimapAngle = 215, -- degrees around the minimap rim + readyCheckScan = true, -- auto-scan on READY_CHECK + readyCheckOpen = true, -- auto-open window on READY_CHECK + collapse = {}, -- per-role collapsed flags, e.g. collapse.RANGED = true + filterGapsOnly = false, -- toolbar filter All | Gaps only + roles = {}, -- confirmed roles keyed by player NAME + -- Weapon skill books: db.skillBooks[playerName][weaponSkillName] = + -- true|false. A quest weapon-skill book (+3 skill) is INVISIBLE on + -- other players -- foreign weapon skill cannot be read at all (see + -- data/hit.lua) -- so it is an opt-in the user ticks per player and + -- per weapon skill; it only shifts that player's melee/ranged hit CAP. + -- Unchecking stores an explicit false (never nil), exactly like the + -- expectation grid, so "deliberately off" survives any later fill-in. + -- The own character ignores these flags: its weapon skill is measured + -- and a measured value already contains the book. + skillBooks = {}, +} + +-- Generic deep copy (tables only; keys are copied by reference, values +-- recursively). No cycle handling -- config/expectation shapes are trees. +function DC.DeepCopy(src) + if type(src) ~= "table" then + return src + end + local out = {} + for k, v in pairs(src) do + if type(v) == "table" then + out[k] = DC.DeepCopy(v) + else + out[k] = v + end + end + return out +end + +-- ------------------------------------------------------------------ +-- Expectations: the saved shape is +-- PER CLASS -- +-- db.expectations[role][class][slotId] = true|nil +-- with one class line for every class that can perform the role +-- (DC_Roles.ROLE_CLASSES, the ordered inversion of CLASS_ROLES). +-- DC.DEFAULT_EXPECT (data/expectations.lua) STAYS the role x slot SEED. +-- +-- EnsureExpectations guarantees the full structure: +-- * first load (expectations == nil): every (role, performing class) +-- gets a deep copy of the seed's role line. +-- * legacy saved tables (one line per role) -- detected by BOOLEANS directly under +-- [role] (new-shape values are class tables) -- are MIGRATED: the +-- old role line is copied to every performing class, then the +-- structure is replaced. User edits survive on every class where +-- they are representable. +-- * new-shape tables: existing class lines are NEVER touched; missing +-- (role,class) combos are filled from the seed. The fill is +-- behavior-neutral -- the model resolves a missing combo to the +-- DC.DEFAULT_EXPECT role line anyway (runtime fallback) -- it only +-- hands the options grid a complete structure. +-- Known ambiguity (accepted): an old-shape role line with EVERY slot +-- unchecked is {} and indistinguishable from an untouched new-shape +-- role table (the boolean marker is gone); it is re-seeded per class. +-- +-- Resolved at CALL time, not file-load time: data/expectations.lua and +-- core/roles.lua load after this file (TOC order const -> config -> +-- data/* -> model -> roles), but EnsureDefaults only runs at +-- ADDON_LOADED when everything is in. Offline callers without DC_Roles +-- loaded simply skip the expectations block. +-- ------------------------------------------------------------------ +local function isOldShapeLine(line) + for _, v in pairs(line) do + if type(v) == "boolean" then + return true + end + end + return false +end + +-- Top up ONE class line in place: slots the seed marks true and the line +-- has NEVER SEEN (nil) become true. Deliberate unchecks survive (the +-- options grid stores those as explicit false, never nil). Shared by both +-- EnsureExpectations branches below so a freshly migrated line is already +-- complete -- not just on the SECOND call (idempotency: a migration must +-- converge in one pass, not two). +local function topUpLine(cl, seedLine) + for slotId, v in pairs(seedLine) do + if v == true and cl[slotId] == nil then + cl[slotId] = true + end + end +end + +function DC.EnsureExpectations(db) + if DC.DEFAULT_EXPECT == nil or DC_Roles == nil + or DC_Roles.ROLE_CLASSES == nil then + return + end + if db.expectations == nil then + db.expectations = {} + end + local exp = db.expectations + local nRoles = table.getn(DC.ROLES) + for r = 1, nRoles do + local role = DC.ROLES[r] + local classes = DC_Roles.ROLE_CLASSES[role] or {} + local nClasses = table.getn(classes) + local line = exp[role] + local seedLine = DC.DEFAULT_EXPECT[role] or {} + if line ~= nil and isOldShapeLine(line) then + -- migration: copy the old role line to every performing + -- class, then top up each new class line from the CURRENT + -- seed (a slot added since the old line was saved must reach + -- it immediately, not only on a later call -- see topUpLine), + -- then replace the structure (never lose user edits where + -- representable) + local byClass = {} + for c = 1, nClasses do + local cl = DC.DeepCopy(line) + topUpLine(cl, seedLine) + byClass[classes[c]] = cl + end + exp[role] = byClass + else + if line == nil then + line = {} + exp[role] = line + end + -- fill missing (role,class) combos from the seed; existing + -- class lines keep their values, but slots the seed knows + -- and the line has NEVER SEEN (nil) are topped up -- without + -- this, a slot added in a later version never reaches saved + -- lines (field report: neck/ring columns arrived as "not + -- expected" for everyone). Deliberate unchecks survive: the + -- options grid stores them as explicit false, never nil. + for c = 1, nClasses do + local cls = classes[c] + if line[cls] == nil then + line[cls] = DC.DeepCopy(seedLine) + else + topUpLine(line[cls], seedLine) + end + end + end + end +end + +-- Fill missing fields in db from DC.DEFAULTS. Existing values -- including +-- saved booleans that are false -- are never touched. Table defaults are +-- deep-copied on assign. +function DC.EnsureDefaults(db) + for k, v in pairs(DC.DEFAULTS) do + if db[k] == nil then + if type(v) == "table" then + db[k] = DC.DeepCopy(v) + else + db[k] = v + end + end + end + -- expectations: per-(role,class) shape, materialized/migrated from + -- the DC.DEFAULT_EXPECT seed (see EnsureExpectations above) + DC.EnsureExpectations(db) + return db +end + +-- Reset the whole config IN PLACE (keeps the table reference -- the client +-- saves the global DopingControlDB, so the reference must survive). +function DC.ResetConfig(db) + for k in pairs(db) do + db[k] = nil + end + return DC.EnsureDefaults(db) +end + +-- Reset only the expectation matrix to the DC.DEFAULT_EXPECT seed, +-- materialized per (role, performing class) -- options button +-- "Reset expectations" (reseeds ALL classes). +function DC.ResetExpectations(db) + db.expectations = nil + DC.EnsureDefaults(db) + return db.expectations +end diff --git a/core/const.lua b/core/const.lua new file mode 100644 index 0000000..843a637 --- /dev/null +++ b/core/const.lua @@ -0,0 +1,238 @@ +-- DopingControl core/const.lua +-- Shared constants: roles, states, tabs, color palette, quality colors. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- ------------------------------------------------------------------ +-- Roles (array order = display order) and labels +-- ------------------------------------------------------------------ +DC.ROLES = { "TANK", "MELEE", "RANGED", "CASTER", "HEALER" } + +DC.ROLE_LABEL = { + TANK = "Tanks", + MELEE = "Melee", + RANGED = "Ranged", + CASTER = "Casters", + HEALER = "Healers", +} + +-- ------------------------------------------------------------------ +-- Cell/data states. Hard rule: UNKNOWN is NEVER condensed to MISSING -- +-- an unreadable player is "no data", not "has nothing". +-- ------------------------------------------------------------------ +DC.STATE = { + HAS = "HAS", + MISSING = "MISSING", + UNKNOWN = "UNKNOWN", + NOTEXP = "NOTEXP", +} + +-- ------------------------------------------------------------------ +-- Tabs (order = display order). DEBUFFS' columns are built DYNAMICALLY +-- from the store (no data/slot table); every other tab has a static slot +-- table. DEBUFFS, RESIST and HIT are pure DISPLAY tabs -- no expectations, +-- no gaps, no sums, no whisper (see the slot tables below and +-- core/model.lua's per-tab branches). HIT sits between RESIST and +-- EQUIPMENT because it answers the same "how is this player set up" +-- question as the resistances, only for the attack side. +-- ------------------------------------------------------------------ +DC.TABS = { "CONSUMABLES", "CLASSBUFFS", "DEBUFFS", "RESIST", "HIT", "EQUIPMENT" } + +DC.TAB_LABEL = { + CONSUMABLES = "Consumables", + CLASSBUFFS = "Class buffs", + DEBUFFS = "Debuffs", + RESIST = "Resistances", + HIT = "Hit", + EQUIPMENT = "Equipment", +} + +-- ------------------------------------------------------------------ +-- RESIST tab slot table (static, unlike DEBUFFS' dynamic columns -- +-- resistances are always the same five schools). All columns are ALWAYS +-- active; expectations are never consulted on this tab (display only, see +-- core/model.lua). +-- +-- `school` is the UnitResistance() school index (0 = physical, 1 = holy -- +-- both never queried here; 2..6 below). +-- ------------------------------------------------------------------ +DC.SLOTS_RESIST = { + { id = "RFIRE", label = "FIR", sub = "fire", full = "Fire resistance", kind = "resist", school = 2 }, + { id = "RNAT", label = "NAT", sub = "nature", full = "Nature resistance", kind = "resist", school = 3 }, + { id = "RFROST", label = "FRO", sub = "frost", full = "Frost resistance", kind = "resist", school = 4 }, + { id = "RSHAD", label = "SHA", sub = "shadow", full = "Shadow resistance", kind = "resist", school = 5 }, + { id = "RARC", label = "ARC", sub = "arcane", full = "Arcane resistance", kind = "resist", school = 6 }, +} + +-- ------------------------------------------------------------------ +-- HIT tab slot table (static). Same display-only rules as RESIST: every +-- column is always active, expectations are never consulted, nothing is +-- ever MISSING and nothing is whispered -- a hit number below its cap is +-- information, not a gap to nag about. +-- +-- The numbers behind these columns are read from item TOOLTIPS (per-item +-- lines plus set bonuses, counted once per set), from hit-granting AURAS, +-- and from TALENTS -- the own character's via GetTalentInfo, everyone +-- else's via this server's inspect protocol (scan/talents.lua). There is +-- no hit API on this client at all -- see data/hit.lua and +-- data/talenthit.lua. `hitKind` selects which number a column shows: +-- "melee" -- generic physical hit vs the yellow (special attack) cap +-- "ranged" -- the same generic gear hit against the RANGED cap, and only +-- for a player who actually carries a ranged attack weapon +-- "spell" -- spell hit vs the spell cap +-- +-- WHY SIX SPELL COLUMNS INSTEAD OF ONE: spell hit is not one number. A +-- column's value is the GENERIC spell hit plus everything that only helps +-- THAT school -- school-specific item affixes and school-specific talents +-- (a mage's Arcane Focus, a priest's Shadow Focus, ...). Two casters of +-- the same class routinely differ per school, which is exactly what the +-- tab exists to show; folding them into one "spell hit" column would hide +-- the difference that decides whether a caster is capped. +-- +-- `school` on a spell column is the LOWERCASE school id, shared with the +-- talent tables (data/talenthit.lua). Gear-parsed school extras are keyed +-- by the CAPITALIZED tooltip spelling instead ("Fire"), because they come +-- straight out of the tooltip text -- DC.HIT_SCHOOL_KEY (data/hit.lua) +-- bridges the two and DC_Hit.SchoolHit does the lookup for callers. +-- ------------------------------------------------------------------ +DC.SLOTS_HIT = { + { id = "MHIT", label = "MHT", sub = "melee", full = "Melee hit", kind = "hit", hitKind = "melee" }, + { id = "RHIT", label = "RNG", sub = "ranged", full = "Ranged hit", kind = "hit", hitKind = "ranged" }, + { id = "HARC", label = "ARC", sub = "arcane", full = "Arcane hit", kind = "hit", hitKind = "spell", school = "arcane" }, + { id = "HFIR", label = "FIR", sub = "fire", full = "Fire hit", kind = "hit", hitKind = "spell", school = "fire" }, + { id = "HFRO", label = "FRO", sub = "frost", full = "Frost hit", kind = "hit", hitKind = "spell", school = "frost" }, + { id = "HHOL", label = "HOL", sub = "holy", full = "Holy hit", kind = "hit", hitKind = "spell", school = "holy" }, + { id = "HNAT", label = "NAT", sub = "nature", full = "Nature hit", kind = "hit", hitKind = "spell", school = "nature" }, + { id = "HSHA", label = "SHA", sub = "shadow", full = "Shadow hit", kind = "hit", hitKind = "spell", school = "shadow" }, +} + +-- ------------------------------------------------------------------ +-- Out-of-range threshold in yards: +-- a READABLE player with distance >= FAR_LIMIT gets rowVM.far = true and +-- counts into vm.coverage.far. Purely diagnostic -- far never changes any +-- cell state, sum, gap or pill (distance is never a filter). +-- ------------------------------------------------------------------ +DC.FAR_LIMIT = 40 + +-- ------------------------------------------------------------------ +-- Alternating-row wash ("zebra"): the alpha of DC.COLORS.zebra on every +-- SECOND data row of a table, so the eye can hold a line across a wide +-- grid. It has to stay well under the washes that carry MEANING -- the +-- unreadable-row wash sits at 0.25 -- or a striped row would read as a +-- state. One number, used by every table (matrix rows, both options +-- grids), so the whole tool stripes at the same strength. +-- ------------------------------------------------------------------ +DC.ZEBRA_ALPHA = 0.035 + +-- ------------------------------------------------------------------ +-- Color palette +-- Every entry is { r=<0..1>, g=<0..1>, b=<0..1> } (WoW SetTextColor / +-- SetVertexColor convention). Hex source noted per line. +-- ------------------------------------------------------------------ +local function hexrgb(hex) + return { + r = tonumber(string.sub(hex, 1, 2), 16) / 255, + g = tonumber(string.sub(hex, 3, 4), 16) / 255, + b = tonumber(string.sub(hex, 5, 6), 16) / 255, + } +end + +DC.COLORS = { + -- state colors (cell glyph / cell background / cell border) + hat = hexrgb("3fae5c"), -- HAS green + hatBg = hexrgb("152b1c"), + hatBorder = hexrgb("2b5e3a"), + fehlt = hexrgb("d05252"), -- MISSING red + fehltBg = hexrgb("341a1a"), + fehltBorder = hexrgb("6e3030"), + unbek = hexrgb("8b93a0"), -- UNKNOWN gray + unbekBg = hexrgb("22262e"), + unbekBorder = hexrgb("3b424e"), + skip = hexrgb("14181f"), -- not-expected cell bg + skipBorder = hexrgb("1d222b"), + skipGlyph = hexrgb("3a404c"), + + -- gold family + gold = hexrgb("8f7440"), -- hover border gold + goldHi = hexrgb("e6c87e"), -- title / highlight gold + goldSoft = hexrgb("54462a"), -- frame accent borders + goldDim = hexrgb("a98f57"), -- secondary gold, links + + -- panels / lines + panel = hexrgb("11141a"), -- frame bg + panel2 = hexrgb("171b23"), -- popover bg + panel3 = hexrgb("1d222c"), -- reserved + line = hexrgb("262c37"), -- standard borders + theadBg = hexrgb("141821"), -- sticky head/foot bg + void = hexrgb("0a0c10"), -- page background + -- alternating-row wash ("zebra"): the COLOR is plain white, the + -- subtlety lives entirely in DC.ZEBRA_ALPHA below -- a tinted stripe + -- would shift the hue of every cell it sits under. + zebra = hexrgb("ffffff"), + + -- text + ink = hexrgb("c9cdd4"), -- body text + dim = hexrgb("868d99"), -- secondary text + faint = hexrgb("5b6270"), -- tertiary / hints + parch = hexrgb("d6cdb8"), -- parchment text + parchDim = hexrgb("9d9482"), -- parchment dim (tooltip detail) + + -- role group colors + roleTANK = hexrgb("8fb3dd"), + roleMELEE = hexrgb("e2a75e"), + roleRANGED = hexrgb("7fcabe"), + roleCASTER = hexrgb("bd9ce8"), + roleHEALER = hexrgb("8fd6a0"), + + -- role badge border colors + roleBorderTANK = hexrgb("3c5877"), + roleBorderMELEE = hexrgb("7a5c33"), + roleBorderRANGED = hexrgb("3a6b63"), + roleBorderCASTER = hexrgb("5e4a80"), + roleBorderHEALER = hexrgb("3c6c4a"), + + -- class colors: OWN table -- RAID_CLASS_COLORS does NOT exist on 1.12. + -- Standard vanilla class colors, all 9 classes (the sim roster covers + -- all of them). + classWARRIOR = hexrgb("c79c6e"), + classPALADIN = hexrgb("f58cba"), + classHUNTER = hexrgb("abd473"), + classROGUE = hexrgb("fff569"), + classPRIEST = hexrgb("ffffff"), + classSHAMAN = hexrgb("0070de"), + classMAGE = hexrgb("69ccf0"), + classWARLOCK = hexrgb("9482c9"), + classDRUID = hexrgb("ff7d0a"), + + -- trinket tile quality accents (WoW rare/3 and epic/4) + rareText = hexrgb("4aa3ff"), + rareBorder = hexrgb("1d5a9e"), + epicText = hexrgb("b06cf0"), + epicBorder = hexrgb("6a3f8f"), + + -- green button / pill family + banners + btnGreenText = hexrgb("cfe0a8"), + btnGreenBg = hexrgb("26311e"), + btnGreenBorder = hexrgb("4c6236"), + bannerYellowBg = hexrgb("2b2413"), + bannerYellowBorder = hexrgb("6e5a26"), + bannerYellowText = hexrgb("e0c98a"), + bannerGreenBg = hexrgb("16240f"), +} + +-- ------------------------------------------------------------------ +-- Item quality colors, indices 0..6 as returned by GetItemQualityColor. +-- Own table so pure-Lua tests can run offline (GetItemQualityColor exists +-- in-game but not under lua50). Values = 1.12 FrameXML ITEM_QUALITY_COLORS. +-- ------------------------------------------------------------------ +DC.QUALITY = { + [0] = { r = 0.65, g = 0.65, b = 0.65 }, -- poor (gray) + [1] = { r = 1.00, g = 1.00, b = 1.00 }, -- common (white) + [2] = { r = 0.10, g = 1.00, b = 0.00 }, -- uncommon (green) + [3] = { r = 0.00, g = 0.44, b = 0.87 }, -- rare (blue) + [4] = { r = 0.64, g = 0.21, b = 0.93 }, -- epic (purple) + [5] = { r = 1.00, g = 0.50, b = 0.00 }, -- legendary (orange) + [6] = { r = 0.90, g = 0.80, b = 0.50 }, -- artifact +} diff --git a/core/init.lua b/core/init.lua new file mode 100644 index 0000000..79dfd37 --- /dev/null +++ b/core/init.lua @@ -0,0 +1,240 @@ +-- DopingControl core/init.lua +-- Bootstrap: ADDON_LOADED wiring (SavedVariables defaults), event +-- registration (roster changes, READY_CHECK), test-mode switch, slash +-- commands /dc + /doping. Loads LAST in the TOC -- every module global +-- (DC_Scan, DC_Matrix, DC_Options, DC_Report, DC_Sim, DC_Probe) exists by +-- the time any function here runs. +-- +-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then". +-- The pure parts (DC.HandleSlash, DC.SetTestMode, DC.DumpUnknown) are +-- offline-tested with stubbed modules. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- Store (the shared data shape every consumer reads). Empty until the +-- first scan finishes or test mode injects the simulated store. +DC.store = DC.store or { players = {}, source = "scan" } + +-- Unknown-aura tally, filled by scan/aura.lua (DC.unknownSeen[name] = count); +-- read here by /dc unknown (unknown buff names are never silently discarded). +DC.unknownSeen = DC.unknownSeen or {} + +local CHAT_PREFIX = "|cff3fae5cDopingControl|r: " + +function DC.Print(msg) + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage(CHAT_PREFIX .. tostring(msg)) + else + print(CHAT_PREFIX .. tostring(msg)) -- offline Lua 5.0 + end +end + +-- Called by scan/engine.lua whenever the store changed (scan finished). +function DC.OnStoreUpdated() + DC_Matrix.Refresh() +end + +-- Matrix visibility, used to decide whether a roster change should trigger +-- an immediate rescan. DC_Matrix.IsShown is not pinned in the plan -- if +-- ui/matrix.lua does not provide it, we conservatively treat the window as +-- hidden (next manual scan / ready check rescans anyway). +local function MatrixIsShown() + if DC_Matrix and DC_Matrix.IsShown then + return DC_Matrix.IsShown() + end + return nil +end + +-- ------------------------------------------------------------------ +-- Test mode: the simulator fully replaces the data source. +-- ------------------------------------------------------------------ +function DC.SetTestMode(v) + local db = DC.db or DopingControlDB + db.testMode = v + if v then + -- buildStore() itself resets and refills DC.simRoles with the + -- built-in confirmations (DC_Sim.ROLES, core/sim.lua) -- do NOT wipe + -- DC.simRoles afterwards, that would erase them and turn every row + -- into a suggestion badge (the sim roster deliberately contains + -- exactly two suggestion-badge cases). + DC.store = DC_Sim.buildStore() + else + DC.store = { players = {}, source = "scan" } + DC_Scan.Start(true) -- leaving test mode: immediate real rescan + end + DC_Matrix.Refresh() +end + +-- ------------------------------------------------------------------ +-- /dc unknown -- dump DC.unknownSeen via SuperWoW ExportFile. +-- ------------------------------------------------------------------ +function DC.DumpUnknown() + local names = {} + local distinct = 0 + for name in pairs(DC.unknownSeen) do + table.insert(names, name) + distinct = distinct + 1 + end + if distinct == 0 then + DC.Print("no unknown buffs recorded") + return + end + table.sort(names, function(a, b) + local ca, cb = DC.unknownSeen[a], DC.unknownSeen[b] + if ca ~= cb then return ca > cb end + return a < b + end) + local lines = { "DopingControl unknown buffs (" .. distinct .. " names)" } + for i = 1, table.getn(names) do + table.insert(lines, string.format("%dx %s", DC.unknownSeen[names[i]], names[i])) + end + local text = table.concat(lines, "\n") + if ExportFile then + -- SuperWoW appends ".txt" itself -- pass the name WITHOUT extension. + -- pcall: optional-DLL call -- an I/O error must not + -- escape into the slash handler. + local okE = pcall(ExportFile, "dc_unknown", text) + if okE then + DC.Print(distinct .. " unknown buff names dumped to dc_unknown.txt (WoW Imports folder)") + else + DC.Print("ExportFile failed - no dump written") + end + else + DC.Print("ExportFile unavailable (SuperWoW required) - no dump written") + end +end + +-- ------------------------------------------------------------------ +-- Slash commands: /dc, /doping +-- ------------------------------------------------------------------ +local HELP_TEXT = "commands: /dc (window) | scan | options | report | test | probe [range ] | unknown | talents [on|off|clear]" + +function DC.HandleSlash(msg) + msg = msg or "" + local _, _, word, rest = string.find(msg, "^%s*(%S*)%s*(.-)%s*$") + local cmd = string.lower(word or "") + local db = DC.db or DopingControlDB + + if cmd == "" then + DC_Matrix.Toggle() + elseif cmd == "scan" then + if db and db.testMode then + -- real data never beats the simulation while test mode is on + DC.Print("test mode is on - /dc test to switch back before scanning") + else + DC_Scan.Start() + end + elseif cmd == "options" then + DC_Options.Toggle() + elseif cmd == "report" then + DC_Report.Open() + elseif cmd == "test" then + DC.SetTestMode(not (db and db.testMode)) + if db and db.testMode then + DC.Print("test mode ON - simulated raid, chat output disabled") + else + DC.Print("test mode OFF - rescanning the real group") + end + elseif cmd == "probe" then + -- core/probe.lua owns the probe subcommand grammar: + -- DC_Probe.HandleSlash(rest) handles "" (battery), + -- "range " (1 Hz run) and the range-run stop toggle. + DC_Probe.HandleSlash(rest) + elseif cmd == "unknown" then + DC.DumpUnknown() + elseif cmd == "talents" then + -- foreign talents come in over this server's inspect protocol + -- (scan/talents.lua). "on"/"off" is a real switch, not cosmetic: + -- asking makes OTHER players' clients send their tree back, so it + -- must be possible to stop doing that. + if not DC_Talents then + DC.Print("talent module not loaded") + elseif rest == "on" or rest == "off" then + DopingControlDB.readTalents = (rest == "on") + DC.Print("talent reading " .. rest) + if rest == "off" and DC_Talents.Clear then + DC_Talents.Clear() + end + elseif rest == "clear" then + DC_Talents.Clear() + DC.Print("talent cache cleared - they will be asked again") + else + local s = DC_Talents.Stats() + DC.Print("talents: " .. s.known .. " known, " .. s.pending + .. " mid-reply, " .. s.queued .. " queued, " + .. (s.enabled and "ON" or "OFF") + .. " (/dc talents on|off|clear)") + end + else + DC.Print(HELP_TEXT) + end +end + +-- ------------------------------------------------------------------ +-- WoW wiring +-- ------------------------------------------------------------------ +if CreateFrame then + SLASH_DOPINGCONTROL1 = "/dc" + SLASH_DOPINGCONTROL2 = "/doping" + SlashCmdList["DOPINGCONTROL"] = DC.HandleSlash + + -- Matrix toolbar hooks (ui/matrix.lua fires MX.hooks.*): the Options + -- and Report buttons fall back to DC_Options/DC_Report directly, but + -- the Scan button ONLY fires MX.hooks.scan -- wire it here through the + -- slash path so it gets the same test-mode gate as "/dc scan". + if DC_Matrix and DC_Matrix.hooks then + DC_Matrix.hooks.scan = function() + DC.HandleSlash("scan") + end + end + + local ev = CreateFrame("Frame", "DopingControlInitEventFrame", UIParent) + ev:RegisterEvent("ADDON_LOADED") + ev:SetScript("OnEvent", function() + if event == "ADDON_LOADED" then + if arg1 ~= "DopingControl" then return end + ev:UnregisterEvent("ADDON_LOADED") + + -- SavedVariables are in place now. + DopingControlDB = DopingControlDB or {} + DC.EnsureDefaults(DopingControlDB) -- also deep-copies + -- DC.DEFAULT_EXPECT into db.expectations on first load + -- (core/config.lua handles that inside EnsureDefaults). + DC.db = DopingControlDB + + -- Roster changes invalidate the store; rescan only when the + -- window is up (otherwise the next scan re-reads the roster + -- anyway -- scan/engine.lua re-walks it on every Start()). + ev:RegisterEvent("RAID_ROSTER_UPDATE") + ev:RegisterEvent("PARTY_MEMBERS_CHANGED") + + -- READY_CHECK: registered defensively -- it is unverified + -- whether this event fires natively on 1.12/TurtleWoW, so a + -- rejecting client must not break the bootstrap. + pcall(ev.RegisterEvent, ev, "READY_CHECK") + + -- Persisted test mode survives reloads: restore the simulated + -- store so window content and db.testMode stay consistent. + if DC.db.testMode then + DC.store = DC_Sim.buildStore() + end + + elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then + local db = DC.db + if db and not db.testMode and MatrixIsShown() then + DC_Scan.Start() + end + + elseif event == "READY_CHECK" then + -- The auto-SCAN on ready check is owned by scan/engine.lua + -- (single owner) -- registering the Start + -- here too fired a duplicate full scan per ready check. This + -- handler only does the auto-OPEN half. + local db = DC.db + if db and db.readyCheckOpen then + DC_Matrix.Show() + end + end + end) +end diff --git a/core/model.lua b/core/model.lua new file mode 100644 index 0000000..40d4134 --- /dev/null +++ b/core/model.lua @@ -0,0 +1,1740 @@ +-- DopingControl core/model.lua +-- Pure view-model layer: classify cells, build the matrix view-model, +-- format whisper/report strings. NO WoW API, NO frames -- offline-testable +-- under real Lua 5.0. +-- +-- Covers: cell states, ready pill, chat formats (whisper/report separators +-- are pure ASCII: "-" and "|", no middle dots). +-- +-- Hard rules implemented here: +-- * UNKNOWN is NEVER condensed into MISSING -- not in cells, not in sums, +-- not in pills, not in footer, not in the report. +-- Concretely: UNKNOWN cells count neither as ready nor as gap; they are +-- excluded from BOTH sum numbers (sumHas/sumExpected count only +-- determinate = HAS/MISSING cells). +-- * Sums only for readable players (sumHas/sumExpected nil otherwise). +-- * Footer and report count only readable players. +-- * Report lines <= M.MAX_LINE (240) chars, overflow continues on +-- " ... continued:" lines (chat hard limit is 255). +-- * Whisper/report strings are pure ASCII. +-- * Aura identity: spell-ID before name (different buffs can collide on +-- one display name, e.g. "Shadow Protection" the buff vs. the potion +-- effect). When the scan delivered ANY aura IDs +-- for a player, a bare name match no longer counts for table entries +-- that have known IDs (the aura carrying that name would have matched +-- by ID if it really were that buff). Names stay authoritative for +-- entries without known IDs (e.g. "Well Fed") and for ID-less scans. +-- +-- Deliberate resolutions (edge cases decided here, documented so they +-- stay deliberate): +-- * ench + EMPTY slot: an item property, not an expectation -- +-- independent of the expectation checkbox. Slots that HAVE an +-- exemption table (OFFH/RNGD) treat an EMPTY slot as NOTEXP "no item" +-- (nothing to enchant; 2H users have no off hand, casters may have no +-- ranged weapon). EVERY OTHER empty ench slot -- armor AND the +-- enchantable neck/ring slots -- is ALWAYS a gap: MISSING with detail +-- " empty", regardless of whether the slot is expected. Empty +-- gear is a problem the expectation grid cannot suppress; the grid +-- only governs the ENCHANT question for a slot that has an item (see +-- the ench branch below). Trinket-kind tiles (T1/T2) mirror this: +-- EMPTY is always MISSING " empty"; the expectation parameter +-- is not even read for them. +-- * Gear entries may carry an optional subType field (itemSubType, +-- GetItemInfo return 6) -- required by the ENCH_EXEMPT check. Absent +-- subType = no exemption. +-- * Row "ready" = readable AND gaps==0 AND unknowns==0 ("verified +-- complete" -- an unverified (UNKNOWN) cell can never be +-- part of a green pill). +-- * Trinket HAS detail is the gear entry TABLE (quality/name/texture for +-- the tile + tooltip) and hit HAS detail is the breakdown table; all +-- other details are strings or nil. +-- * Footer name lists and the "Not readable" list are sorted +-- alphabetically (deterministic across scans). +-- +-- Cell shape: +-- aura/weapon cells: { state, detail, icon = texturePath|nil } +-- icon ONLY when state==HAS; aura kind = texture of the matched buff +-- name from player.auras.textures (optional store field -- nil-guarded, +-- absent/partial textures => icon nil); weapon kind = always nil for +-- now. For ID matches the matched name is idToName[id]; when that is +-- nil the icon stays nil. +-- ench/trinket cells: { state, detail, item = gearEntry|nil } +-- item = the gear entry TABLE BY REFERENCE (model is read-only over +-- the store, no copy) whenever the entry is a real item table -- HAS, +-- enchant missing, subtype-exempt NOTEXP, and (ench kind only) the +-- not-expected NOTEXP case (the item still rides along so the tile +-- keeps rendering -- see the ench branch below); nil for "EMPTY" and +-- unknown (gear is not consulted, or the slot never held an item). +-- resist cells: { state, detail, value = number|nil } -- RESIST tab +-- only (see below); value is the resistance total, set on HAS, nil on +-- UNKNOWN. +-- hit cells: { state, detail, value, cap, capMet } -- HIT tab only; +-- value/cap are percentages with one decimal (set on HAS, nil +-- otherwise), detail is the breakdown TABLE on HAS and a plain reason +-- string on NOTEXP/UNKNOWN. +-- classify() accordingly returns (state, detail, icon, item, value) -- +-- the trailing returns are nil for callers/kinds that do not use them. +-- `value` is used ONLY by kind=="resist" (build() copies it onto +-- cell.value); every other kind returns it as nil. +-- +-- DEBUFFS tab, class exemptions, out-of-range flag: +-- * DEBUFFS tab: vm.slots is built DYNAMICALLY from the store +-- -- one column per distinct debuff name across READABLE players, +-- sorted by afflicted count desc then alpha, capped at M.DEBUFF_CAP +-- (10) columns; slot entries { id=name, label=nil, full=name, +-- kind="debuff", icon=texture|nil }. Cell semantics: afflicted => +-- MISSING (detail "Afflicted: ", icon = debuff texture -- the +-- ONE kind whose icon rides on a non-HAS state); clean => HAS (icon +-- nil); unreadable (aurasRead false) => UNKNOWN. Zero debuffs is a +-- LEGITIMATE clean state -- an absent/empty player.debuffs on a +-- readable player is HAS, never a read failure. Expectations are NOT +-- consulted (every column applies to every role); whisperEligible is +-- ALWAYS false on DEBUFFS; ready pill formula unchanged (readable, 0 +-- gaps, 0 unknowns = "clean players"); footer/report machinery +-- unchanged (afflicted counts per debuff). More than DEBUFF_CAP +-- distinct debuffs: columns stay capped, vm.debuffMore carries the +-- overflow and reportLines appends "+N more debuff types". +-- * Class-based enchant exemption: DC.ENCH_CLASS_EXEMPT +-- [slotId][class] => NOTEXP (detail "no ranged enchant for this +-- class"), checked FIRST in the ench branch -- before gearRead/ +-- UNKNOWN, before EMPTY, before the expectation -- so it wins over +-- every other equipment outcome, readable or not. +-- * Out-of-range flag: rowVM.far = true iff readable and +-- player.distance and player.distance >= DC.FAR_LIMIT; +-- vm.coverage.far = count. Diagnostic ONLY -- far changes no state, +-- no sum, no pill. +-- +-- RESIST and HIT tabs (display only, no gaps): +-- * Two static-column tabs share ONE set of rules. vm.slots is +-- DC.SLOTS_RESIST (the five resistance schools) resp. DC.SLOTS_HIT +-- (the eight hit columns), always ALL of them, unconditionally -- +-- unlike the equipment/consumable tabs, nothing here is gated by +-- expectations, there are none to consult. +-- * classify() kind=="resist": not player.resistsRead => UNKNOWN (same +-- unreadableReason convention as every other kind); otherwise ALWAYS +-- HAS, value = player.resists[slotdef.school] or 0 (0 is a legitimate +-- resistance, not a read failure), detail = ": ". +-- NEVER MISSING, NEVER NOTEXP -- a display tab has no gaps. +-- * Row/group aggregates on BOTH tabs: sumHas/sumExpected stay nil +-- (mirrors DEBUFFS); whisperEligible is ALWAYS false; gaps stay 0 +-- (nothing can produce MISSING here), so the ready pill is vacuously +-- "all clean" and not rendered as a readiness claim by the UI. +-- * vm.colAvg = { [slotId] = the column's average over the players who +-- DELIVER that column } -- resistance columns average over +-- player.resistsRead == true, hit columns over the players whose hit +-- cell came out HAS. Computed straight off store.players (independent +-- of row.readable, which tracks aura/online status, not resistance or +-- hit readability). +-- * vm.colCount = { [slotId] = the DENOMINATOR that column's average +-- was divided by }. Anything that PRINTS a population (the report) +-- must take it from here: the ranged hit column drops every player +-- without a ranged weapon, so its denominator differs from any +-- channel-wide reader count. Those channel counts are +-- vm.resistReadable (resistsRead) and vm.hitReadable (hit.hitRead). +-- * vm.resistAvg / vm.resistCount are kept as ALIASES of colAvg/ +-- colCount (the same tables, not copies) so readers written when the +-- resistances were the only display tab keep working. +-- +-- Hit columns (HIT tab, kind=="hit"): +-- * DC.SLOTS_HIT carries EIGHT columns: melee and ranged hit +-- (slotdef.hitKind "melee"/"ranged"), then one column per magic +-- school (hitKind "spell" plus slotdef.school = "arcane" / "fire" / +-- "frost" / "holy" / "nature" / "shadow"). A school column shows the +-- generic spell hit PLUS that school's own bonuses -- which is the +-- entire point of the tab: two casters of the same class can sit at +-- different numbers per school. +-- * They ride on the SAME display-only rules as the resistances: +-- expectations are never consulted, row sums stay nil, no cell can +-- become a gap, no whisper. +-- * Readability is its own flag again: player.hit.hitRead. No hit table +-- or hitRead == false => UNKNOWN. There is no hit API on this client +-- (no GetHitModifier / GetSpellHitModifier / GetCombatRating), so the +-- numbers are summed from item tooltips by the scan -- an unreadable +-- unit yields no hit at all, never a 0. +-- * Otherwise HAS with cell.value = the hit percentage (one decimal), +-- cell.cap = the cap that applies to THIS player, cell.capMet = +-- value >= cap, and cell.detail = the breakdown TABLE below (the +-- second kind after trinket HAS whose detail is a table, not a +-- string). +-- * ONE exception to "readable => HAS": the ranged column is NOTEXP for +-- a player without a physical ranged weapon (bow/gun/crossbow/thrown) +-- -- a caster holding a wand has no ranged-hit question. NOTEXP needs +-- readable data, so an unreadable player stays UNKNOWN (unlike the +-- expectation kinds, where NOTEXP wins over UNKNOWN). +-- * Melee and ranged caps shrink with weapon skill (the linear scaling +-- of the current patch); the spell cap does not. The cap arithmetic +-- itself is NOT reimplemented here -- DC_Hit.MeleeCap/RangedCap +-- (scan/hit.lua, pure) own it, DC.HIT_CAPS (data/hit.lua) owns the +-- constants, and every call is existence-guarded: without that module +-- the cells show their numbers and make no cap claim. +-- * The weapon skill behind a melee/ranged cap is resolved HERE, per +-- build: the own character's MEASURED skill (hit.skillExact) wins, +-- everyone else gets DC_Hit.SkillFor's estimate = base + racial +-- specialization (player.race) + weapon-skill book. The book flags +-- are the build() `books` param (db.skillBooks), so ticking one in +-- the options changes the cap on the next REFRESH -- the scan +-- deliberately does not bake it in, and re-deriving the estimate here +-- is idempotent (a book is never added twice). Each column resolves +-- its OWN skill line: a bow has nothing to do with the measured +-- main-hand skill. +-- * There is no separate ranged-hit item stat in 1.12: the generic +-- "+N% chance to hit" line covers melee AND ranged, so both columns +-- read the same summed gear number and differ only in cap and weapon +-- skill. +-- * THREE contributions per column, kept apart end to end so the cell +-- tooltip can name each one instead of showing one unlabeled number: +-- gear hit.gearMelee / hit.gearSpell, summed from item tooltips +-- by scan/hit.lua. Falls back to the pre-split totals +-- hit.melee / hit.spell when the split fields are absent, +-- so a hand-built or older store still classifies right. +-- auras hit.auraMelee / auraRanged / auraSpell, folded on top by +-- scan/engine.lua's BuildPlayer from DC.HIT_AURAS. +-- hit.aurasRead == false means the aura contribution is +-- UNKNOWN, not a verified zero (the tooltip says so). +-- talents hit.talentMelee / talentRanged / talentSpell, plus +-- hit.talentSchools per school and hit.talentOffhand (the +-- warrior off-hand talent -- deliberately NOT part of the +-- melee number, it only rides in the tooltip). +-- hit.melee / hit.spell stay the TOTAL for backward compatibility. +-- * TALENTS ARE GATED BY hit.talentsRead, read STRICTLY (== true): +-- GetTalentInfo has no foreign-unit channel, so other players' ranks +-- arrive over this server's inspect protocol (scan/talents.lua) and +-- land in the same fields via the same DC_Hit.TalentHit. Anything but +-- true means no plausible reply has arrived YET (a "not yet", not an +-- "impossible"), so the number EXCLUDES talents and every tooltip +-- says exactly that. Reading the +-- flag strictly is what keeps the number and that sentence from ever +-- contradicting each other -- an absent flag never invents a bonus. +-- * Per-school value = generic spell hit (gear + aura + talent) + that +-- school's GEAR bonus (hit.schools, the school-only item lines) + +-- that school's TALENT bonus (hit.talentSchools). School keys arrive +-- in TWO spellings -- the gear table uses the DC.HIT_SCHOOLS display +-- names ("Frost"), slots and talents the lower-case token ("frost") -- +-- and are resolved case-insensitively with FIRST MATCH ONLY, never a +-- sum, so a table carrying both spellings cannot double-count. +-- * cell.detail (hit breakdown table): { kind, value, cap, capBase, +-- capMet, gear, aura, aurasRead, talent, talentsRead, talentOffhand, +-- generic, school, gearSchool, talentSchool, skill, skillBase, +-- skillExact, skillName, book, dualWield, capWhite, mainSub, offSub, +-- rangedSub, schools }. Everything the cell tooltip needs -- capBase +-- is the unadjusted cap and skillBase the unmodified weapon skill, so +-- the tooltip can show the derivation; the UI never recomputes hit +-- math. `generic` is the school-independent part of a spell column, +-- `schools` rides along on a school-LESS spell column only (on the +-- HIT tab every school has its own column, so listing the others in a +-- column's tooltip would be noise). The spell cap carries no +-- capUnverified flag any more: it is derived from the server's own +-- 99%-hit clamp (data/hit.lua DC.HIT_CAPS header), not a +-- quoted-but-unmeasured number. +-- * vm.colAvg holds one entry per hit column (average over the players +-- whose hit cell is HAS, one decimal -- a NOTEXP ranged cell is left +-- out instead of counted as 0) and vm.hitReadable is the channel's +-- reader count. +-- +-- Weapon temp-enchant name: +-- * weaponMainName: weapon-kind HAS detail = player.weaponMainName (the +-- temp-enchant NAME the scan delivered) when set, else the generic +-- "main hand temp enchant". States are untouched -- the name never +-- influences HAS/MISSING/UNKNOWN (that stays weaponMain's job). +-- +-- Last-known display layer: +-- THE IRON RULE: last-known data is DISPLAY-ONLY. It never counts as +-- HAS/MISSING in gaps, ready pills, sums, footer or report -- those keep +-- treating the cell as UNKNOWN. A flask seen 20 minutes ago is not +-- verified preparation. +-- * build() gains an OPTIONAL 5th param `now` (number|nil; nil => no +-- ages computed). Matrix passes GetTime(); report/offline callers may +-- omit it. +-- * For every cell whose state is UNKNOWN, when the player carries +-- matching last* data (p.lastAuras/p.lastGear/p.lastWeaponMain -- +-- attached by scan/engine.lua from the runtime cache, or by core/sim), +-- THAT data is classified with the SAME classify() rules via a light +-- synthetic player view (no duplicated state machine) and the result +-- rides as cell.last = { state, detail, icon, item, age } (age = +-- now - when both known). last.state can be HAS or MISSING -- +-- purely informational; a cached UNKNOWN/NOTEXP outcome attaches +-- nothing (the plain gray "?" stays). Debuff cells have no last-known +-- channel. +-- * rowVM.gearUnreadable = (tab=="EQUIPMENT" and readable and not +-- player.gearRead) -- the cross-continent inspect-limit case. +-- rowVM.unreadNote = "offline" for offline rows, an "items not +-- readable" observation for +-- gearUnreadable rows, nil otherwise. +-- +-- Per-class expectations + DEBUFFS warning semantics: +-- * Expectations are PER (role, class): expect[role][class][slotId]. +-- build() resolves each row's line via (row role, player.class); an +-- UNKNOWN combination -- role or class line missing -- falls back to +-- the DC.DEFAULT_EXPECT role line. classify()'s +-- signature is unchanged: the CALLER resolves `expected`. +-- Dynamic columns: a slot column is active when ANY (role, +-- performing class) resolves to expected -- the domain is +-- DC_Roles.ROLE_CLASSES, and uncovered combos contribute their +-- fallback line. Corollary: a legacy role x slot table passed as +-- `expect` behaves exactly like before (every combo falls back to +-- its role line). +-- * DEBUFFS = WARNING SIGNAL, not expectation fulfillment: +-- rowVM.sumHas/sumExpected stay NIL on DEBUFFS (no x/y); instead +-- rowVM.debuffCount = TOTAL count of distinct debuff names on the +-- player (player.debuffs.names -- INCLUDING names beyond the +-- DEBUFF_CAP columns), nil when the row is not readable; and +-- group.debuffTotal = sum of debuffCount over the group's readable +-- rows. Gap semantics stay unchanged INTERNALLY: afflicted cells +-- keep driving row sort weight, footer, report and the ready pill. +-- (debuffCount/debuffTotal are computed on every tab -- they are +-- player properties -- the UI reads them on DEBUFFS.) +-- * reportLines section header: the DEBUFFS tab says +-- "Debuffs:", every other tab keeps "Missing per slot:". + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Model = DC_Model or {} +local M = DC_Model + +-- Max report line length; the client chat limit is 255, we stay below. +M.MAX_LINE = 240 + +-- Max dynamic debuff columns on the DEBUFFS tab. +M.DEBUFF_CAP = 10 + +-- ================================================================== +-- Hit columns (HIT tab). +-- The hit ARITHMETIC lives in scan/hit.lua (DC_Hit.SkillFor / MeleeCap / +-- RangedCap / TalentHit, all pure) and the constants in data/hit.lua and +-- data/talenthit.lua -- this layer only resolves which number a cell +-- shows and whether the cap is met, so there is exactly ONE +-- implementation of the cap math in the addon. +-- Every DC_Hit call is existence-guarded: without that module the cells +-- still show their percentages, they just make no cap claim (a degraded +-- claim is worse than none). Without data/hit.lua the ranged column +-- cannot tell a bow from a wand either and stays NOTEXP throughout -- +-- again silence rather than a guess. +-- ================================================================== + +-- Percentages are carried with ONE decimal everywhere (cells, caps, +-- averages, report) -- rounding once keeps display and capMet comparison +-- consistent (a 7.96 that renders as "8.0" must also count as capped). +-- One-decimal text for a hit percentage, without the sign or unit -- +-- shared by the whisper text so a whispered number reads exactly like the +-- one in the cell the player is being told about. +-- ------------------------------------------------------------------ +-- Which hit columns a player is actually asked about. +-- +-- Every player HAS all eight hit columns -- a warrior's shadow hit is a +-- real number, it is simply 0. Telling that warrior "shadow 0.0/16.0" is +-- noise, so the whisper (and the below-cap counter that offers it) only +-- looks at the columns that match the ROLE's attack kind. Heals cannot +-- miss, so healers are asked about nothing at all. +-- +-- This governs WHISPERING only. Every column stays visible and keeps its +-- own cap marking on the tab -- the display never hides a number because +-- a role "should not care" about it. +-- ------------------------------------------------------------------ +M.HIT_ROLE_KINDS = { + TANK = { melee = true }, + MELEE = { melee = true }, + RANGED = { melee = true, ranged = true }, + CASTER = { spell = true }, + HEALER = {}, +} + +function M.HitRelevant(role, slotdef) + local kinds = role and M.HIT_ROLE_KINDS[role] + if not kinds or not slotdef then + return false + end + return kinds[slotdef.hitKind] == true +end + +function M.HitNum(v) + return string.format("%.1f", M.RoundTenth(v or 0)) +end + +function M.RoundTenth(v) + if type(v) ~= "number" then + return nil + end + return math.floor(v * 10 + 0.5) / 10 +end + +-- Display text for a hit percentage: always one decimal ("8.0", "12.5"), +-- so a column of hit numbers stays visually aligned. The UI and +-- reportLines both go through this. +function M.HitText(v) + if type(v) ~= "number" then + return "?" + end + return string.format("%.1f", v) +end + +-- One talent contribution, gated by the read flag. An unread talent tree +-- contributes NOTHING -- never a guess -- which is exactly what keeps the +-- displayed number and the tooltip's "excludes talents" sentence from +-- contradicting each other. +local function talentValue(v, talentsRead) + if not talentsRead then + return 0 + end + return tonumber(v) or 0 +end + +-- One school's bonus out of ONE source table. +-- The same school reaches this layer in two spellings, because two +-- sources do: gear extras are keyed by the capitalized tooltip word +-- ("Frost"), slot definitions and talent tables by the lower-case id +-- ("frost"). DC.HIT_SCHOOL_KEY (data/hit.lua) is the bridge and is +-- consulted first; the case-insensitive fallbacks below keep this pure +-- layer working when that table is absent (offline tests, partial load). +-- The FIRST match wins -- never a sum -- so a table that happens to carry +-- both spellings can not double-count. Missing table, missing key or a +-- non-numeric value all read as 0. +-- Why not DC_Hit.SchoolHit (scan/hit.lua): that helper answers "what is +-- this school worth in total", while the cell tooltip has to name each +-- SOURCE apart (gear vs talents), so the two amounts are looked up +-- separately here and only summed for display. +local function schoolAmount(tbl, school) + if type(tbl) ~= "table" or type(school) ~= "string" or school == "" then + return 0 + end + local v = tbl[school] + if type(v) == "number" then + return v + end + local lower = string.lower(school) + local bridged = DC.HIT_SCHOOL_KEY and DC.HIT_SCHOOL_KEY[lower] + if bridged then + v = tbl[bridged] + if type(v) == "number" then + return v + end + end + v = tbl[lower] + if type(v) == "number" then + return v + end + v = tbl[string.upper(string.sub(school, 1, 1)) + .. string.lower(string.sub(school, 2))] + if type(v) == "number" then + return v + end + for k, n in pairs(tbl) do + if type(k) == "string" and type(n) == "number" + and string.lower(k) == lower then + return n + end + end + return 0 +end + +-- Effective weapon skill for a hit column, resolved at DISPLAY time: +-- * a MEASURED skill wins (only the own character has one -- scan/hit +-- .lua reads it from the skill window and sets the exact flag), +-- * everyone else gets DC_Hit.SkillFor's estimate = base + racial +-- specialization + weapon-skill book. +-- Resolving here rather than trusting a value the scan wrote is +-- deliberate: the book flags are user input, so a checkbox click must +-- take effect on the next refresh, not on the next scan. Re-deriving the +-- estimate is also idempotent -- it never adds a book twice, whatever the +-- scan already filled in. +-- Each column passes its OWN measured triple (the main hand's skill has +-- no bearing on a bow) and its own subtype. +-- Returns skill, exact(bool), skillName -- all nil/false when the subtype +-- is no weapon at all (nothing equipped, shield, relic): no skill claim. +-- `books` is ONE player's flag table (db.skillBooks[playerName]). +local function effectiveSkill(player, subType, books, measured, measuredExact, + measuredName) + if measuredExact and measured then + return measured, true, measuredName + end + if DC_Hit and DC_Hit.SkillFor then + local skill, skillName = DC_Hit.SkillFor(subType, player.race, books) + if skill then + return skill, false, skillName + end + end + return nil, false, nil +end + +-- ================================================================== +-- Data indices, built lazily from DC.CONSUMABLES / DC.CLASSBUFFS +-- (data/*.lua load before first use; call M.RebuildIndex() after +-- mutating the data tables, e.g. in tests). +-- ================================================================== +local indexBuilt = false +local nameHasIds = {} -- [buffName] = true when the table entry has known spell IDs +local idToName = {} -- [spellID] = buffName (for HAS detail on ID match) + +function M.RebuildIndex() + nameHasIds = {} + idToName = {} + if DC.CONSUMABLES then + for name, def in pairs(DC.CONSUMABLES) do + if def.spellID and def.spellID > 0 then + nameHasIds[name] = true + idToName[def.spellID] = name + end + end + end + if DC.CLASSBUFFS then + for name, def in pairs(DC.CLASSBUFFS) do + if def.ids and table.getn(def.ids) > 0 then + nameHasIds[name] = true + local n = table.getn(def.ids) + for i = 1, n do + idToName[def.ids[i]] = name + end + end + end + end + indexBuilt = true +end + +local function ensureIndex() + if not indexBuilt then + M.RebuildIndex() + end +end + +local function nameMapsToSlot(name, slotId) + local c = DC.CONSUMABLES and DC.CONSUMABLES[name] + if c and c.slot == slotId then + return true + end + local b = DC.CLASSBUFFS and DC.CLASSBUFFS[name] + if b and b.slot == slotId then + return true + end + return false +end + +-- ================================================================== +-- classify(player, slotdef, expected, books) +-- -> state, detail, icon, item, value +-- State machine per slotdef.kind (icon/item/value extra returns per the +-- cell shape documented in the header). +-- `books` is ONE player's weapon-skill-book flags +-- (db.skillBooks[playerName]) and is read by the hit kind ONLY -- every +-- other kind ignores it, so three-argument callers keep working. +-- NOTEXP takes precedence over UNKNOWN (an unexpected slot is "not +-- expected" even for an unreadable player) for the KINDS that consult +-- `expected` at all -- debuff/resist ignore it outright (see their +-- branches), and ench/trinket consult it LATER than every other kind +-- (see the ench/trinket branch below: gear facts -- readability, class +-- exemption, EMPTY -- are resolved FIRST, `expected` only ever decides +-- the narrower "is this item enchanted" question). +-- ================================================================== +function M.classify(player, slotdef, expected, books) + local S = DC.STATE + local kind = slotdef.kind + + if kind == "debuff" then + -- Expectations are NOT consulted on debuff columns + -- (every column applies to every role) -- the `expected` param + -- is deliberately ignored, so this branch sits BEFORE the + -- expected-check. Readability = the existing aurasRead flag. + if not player.aurasRead then + return S.UNKNOWN, player.unreadableReason or "no data" + end + local d = player.debuffs + if d and d.names and d.names[slotdef.id] then + -- afflicted: icon = debuff texture (player's own entry wins, + -- column icon as fallback); the ONE non-HAS icon case + local icon = slotdef.icon + if d.textures and d.textures[slotdef.id] then + icon = d.textures[slotdef.id] + end + return S.MISSING, "Afflicted: " .. slotdef.full, icon + end + -- zero debuffs / absent debuffs field on a readable player is a + -- LEGITIMATE clean state -- never read failure + return S.HAS, nil + end + + if kind == "resist" then + -- RESIST tab: display only, expectations are NEVER consulted (the + -- `expected` param is deliberately ignored, like debuff above). + -- Readability rides on its OWN flag (player.resistsRead) -- fully + -- independent of aurasRead/gearRead, since a foreign + -- UnitResistance read is neither an aura nor a gear read. + if not player.resistsRead then + return S.UNKNOWN, player.unreadableReason or "no data" + end + local v = (player.resists and player.resists[slotdef.school]) or 0 + -- NEVER MISSING, NEVER NOTEXP -- a display tab has no gaps; the + -- resistance total rides as the 5th return (value), copied onto + -- cell.value by build() (see the cell shape doc in the header). + -- + -- Provenance suffix (binding honesty rule, see scan/engine.lua's + -- AssembleResists header): a RECONSTRUCTED number is not the same + -- kind of fact as a MEASURED one. player.resistsSource == "gear" + -- means this total came from item tooltips + the racial bonus, not + -- from UnitResistance/GetUnitField -- the detail string says so + -- rather than presenting a rebuilt number as an unqualified + -- reading. Every other source ("token"/"guid"/"field", or nil for + -- the always-measured own character) prints unchanged. + local detail = slotdef.full .. ": " .. v + if player.resistsSource == "gear" then + detail = detail .. " (from gear)" + end + return S.HAS, detail, nil, nil, v + end + + if kind == "hit" then + -- HIT tab, display only: expectations are NEVER consulted (the + -- `expected` param is ignored, like debuff/resist above), and no + -- outcome can ever be MISSING -- a hit number below its cap is + -- information, not a gap the addon nags about. + local h = player.hit + if not (h and h.hitRead) then + -- no hit API exists on this client; without readable item + -- tooltips there is no number at all (never a 0) + return S.UNKNOWN, player.unreadableReason or "no data" + end + local caps = DC.HIT_CAPS or {} + local hitKind = slotdef.hitKind or "melee" + -- Talents: readable for the OWN character only, so the flag is + -- read strictly (see the header). Anything but true keeps every + -- talent contribution at 0 AND makes the tooltip say the number + -- excludes talents -- one flag drives both. + local talentsRead = (h.talentsRead == true) + local b = { + kind = hitKind, + skillBase = DC.BASE_WEAPON_SKILL or 300, + mainSub = h.mainSub, + offSub = h.offSub, + -- hit-granting auras (DC.HIT_AURAS, data/hit.lua) are folded + -- into hit.gearX/hit.auraX at scan time (scan/engine.lua + -- BuildPlayer) -- b.aura carries just the AURA portion so the + -- tooltip can show "from gear" and "from auras" apart. A + -- missing aurasRead flag (older/hand-built stores) reads as + -- true: the aura contribution is a known zero, not unknown. + aurasRead = (h.aurasRead ~= false), + talentsRead = talentsRead, + } + if hitKind == "spell" then + b.gear = h.gearSpell or h.spell or 0 + -- SET bonuses are their own addend: scan/engine.lua already + -- took them OUT of gearSpell, so adding them here counts them + -- exactly once and lets the tooltip list them on their own line + b.set = h.setSpell or 0 + b.aura = h.auraSpell or 0 + b.talent = talentValue(h.talentSpell, talentsRead) + -- the school-INDEPENDENT part: what every school column has in + -- common, printed as its own tooltip line there + b.generic = M.RoundTenth(b.gear + b.set + b.aura + b.talent) + -- spell hit knows no weapon skill: base cap, unadjusted. The + -- cap itself is DERIVED from the server's 99% hit clamp (data/ + -- hit.lua DC.HIT_CAPS header), not a measured value, but that + -- is no longer flagged as "unverified" -- see the header note. + b.capBase = M.RoundTenth(caps.spell) + b.cap = b.capBase + if slotdef.school then + -- School column: generic spell hit + this school's gear + -- bonus + this school's talent bonus. That sum is the + -- whole reason the tab exists -- two casters of one class + -- differ per school. b.schools stays nil here: every + -- other school has its own column. + b.school = slotdef.school + -- h.schools is the TOTAL per school (items + set bonuses), + -- so the set part is split out here too -- otherwise a + -- school-specific set bonus would land in both lines + b.setSchool = schoolAmount(h.setSchools, slotdef.school) + b.gearSchool = schoolAmount(h.schools, slotdef.school) + - b.setSchool + b.talentSchool = 0 + if talentsRead then + b.talentSchool = schoolAmount(h.talentSchools, + slotdef.school) + end + b.value = M.RoundTenth(b.generic + b.gearSchool + + b.setSchool + b.talentSchool) + else + -- Generic spell column (no school on the slot): a + -- school-specific bonus counts against that school ONLY, + -- so it never enters the value; the whole table rides + -- along for the tooltip (by reference, the model never + -- copies store tables). + b.value = b.generic + b.schools = h.schools + end + else + -- one gear number for melee AND ranged: this client has no + -- separate ranged-hit item stat + b.gear = h.gearMelee or h.melee or 0 + b.set = h.setMelee or 0 + if hitKind == "ranged" then + local ranged = DC.RANGED_ATTACK_SUBTYPE or {} + if not (h.rangedSub and ranged[h.rangedSub]) then + -- Readable, but there is no ranged-hit question to + -- ask. The two causes get their OWN reason, because + -- the wand sentence is a non-sequitur for a player + -- who simply carries nothing there -- and this string + -- is what the cell tooltip prints verbatim. + if h.rangedSub then + return S.NOTEXP, + "a wand or relic is not a ranged attack weapon" + end + return S.NOTEXP, "nothing in the ranged slot" + end + b.aura = h.auraRanged or 0 + b.talent = talentValue(h.talentRanged, talentsRead) + b.value = M.RoundTenth(b.gear + b.set + b.aura + b.talent) + b.rangedSub = h.rangedSub + -- the ranged skill is its OWN skill line (bows/guns/...), + -- so the measured MAIN-HAND skill must not leak into it + b.skill, b.skillExact, b.skillName = + effectiveSkill(player, h.rangedSub, books, + h.rangedSkill, h.rangedSkillExact, nil) + b.capBase = M.RoundTenth(caps.ranged) + if DC_Hit and DC_Hit.RangedCap then + local rc = DC_Hit.RangedCap(h.rangedSub, b.skill, caps) + if rc then + b.cap = rc.cap + b.skillName = b.skillName or rc.skillName + end + end + else + b.aura = h.auraMelee or 0 + b.talent = talentValue(h.talentMelee, talentsRead) + -- Off-hand hit talent (the warrior dual-wield talent): + -- it raises OFF-HAND swings only, so it is deliberately + -- NOT added to the melee number -- the tooltip mentions + -- it while the player actually holds two weapons. + b.talentOffhand = talentValue(h.talentOffhand, talentsRead) + b.value = M.RoundTenth(b.gear + b.set + b.aura + b.talent) + b.skill, b.skillExact, b.skillName = + effectiveSkill(player, h.mainSub, books, + h.skill, h.skillExact, h.skillName) + b.skillName = b.skillName or h.skillName + b.capBase = M.RoundTenth(caps.meleeYellow) + if DC_Hit and DC_Hit.MeleeCap then + local mc = DC_Hit.MeleeCap(h.mainSub, h.offSub, + b.skill, caps) + b.cap = mc.yellow + b.dualWield = mc.dualWield + if mc.dualWield then + -- white swings keep their own, much higher cap. + -- Stated at its BASE too (see the skill note + -- below): the boss's floor does not move. + b.capWhite = M.RoundTenth(caps.meleeWhiteDW) + end + b.skillName = b.skillName or mc.skillName + end + end + -- did a ticked book go into this column's skill? (a MEASURED + -- skill already contains it, the tooltip says so instead) + b.book = (books and b.skillName and books[b.skillName]) + and true or false + end + -- WEAPON SKILL counts on the PLAYER's side of the comparison, not + -- as a discount on the boss's requirement. The two readings are + -- arithmetically identical -- + -- value >= cap - d <=> value + d >= cap + -- -- but a +3-level boss's miss floor is a property of the BOSS and + -- does not move because the attacker trained a weapon; skill is + -- something the player brings, exactly like gear or a talent. So + -- the skill-derived part becomes an addend of the value and the cap + -- is reported at its base. b.skillHit carries it so the tooltip can + -- show it as its own row of the addition. + if b.cap and b.capBase then + b.skillHit = M.RoundTenth(b.capBase - b.cap) + if b.skillHit ~= 0 then + b.value = M.RoundTenth(b.value + b.skillHit) + end + b.cap = b.capBase + else + b.skillHit = 0 + end + if b.cap == nil then + -- no cap claim without the hit module (see the section + -- header) -- the number itself is still shown + b.capMet = false + else + b.capMet = b.value >= b.cap + end + return S.HAS, b, nil, nil, b.value + end + + if kind == "ench" or kind == "trinket" then + -- Equipment: gaps are shaped by what is EQUIPPED, not by the + -- expectation grid. Order: class exemption (ench only, wins over + -- everything -- e.g. a shaman never has a ranged enchant slot, + -- readable or not), then gearRead/UNKNOWN, then whether the slot + -- is EMPTY (always MISSING except the legitimately-empty + -- exemptable case -- see the header). `expected` is consulted + -- LAST and ONLY for the ench-kind "is this item enchanted" + -- question; trinket-kind tiles never read `expected` at all -- + -- an equipped item is HAS, an empty tile is MISSING, always. + if kind == "ench" then + local classExempt = DC.ENCH_CLASS_EXEMPT + and DC.ENCH_CLASS_EXEMPT[slotdef.id] + if classExempt and player.class and classExempt[player.class] then + return S.NOTEXP, "no ranged enchant for this class" + end + end + if not player.gearRead then + return S.UNKNOWN, player.unreadableReason or "no data" + end + local entry = player.gear and player.gear[slotdef.invSlot] + if entry == nil then + return S.UNKNOWN, "no data" + end + local exempt = DC.ENCH_EXEMPT and DC.ENCH_EXEMPT[slotdef.id] + if entry == "EMPTY" then + if kind == "ench" and exempt then + -- exemptable slot (off hand / ranged) with no item: + -- nothing to enchant, and the item itself would have been + -- exempt anyway -- legitimately empty, not a gap + return S.NOTEXP, "no item" + end + -- every other empty slot -- armor, the enchantable neck/ring + -- slots, and the trinket tiles -- is ALWAYS a gap, no matter + -- what the expectation grid says (see the header) + return S.MISSING, slotdef.full .. " empty" + end + -- entry is a table from here on -> it rides along as cell.item + -- (by reference; the model never copies gear entries) + if kind == "trinket" then + -- tiles ignore `expected` entirely: an equipped item is HAS + return S.HAS, entry, nil, entry + end + if exempt and entry.subType and exempt[entry.subType] then + return S.NOTEXP, entry.subType .. " - not enchantable", nil, entry + end + if not expected then + -- enchant not expected for this role/class: the item is real + -- (not empty), so the tile still renders -- NOTEXP carries + -- the item, counts nowhere, no green/red marker (ui/matrix.lua) + return S.NOTEXP, nil, nil, entry + end + if entry.enchant and entry.enchant > 0 then + return S.HAS, entry.name, nil, entry + end + return S.MISSING, "no enchant", nil, entry + end + + if not expected then + return S.NOTEXP, nil + end + + if kind == "aura" then + if not player.aurasRead then + return S.UNKNOWN, player.unreadableReason or "no data" + end + ensureIndex() + local auras = player.auras or {} + local ids = auras.ids or {} + local names = auras.names or {} + local textures = auras.textures -- optional store field, nil-safe + local spellToSlot = DC.SPELL_TO_SLOT or {} + -- ID pass: spell ID is the primary identity + for id in pairs(ids) do + if spellToSlot[id] == slotdef.id then + local nm = idToName[id] + if nm then + local icon = nil + if textures then + icon = textures[nm] + end + return S.HAS, nm .. " (spell " .. id .. ")", icon + end + -- no name carried the id -> icon stays nil (by design) + return S.HAS, "spell " .. id + end + end + -- Name fallback: when IDs were readable, only names WITHOUT known + -- IDs still count (name-collision rule: the Shadow Protection + -- potion aura must not satisfy the SPROT class-buff slot). + local haveIds = (next(ids) ~= nil) + for name in pairs(names) do + if nameMapsToSlot(name, slotdef.id) then + if not (haveIds and nameHasIds[name]) then + local icon = nil + if textures then + icon = textures[name] + end + return S.HAS, name, icon + end + end + end + return S.MISSING, nil + + elseif kind == "weapon" then + if player.weaponMain == nil then + return S.UNKNOWN, player.unreadableReason or "no data" + end + if player.weaponMain then + -- enchant name when the scan delivered one + if player.weaponMainName then + return S.HAS, player.weaponMainName + end + return S.HAS, "main hand temp enchant" + end + return S.MISSING, nil + end + + return S.UNKNOWN, "unknown slot kind" +end + +-- ================================================================== +-- Gap label for chat output (whisper + report): +-- consumables/class buffs -> full slot name; enchant -> "Enchant "; +-- trinket -> "Trinket N empty". +-- ================================================================== +function M.gapLabel(slotdef) + if slotdef.kind == "ench" then + return "Enchant " .. slotdef.full + elseif slotdef.kind == "trinket" then + return slotdef.full .. " empty" + end + return slotdef.full +end + +-- ================================================================== +-- build(store, roleOf, expect, tab, now, books) -> vm +-- roleOf(playerEntry) -> role, suggested(bool), reason(string|nil) +-- expect = per-class expectation table from db: +-- expect[role][class][slotId] = true|nil; unknown (role, +-- class) combos fall back to the DC.DEFAULT_EXPECT role line +-- tab = "CONSUMABLES" | "CLASSBUFFS" | "DEBUFFS" | "RESIST" | +-- "HIT" | "EQUIPMENT" +-- now = optional clock (GetTime seconds) for cell.last.age -- nil +-- => no ages computed +-- books = optional db.skillBooks (weapon-skill-book flags per player +-- NAME, then per weapon skill). Read by the hit columns only; +-- nil => nobody owns a book. Passed in rather than read from a +-- global so the model stays offline-testable, and resolved on +-- every build so a checkbox click shows up on the next refresh +-- instead of the next scan. +-- vm.unassigned = { count = n, names = sorted array } -- players whose +-- role is only a heuristic suggestion (suggested==true), i.e. no +-- confirmed assignment. Counts ALL players, readable or not: role +-- assignment is independent of readability. +-- ================================================================== +local function tabSlots(tab) + if tab == "CLASSBUFFS" then + return DC.SLOTS_CLASSBUFFS + elseif tab == "EQUIPMENT" then + return DC.SLOTS_EQUIPMENT + end + return DC.SLOTS_CONSUMABLES +end + +-- DISPLAY tabs: static columns, no expectations, no gaps, no sums, no +-- whisper, a per-column average instead of a missing count. Named once so +-- the four places that implement those rules cannot drift apart when a +-- third display tab appears. +function M.IsDisplayTab(tab) + return tab == "RESIST" or tab == "HIT" +end + +-- ================================================================== +-- ChannelRead(player, tab) -> bool +-- Did the read channel THIS TAB DRAWS FROM succeed for this player? +-- Row readability (and every cell state) rides on the aura channel, +-- which is raid-wide; gear and hit are near-range only, and foreign +-- resistances are not transmitted at all, so one global count +-- over-reports on every tab that is not aura-backed. Measured in a +-- 26-man raid: auras 26/26, gear 15/26, resistances 1/26 while the +-- pill said 26/26 everywhere. +-- The offline gate comes first on purpose: a player who dropped out +-- keeps whatever flags the last successful scan left behind, and those +-- must not re-inflate the number that exists to stay honest. +-- Deliberately NOT wired into row.readable -- that flag decides cell +-- states and sums, and turning it per-tab would silently rewrite what +-- UNKNOWN means on three tabs. This is the reporting number only. +-- ================================================================== +function M.ChannelRead(p, tab) + if p.online == false then + return false + end + if tab == "RESIST" then + return p.resistsRead and true or false + elseif tab == "HIT" then + return (p.hit and p.hit.hitRead) and true or false + elseif tab == "EQUIPMENT" then + return p.gearRead and true or false + end + return p.aurasRead and true or false +end + +-- ================================================================== +-- CoverageLabel(tab) -> string +-- The noun behind the coverage number. "readable" used to sit on the +-- pill AND (as "AVERAGE (READABLE)") in the footer while meaning two +-- different populations; naming the channel on the pill removes the +-- collision instead of papering over it, and says in-game which read +-- the number is about. Plain ASCII -- the 1.12 chat/UI font. +-- ================================================================== +function M.CoverageLabel(tab) + if tab == "RESIST" then + return "resist read" + elseif tab == "HIT" then + return "hit read" + elseif tab == "EQUIPMENT" then + return "gear read" + end + return "readable" +end + +-- ------------------------------------------------------------------ +-- Per-(role,class) expectation resolution. +-- expect[role][class] is the explicit line; a missing role or class +-- line falls back to the DC.DEFAULT_EXPECT role line, so +-- saved tables that predate a class/role addition keep sane behavior. +-- classify() stays untouched: these helpers run in the CALLER. +-- ------------------------------------------------------------------ +local EMPTY_LINE = {} + +local function expectLine(expect, role, class) + local byClass = expect and expect[role] + if byClass and class then + local line = byClass[class] + if type(line) == "table" then + return line + end + end + local def = DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role] + return def or EMPTY_LINE +end + +-- A slot column is active when ANY (role, performing class) resolves to +-- expected. The domain is role x DC_Roles.ROLE_CLASSES +-- [role] -- resolved with the same fallback as row cells, so uncovered +-- combos keep their default columns visible. +local function anyClassExpects(expect, slotId) + local nRoles = table.getn(DC.ROLES) + for r = 1, nRoles do + local role = DC.ROLES[r] + local classes = DC_Roles and DC_Roles.ROLE_CLASSES + and DC_Roles.ROLE_CLASSES[role] + if classes then + for c = 1, table.getn(classes) do + if expectLine(expect, role, classes[c])[slotId] then + return true + end + end + else + -- defensive: without DC_Roles fall back to the role line + if expectLine(expect, role, nil)[slotId] then + return true + end + end + end + return false +end + +-- Dynamic DEBUFFS columns: one column per distinct +-- debuff name across READABLE players (same readability rule as the row +-- loop), sorted by afflicted count desc then alpha, capped at +-- M.DEBUFF_CAP. Returns slots array + overflow count (distinct beyond +-- the cap; the report appends "+N more debuff types" from it). +local function buildDebuffSlots(store) + local counts = {} + local icons = {} + local names = {} + local players = (store and store.players) or {} + local nPlayers = table.getn(players) + for i = 1, nPlayers do + local p = players[i] + -- readability rule: offline / aurasRead==false + -- players contribute no columns + if p.online ~= false and p.aurasRead + and p.debuffs and p.debuffs.names then + for nm in pairs(p.debuffs.names) do + if counts[nm] == nil then + counts[nm] = 0 + table.insert(names, nm) + end + counts[nm] = counts[nm] + 1 + if icons[nm] == nil and p.debuffs.textures then + icons[nm] = p.debuffs.textures[nm] + end + end + end + end + table.sort(names, function(a, b) + if counts[a] ~= counts[b] then + return counts[a] > counts[b] + end + return a < b + end) + local total = table.getn(names) + local keep = total + if keep > M.DEBUFF_CAP then + keep = M.DEBUFF_CAP + end + local slots = {} + for i = 1, keep do + local nm = names[i] + table.insert(slots, { + id = nm, label = nil, full = nm, + kind = "debuff", icon = icons[nm], + }) + end + return slots, total - keep +end + +-- Group-internal sort: gaps first (gaps desc), then unreadable, then +-- complete; ties stable by name (table.sort is NOT stable in Lua 5.0, +-- so name is an explicit final tiebreaker -> total deterministic order). +local function rowLess(a, b) + if a.sortBucket ~= b.sortBucket then + return a.sortBucket < b.sortBucket + end + if a.sortBucket == 1 and a.gaps ~= b.gaps then + return a.gaps > b.gaps + end + return a.player.name < b.player.name +end + +function M.build(store, roleOf, expect, tab, now, books) + local S = DC.STATE + local slots + local debuffMore = 0 + + if tab == "DEBUFFS" then + -- columns come from the STORE, not from a data table, and + -- expectations play no part + slots, debuffMore = buildDebuffSlots(store) + elseif tab == "RESIST" then + -- static columns, always all 5, expectations play no part + -- (display-only tab -- see the header) + slots = DC.SLOTS_RESIST or {} + elseif tab == "HIT" then + -- static columns, always all 8 (melee, ranged, six schools) -- + -- the second display-only tab, same rules throughout + slots = DC.SLOTS_HIT or {} + else + local allSlots = tabSlots(tab) + -- Dynamic columns: a slot is active + -- iff at least one (role, performing class) expects it; trinkets + -- are ALWAYS active on EQUIPMENT. + slots = {} + local nAll = table.getn(allSlots) + for i = 1, nAll do + local sd = allSlots[i] + local active = false + if sd.kind == "trinket" then + active = true + else + active = anyClassExpects(expect, sd.id) + end + if active then + table.insert(slots, sd) + end + end + end + local nSlots = table.getn(slots) + + -- Groups: always all five, fixed order. + local groups = {} + local groupsByRole = {} + local nRoles = table.getn(DC.ROLES) + for r = 1, nRoles do + local role = DC.ROLES[r] + local g = { + role = role, + label = DC.ROLE_LABEL[role], + rows = {}, + total = 0, + readable = 0, + unreadable = 0, + gaps = 0, + ready = 0, + -- sum of debuffCount over readable rows + debuffTotal = 0, + } + table.insert(groups, g) + groupsByRole[role] = g + end + + -- Footer: one entry per ACTIVE slot (count 0 included). + local footer = {} + for i = 1, nSlots do + footer[slots[i].id] = { count = 0, names = {} } + end + + -- readable = the aura-channel/row-readability count (unchanged, it + -- backs the cell states); channelReadable = what THIS tab's own read + -- channel actually covers -- see M.ChannelRead + local coverage = { readable = 0, total = 0, offline = 0, + readfailed = 0, far = 0, channelReadable = 0 } + local unreadableNames = {} + local unassignedNames = {} + + local players = store.players or {} + local nPlayers = table.getn(players) + coverage.total = nPlayers + + for i = 1, nPlayers do + local p = players[i] + + -- Readability rule (binding): online==false => + -- offline; otherwise aurasRead==false => read failed. Distance is + -- diagnosis only, never a filter. + local readable, urReason + if p.online == false then + readable = false + urReason = p.unreadableReason or "offline" + coverage.offline = coverage.offline + 1 + elseif not p.aurasRead then + readable = false + urReason = p.unreadableReason or "read failed" + coverage.readfailed = coverage.readfailed + 1 + else + readable = true + end + if readable then + coverage.readable = coverage.readable + 1 + else + table.insert(unreadableNames, p.name) + end + if M.ChannelRead(p, tab) then + coverage.channelReadable = coverage.channelReadable + 1 + end + + -- Out-of-range flag: diagnostic ONLY -- far + -- changes no cell state, no sum, no pill. Unreadable players are + -- never "far" (their distance is already pure diagnosis). + local far = false + if readable and p.distance and DC.FAR_LIMIT + and p.distance >= DC.FAR_LIMIT then + far = true + coverage.far = coverage.far + 1 + end + + local role, suggested, roleReason = roleOf(p) + if not groupsByRole[role] then + role = DC.ROLES[1] -- defensive: roleOf must always return a known role + end + -- Unassigned aggregate: suggested == the role is only a heuristic + -- proposal, not a confirmed assignment. EVERY such player counts, + -- unreadable ones too (assignment is independent of readability). + if suggested then + table.insert(unassignedNames, p.name) + end + -- per-class expectation line: explicit + -- expect[role][player.class] when present, else the + -- DC.DEFAULT_EXPECT role line (default fallback) + local rexp = expectLine(expect, role, p.class) + -- this player's weapon-skill-book flags (hit columns only) + local pBooks = books and books[p.name] or nil + + -- Last-known display layer: synthetic read-only + -- player views over the cached tables, built ONCE per row, so the + -- SAME classify() state machine below can re-run on the cached + -- data (no duplicated rules). IRON RULE: results only decorate + -- UNKNOWN cells as cell.last -- no counter below ever reads them. + local lastAuraView, lastGearView, lastWeaponView + if p.lastAuras then + lastAuraView = { name = p.name, class = p.class, online = true, + aurasRead = true, auras = p.lastAuras } + end + if p.lastGear then + lastGearView = { name = p.name, class = p.class, online = true, + gearRead = true, gear = p.lastGear } + end + if p.lastWeaponMain ~= nil then + lastWeaponView = { name = p.name, class = p.class, online = true, + weaponMain = p.lastWeaponMain, + weaponMainName = p.lastWeaponMainName } + end + + local cells = {} + local gaps, unknowns, has = 0, 0, 0 + -- HIT tab only: columns that are SHORT OF THEIR CAP. The hit + -- tab produces no MISSING cells (a number below its cap is + -- information, not an unfilled slot), so it needs its own + -- counter to answer "is there anything to tell this player". + local belowCap = 0 + for si = 1, nSlots do + local sd = slots[si] + local expd = false + if sd.kind == "debuff" or sd.kind == "resist" + or sd.kind == "hit" then + -- expectations are NOT consulted on debuff/resist/hit + -- columns; classify ignores the flag anyway + expd = true + elseif rexp[sd.id] then + expd = true + end + local state, detail, icon, item, value = + M.classify(p, sd, expd, pBooks) + cells[sd.id] = { state = state, detail = detail, + icon = icon, item = item } + if sd.kind == "resist" then + cells[sd.id].value = value + elseif sd.kind == "hit" then + -- value/cap/capMet are lifted out of the breakdown table + -- so the UI can paint a cell without unpacking it; they + -- stay nil on NOTEXP/UNKNOWN (detail is a string there) + cells[sd.id].value = value + if type(detail) == "table" then + cells[sd.id].cap = detail.cap + cells[sd.id].capMet = detail.capMet + -- BELOW-CAP counter -- the hit tab's equivalent of a + -- gap, and the only thing that makes a hit row worth + -- whispering about. A column with NO cap known makes + -- no claim (capMet is false there too), so the cap + -- itself has to be present before this counts. + if detail.cap and detail.capMet == false + and M.HitRelevant(role, sd) then + belowCap = belowCap + 1 + end + end + end + if state == S.MISSING then + gaps = gaps + 1 + if readable then + local f = footer[sd.id] + f.count = f.count + 1 + table.insert(f.names, p.name) + end + elseif state == S.UNKNOWN then + unknowns = unknowns + 1 + -- cell.last: classify the cached data with + -- the same rules; attach only determinate outcomes (a + -- cached UNKNOWN/NOTEXP keeps the plain gray "?"). Debuff + -- kind has no last-known channel (no view is ever built). + local view, at + if sd.kind == "aura" then + view = lastAuraView + at = p.lastAurasAt + elseif sd.kind == "weapon" then + view = lastWeaponView + at = p.lastWeaponAt + elseif sd.kind == "ench" or sd.kind == "trinket" then + view = lastGearView + at = p.lastGearAt + end + if view then + local ls, ld, li, lit = M.classify(view, sd, expd) + if ls == S.HAS or ls == S.MISSING then + local age = nil + if now and at then + age = now - at + end + cells[sd.id].last = { state = ls, detail = ld, + icon = li, item = lit, age = age } + end + end + elseif state == S.HAS then + has = has + 1 + end + end + + -- total count of DISTINCT debuff names on the + -- player -- straight off player.debuffs.names, so it INCLUDES + -- names beyond the DEBUFF_CAP columns (warning signal, not + -- column arithmetic). nil when the row is not readable; zero + -- debuffs on a readable player is the verified-clean case. + local debuffCount = nil + if readable then + debuffCount = 0 + if p.debuffs and p.debuffs.names then + for _ in pairs(p.debuffs.names) do + debuffCount = debuffCount + 1 + end + end + end + + local row = { + player = p, + tab = tab, + role = role, + suggested = suggested and true or false, + reason = roleReason, + readable = readable, + unreadableReason = urReason, + cells = cells, + gaps = gaps, + unknowns = unknowns, + far = far, + debuffCount = debuffCount, + -- Class buffs are the BUFFER's duty, not the recipient's: + -- whispering the player who lacks Arcane Intellect is + -- pointless. Gaps on that tab go to the raid report, + -- which already names slot + affected players. Debuffs are + -- nobody's shopping list either: whisperEligible is ALWAYS + -- false on DEBUFFS. The display tabs never produce gaps at + -- all (gaps stays 0 there), but they are excluded explicitly + -- too -- belt and suspenders, a display tab is never whisper + -- bait. + -- HIT is the one display tab a player CAN act on: being + -- short of the hit cap is fixable by that player (gear, + -- enchants, a weapon-skill book), unlike a resistance total. + -- So it gets the whisper button, driven by belowCap instead + -- of gaps -- RESIST stays silent. + belowCap = belowCap, + whisperEligible = readable + and ((tab == "HIT" and belowCap > 0) + or (gaps > 0 and tab ~= "CLASSBUFFS" + and tab ~= "DEBUFFS" + and not M.IsDisplayTab(tab))), + } + -- last-known row flags: an EQUIPMENT row that is readable by + -- the aura rule but whose gear could not be read (cross-continent + -- inspect limit) -- the UI renders a row-spanning note instead of + -- 13 bare "?" cells. + -- + -- unreadNote states the OBSERVATION, not a guessed cause. It used + -- to say "out of range", which named one possible reason as if it + -- were measured: gearRead is false whenever not one of the 16 + -- inventory slots returned a link, and distance is only one way + -- that happens (no SuperWoW, a failed inspect, a different + -- continent). The measured distance is printed separately by the + -- UI, so a reader still gets the range hypothesis -- as a number + -- they can judge, not as a verdict. + row.gearUnreadable = false + if tab == "EQUIPMENT" and readable and not p.gearRead then + row.gearUnreadable = true + end + if p.online == false then + row.unreadNote = "offline" + elseif row.gearUnreadable then + -- SHORT on purpose: this lands in the small status chip next + -- to the player's name, which has room for a few words. The + -- reason it is not "out of range" is that distance is only + -- ONE way the gear read comes back empty -- the full sentence + -- lives in the row-spanning message and the cell tooltip, + -- where there is space for it. + row.unreadNote = "items not readable" + end + if readable and tab ~= "DEBUFFS" and not M.IsDisplayTab(tab) then + -- Sums over determinate cells only: UNKNOWN is excluded from + -- BOTH numbers (never condensed into sums). + -- On DEBUFFS and the display tabs the sums stay NIL even for + -- readable rows (warning signal / display tab, no x/y -- the + -- sum column renders debuffCount or nothing instead). + row.sumHas = has + row.sumExpected = has + gaps + end + if not readable then + row.sortBucket = 2 + elseif gaps > 0 then + row.sortBucket = 1 + else + row.sortBucket = 3 + end + + local g = groupsByRole[role] + table.insert(g.rows, row) + g.total = g.total + 1 + if readable then + g.readable = g.readable + 1 + g.gaps = g.gaps + gaps + g.debuffTotal = g.debuffTotal + (debuffCount or 0) + -- ready = verified complete: readable, 0 gaps, 0 unknowns + -- (an UNKNOWN cell never counts toward a green pill) + if gaps == 0 and unknowns == 0 then + g.ready = g.ready + 1 + end + else + g.unreadable = g.unreadable + 1 + end + end + + for r = 1, nRoles do + table.sort(groups[r].rows, rowLess) + end + for i = 1, nSlots do + table.sort(footer[slots[i].id].names) + end + table.sort(unreadableNames) + table.sort(unassignedNames) + + -- Display-tab averages (RESIST + HIT): computed straight off + -- store.players, independent of row.readable (which tracks aura/ + -- online status -- resistance and hit readability are their own + -- flags, player.resistsRead and player.hit.hitRead). nil on every + -- other tab. + -- Hit columns bring their own denominator per column: a cell that is + -- not HAS -- an unreadable player, or the ranged column of a player + -- without a ranged weapon -- is left OUT of the average instead of + -- being counted as 0. classify() is re-run for those columns so the + -- average can never drift away from the rendered cells (same trick as + -- the last-known layer: one state machine). + local colAvg, colCount, resistReadable, hitReadable + if M.IsDisplayTab(tab) then + colAvg = {} + resistReadable = 0 + hitReadable = 0 + local sums, counts = {}, {} + -- the DENOMINATOR each average was actually divided by, per + -- column: it equals resistReadable for the five resistances, but + -- the ranged hit column skips every NOTEXP player, so a report + -- that printed hitReadable next to it would name a population the + -- average never covered + colCount = counts + for i = 1, nPlayers do + local p = players[i] + if p.resistsRead then + resistReadable = resistReadable + 1 + end + local hitRead = (p.hit and p.hit.hitRead) and true or false + if hitRead then + hitReadable = hitReadable + 1 + end + for si = 1, nSlots do + local sd = slots[si] + if sd.kind == "hit" then + if hitRead then + local hs, _, _, _, hv = M.classify(p, sd, true, + books and books[p.name] or nil) + if hs == S.HAS and hv then + sums[sd.id] = (sums[sd.id] or 0) + hv + counts[sd.id] = (counts[sd.id] or 0) + 1 + end + end + elseif p.resistsRead then + local v = (p.resists and p.resists[sd.school]) or 0 + sums[sd.id] = (sums[sd.id] or 0) + v + counts[sd.id] = (counts[sd.id] or 0) + 1 + end + end + end + for si = 1, nSlots do + local sd = slots[si] + local n = counts[sd.id] or 0 + if n <= 0 then + colAvg[sd.id] = 0 + elseif sd.kind == "hit" then + colAvg[sd.id] = M.RoundTenth((sums[sd.id] or 0) / n) + else + colAvg[sd.id] = math.floor((sums[sd.id] or 0) / n + 0.5) + end + end + end + + return { + coverage = coverage, + slots = slots, + groups = groups, + footer = footer, + unreadable = { + count = coverage.offline + coverage.readfailed, + names = unreadableNames, + }, + unassigned = { + count = table.getn(unassignedNames), + names = unassignedNames, + }, + -- distinct debuff names beyond the DEBUFF_CAP columns (0 outside + -- the DEBUFFS tab); reportLines appends "+N more debuff types" + debuffMore = debuffMore, + -- DISPLAY tabs only (nil elsewhere): rounded average per column + -- over the players who deliver that column -- resistance readers + -- for the five schools, hit readers with a HAS cell for the hit + -- columns -- plus the per-column denominator behind it and the + -- two channel reader counts. + colAvg = colAvg, + colCount = colCount, + -- aliases (the SAME tables, not copies) for readers written when + -- the resistances were the only display tab + resistAvg = colAvg, + resistCount = colCount, + resistReadable = resistReadable, + hitReadable = hitReadable, + tab = tab, + } +end + +-- ================================================================== +-- whisperText(rowVM, tabLabel, slots) -> string +-- Pure ASCII, plain "-" separator (not an +-- em dash): "DopingControl - missing before pull: Flask, Stamina/HP". +-- Slot order = column order. tabLabel is part of the fixed signature +-- but not of the output string (gap labels are self-explanatory). +-- ================================================================== +function M.whisperText(rowVM, tabLabel, slots) + local parts = {} + local n = table.getn(slots) + -- HIT has no MISSING cells at all -- a number below its cap is + -- information, not an unfilled slot. So it whispers the SHORTFALL, + -- with both numbers: "Melee 5.0/8.0" says what to fix and how far, + -- which a bare slot name could not. + if rowVM.tab == "HIT" then + for i = 1, n do + local sd = slots[i] + local c = rowVM.cells[sd.id] + if c and c.cap and c.capMet == false and c.value + and M.HitRelevant(rowVM.role, sd) then + table.insert(parts, M.gapLabel(sd) .. " " + .. M.HitNum(c.value) .. "/" .. M.HitNum(c.cap)) + end + end + return "DopingControl - below the hit cap: " + .. table.concat(parts, ", ") + end + for i = 1, n do + local sd = slots[i] + local c = rowVM.cells[sd.id] + if c and c.state == DC.STATE.MISSING then + table.insert(parts, M.gapLabel(sd)) + end + end + return "DopingControl - missing before pull: " .. table.concat(parts, ", ") +end + +-- ================================================================== +-- reportLines(vm, tabLabel, timeStr) -> array of strings +-- Each line <= M.MAX_LINE chars; long name lists continue on +-- " ... continued:" lines. Pure ASCII ("|" separators -- +-- chat output never uses middle dots). +-- ================================================================== +local CONT_PREFIX = " ... continued: " + +local function emitNameList(lines, prefix, names) + local cur = prefix + local first = true + local n = table.getn(names) + for i = 1, n do + local nm = names[i] + if not first and + string.len(cur) + 2 + string.len(nm) > M.MAX_LINE then + table.insert(lines, cur) + cur = CONT_PREFIX .. nm + elseif first then + cur = cur .. nm + first = false + else + cur = cur .. ", " .. nm + end + end + table.insert(lines, cur) +end + +function M.reportLines(vm, tabLabel, timeStr) + local lines = {} + -- the tab's own channel count, not the aura one: the header names a + -- tab, so the number behind it has to be that tab's (see + -- M.ChannelRead). Falls back for hand-built vms from older callers. + table.insert(lines, "DopingControl | " .. timeStr .. " | " .. tabLabel + .. " | coverage " + .. (vm.coverage.channelReadable or vm.coverage.readable) + .. "/" .. vm.coverage.total) + + -- The display tabs are no expectation check -- no missing lists at + -- all, one average line per column instead. Early return: the + -- gap-oriented machinery below (order/footer/debuffMore) does not + -- apply here. + if M.IsDisplayTab(vm.tab) then + if vm.tab == "HIT" then + table.insert(lines, "Hit (average, readable players):") + else + table.insert(lines, "Resistances (average, readable players):") + end + local nSlots = table.getn(vm.slots) + for i = 1, nSlots do + local sd = vm.slots[i] + local avg = (vm.colAvg and vm.colAvg[sd.id]) or 0 + -- hit columns print percent with one decimal; resistances + -- print a plain integer + local avgTxt + if sd.kind == "hit" then + avgTxt = M.HitText(avg) .. "%" + else + avgTxt = tostring(avg) + end + -- The count named here is the population the average was + -- actually taken over, NOT the channel's reader count: the + -- ranged hit column drops every player without a ranged + -- weapon, so quoting the reader count would overstate it. + local readers + if vm.colCount and vm.colCount[sd.id] then + readers = vm.colCount[sd.id] + elseif sd.kind == "hit" then + readers = vm.hitReadable or 0 + else + readers = vm.resistReadable or 0 + end + local noun = (readers == 1) and " player)" or " players)" + table.insert(lines, " " .. sd.full .. ": avg " .. avgTxt + .. " (" .. readers .. noun) + end + if vm.unreadable and vm.unreadable.count > 0 then + emitNameList(lines, + "Not readable (" .. vm.unreadable.count .. "): ", + vm.unreadable.names) + end + return lines + end + + -- DEBUFFS is a warning list, not an expectation check + -- -- its section header word is "Debuffs:"; the per-debuff name lists + -- below stay exactly as they are. Expectation tabs keep the pinned + -- "Missing per slot:". + if vm.tab == "DEBUFFS" then + table.insert(lines, "Debuffs:") + else + table.insert(lines, "Missing per slot:") + end + + -- slots with gaps, sorted by count desc; ties keep column order + local order = {} + local nSlots = table.getn(vm.slots) + for i = 1, nSlots do + local sd = vm.slots[i] + local f = vm.footer[sd.id] + if f and f.count > 0 then + table.insert(order, { sd = sd, f = f, idx = i }) + end + end + table.sort(order, function(a, b) + if a.f.count ~= b.f.count then + return a.f.count > b.f.count + end + return a.idx < b.idx + end) + + local nOrder = table.getn(order) + if nOrder == 0 then + table.insert(lines, " none") + end + for i = 1, nOrder do + local e = order[i] + emitNameList(lines, + " " .. M.gapLabel(e.sd) .. " (" .. e.f.count .. "): ", e.f.names) + end + + -- "Not readable" is its OWN line; unreadable players never appear in + -- the missing lists above (UNKNOWN is never counted as MISSING). + if vm.unreadable and vm.unreadable.count > 0 then + emitNameList(lines, + "Not readable (" .. vm.unreadable.count .. "): ", + vm.unreadable.names) + end + -- DEBUFFS column-cap overflow: the report's slot + -- lines cover the capped columns; the overflow is appended as one + -- line so the report never silently hides debuff types. + if vm.debuffMore and vm.debuffMore > 0 then + table.insert(lines, "+" .. vm.debuffMore .. " more debuff types") + end + return lines +end + +-- ================================================================== +-- unknownBuffs(store) -> { [buffName] = count } +-- Aura names no data table knows (no silent discard); +-- count = number of readable players carrying the aura. +-- ================================================================== +function M.unknownBuffs(store) + local out = {} + local players = (store and store.players) or {} + local n = table.getn(players) + for i = 1, n do + local p = players[i] + if p.aurasRead and p.auras and p.auras.names then + for name in pairs(p.auras.names) do + local known = (DC.CONSUMABLES and DC.CONSUMABLES[name]) + or (DC.CLASSBUFFS and DC.CLASSBUFFS[name]) + if not known then + out[name] = (out[name] or 0) + 1 + end + end + end + end + return out +end diff --git a/core/probe.lua b/core/probe.lua new file mode 100644 index 0000000..893f87f --- /dev/null +++ b/core/probe.lua @@ -0,0 +1,534 @@ +-- DopingControl core/probe.lua +-- Feasibility probe: dumps RAW API returns to dc_probe.txt (in the WoW +-- client's Imports folder) via SuperWoW ExportFile so raw facts can be read +-- from disk instead of retyped from chat lines. +-- +-- Entry points (core/init.lua wires the slash command): +-- DC_Probe.HandleSlash(rest) -- rest = text after "probe": +-- "" -> Battery() (part 2, near-range questions) +-- "range " -> RangeStart(name) / RangeStop() toggle (part 1) +-- DC_Probe.Battery() +-- DC_Probe.RangeStart(playerName) / DC_Probe.RangeStop() +-- DC_Probe.IsRangeRunning() +-- +-- Part 1 (range run): a raid member with known buffs walks away; a 1 Hz +-- poller writes one line per tick: +-- dist | UnitExists | UnitIsVisible | nA=count(UnitBuff) | 4th-return | +-- nB=count(GetUnitField) | UnitPosition +-- The unit token is RE-RESOLVED from the name every tick (raid indices are +-- volatile after roster changes). +-- +-- Part 2 (battery) answers five open client-behavior questions with raw data: +-- Q1 does UnitBuff(unit, i) return a 4th value ~= nil (spell id)? +-- Q2 does GetUnitField(guid,"aura") match the tooltip names; is the +-- slot split really 1-32 buffs / 33-48 debuffs? +-- Q3 does GetWeaponEnchantInfo(unit) name a foreign temp enchant? +-- Q4 does GetInventoryItemLink(unit, slot) serve out-of-sight units? +-- Q5 is the ENCHANT field in foreign item links filled (~= 0)? +-- +-- File mechanics: ExportFile appends ".txt" ITSELF -- the name is passed +-- WITHOUT extension ("dc_probe", never "dc_probe.txt"). ExportFile is a +-- whole-file overwrite, so the probe keeps an in-memory ring of lines and +-- rewrites the complete file on every flush. +-- +-- Pure Lua 5.0, dofile-loadable offline (all WoW usage inside functions / +-- existence-checked; the poller frame is created lazily in-game). + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Probe = DC_Probe or {} +local P = DC_Probe + +P.RING_CAP = 2000 -- max lines kept; oldest dropped first +P.EXPORT_NAME = "dc_probe" -- NO extension (ExportFile appends .txt) +P.BATTERY_UNIT_CAP = 8 -- full detail for at most N foreign units + +local ring = {} + +-- ------------------------------------------------------------------ +-- ring + flush +-- ------------------------------------------------------------------ +function P.Clear() + ring = {} +end + +function P.Push(line) + table.insert(ring, line) + while table.getn(ring) > P.RING_CAP do + table.remove(ring, 1) + end +end + +function P.Text() + return table.concat(ring, "\n") +end + +local function say(msg) + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("|cffe6c87eDopingControl|r probe: " .. msg) + end +end + +function P.Flush() + if ExportFile then + -- name WITHOUT extension -- ExportFile appends .txt itself. + -- pcall: optional-DLL call -- a DLL-side I/O error + -- must not escape into the slash handler or the 1 Hz range ticker. + local ok = pcall(ExportFile, P.EXPORT_NAME, P.Text()) + if not ok then + say("ExportFile failed - dump not written") + return false + end + return true + end + say("ExportFile missing (SuperWoW required) - dump not written") + return false +end + +-- ------------------------------------------------------------------ +-- formatting helpers +-- ------------------------------------------------------------------ +local function fmt(v) + if v == nil then + return "nil" + end + if v == true then + return "true" + end + if v == false then + return "false" + end + if type(v) == "number" then + return tostring(v) + end + if type(v) == "string" then + return v + end + return type(v) +end + +local function gtype(name) + local v = getglobal and getglobal(name) + if v == nil then + return "nil" + end + return type(v) +end + +local function nowStamp() + if date then + return date("%Y-%m-%d %H:%M:%S") + end + return "?" +end + +local function header(kind) + P.Push("=== DopingControl probe " .. kind .. " " .. nowStamp() .. " ===") + P.Push("client: TURTLE_WOW_VERSION=" .. fmt(TURTLE_WOW_VERSION) + .. " SUPERWOW_VERSION=" .. fmt(SUPERWOW_VERSION)) + P.Push("dll-surface: UnitXP=" .. gtype("UnitXP") + .. " GetUnitField=" .. gtype("GetUnitField") + .. " ExportFile=" .. gtype("ExportFile") + .. " UnitPosition=" .. gtype("UnitPosition")) +end + +-- resolve a roster unit token by player name; nil if not in group. +-- Re-run every use: raid indices are volatile. +local function resolveUnit(name) + if not UnitName then + return nil + end + local lower = string.lower(name) + local nraid = (GetNumRaidMembers and GetNumRaidMembers()) or 0 + if nraid > 0 then + for i = 1, nraid do + local ok, nm = pcall(UnitName, "raid" .. i) + if ok and nm and string.lower(nm) == lower then + return "raid" .. i + end + end + return nil + end + for i = 1, 4 do + local ok, nm = pcall(UnitName, "party" .. i) + if ok and nm and string.lower(nm) == lower then + return "party" .. i + end + end + return nil +end + +local function unitGuid(unit) + if not UnitExists then + return nil + end + local ok, ex, g = pcall(UnitExists, unit) + if ok and ex and g then + return g + end + return nil +end + +-- count UnitBuff entries + first non-nil 4th return +local function pathACounts(unit) + local n = 0 + local firstId = nil + local idCount = 0 + if UnitBuff then + for i = 1, 32 do + local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, unit, i) + if not ok or not tex then + break + end + n = n + 1 + if type(id4) == "number" and id4 > 0 then + idCount = idCount + 1 + if not firstId then + firstId = id4 + end + end + end + end + return n, firstId, idCount +end + +-- GetUnitField(guid,"aura") -> comma list for a slot range (sparse table) +local function pathBList(guid, from, to) + if not GetUnitField or not guid then + return nil, 0 + end + local ok, t = pcall(GetUnitField, guid, "aura") + if not ok then + return "ERR", 0 + end + if type(t) ~= "table" then + return "type=" .. type(t), 0 + end + local parts = {} + local n = 0 + for i = from, to do + local v = t[i] + if type(v) == "number" and v > 0 then + n = n + 1 + table.insert(parts, i .. ":" .. v) + end + end + if n == 0 then + return "-", 0 + end + return table.concat(parts, ","), n +end + +-- ------------------------------------------------------------------ +-- Part 2: the battery (/dc probe) +-- ------------------------------------------------------------------ +function P.Battery() + if not UnitName then + say("no client APIs (offline?) - battery skipped") + return false + end + P.Clear() + header("battery") + + local roster = (DC_Scan and DC_Scan.BuildRoster and DC_Scan.BuildRoster()) or {} + local total = table.getn(roster) + P.Push("roster: " .. total .. " units; full detail for the first " + .. P.BATTERY_UNIT_CAP .. " foreign units") + P.Push("") + + -- self weapon enchant baseline (vanilla 6-return form) + if GetWeaponEnchantInfo then + local ok, r1, r2, r3, r4, r5, r6 = pcall(GetWeaponEnchantInfo) + P.Push("self GetWeaponEnchantInfo(): ok=" .. fmt(ok) + .. " hasMain=" .. fmt(r1) .. " mainExp=" .. fmt(r2) + .. " mainCharges=" .. fmt(r3) .. " hasOff=" .. fmt(r4) + .. " offExp=" .. fmt(r5) .. " offCharges=" .. fmt(r6)) + else + P.Push("self GetWeaponEnchantInfo(): API missing") + end + P.Push("") + + local detailed = 0 + for r = 1, total do + local e = roster[r] + local isSelf = false + if UnitIsUnit then + local ok, v = pcall(UnitIsUnit, e.unit, "player") + isSelf = (ok and v) and true or false + end + if not isSelf and detailed < P.BATTERY_UNIT_CAP then + detailed = detailed + 1 + local guid = unitGuid(e.unit) + local online, dist = DC_Reach.ReadUnit(e.unit) + P.Push("--- " .. e.unit .. " name=" .. fmt(e.name) + .. " class=" .. fmt(e.class) .. " online=" .. fmt(online) + .. " dist=" .. fmt(dist) .. " guid=" .. fmt(guid)) + + -- Q1: UnitBuff rows with 4th return + tooltip name + if UnitBuff then + local rows = 0 + for i = 1, 32 do + local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, e.unit, i) + if not ok or not tex then + break + end + rows = rows + 1 + local tipName = nil + -- same hidden tooltip as scan/aura.lua (shared accessor, + -- never a second frame under the same global name) + local tip = DC_Aura and DC_Aura.GetScanTip + and DC_Aura.GetScanTip() + if tip then + local ok2 = pcall(function() + tip:ClearLines() + tip:SetUnitBuff(e.unit, i) + end) + if ok2 then + local obj = getglobal("DopingControlScanTipTextLeft1") + if obj then + tipName = obj:GetText() + end + end + end + P.Push("Q1 UnitBuff i=" .. i .. " tex=" .. fmt(tex) + .. " stacks=" .. fmt(stacks) .. " dtype=" .. fmt(dtype) + .. " id4=" .. fmt(id4) .. " tipName=" .. fmt(tipName)) + end + local nA, firstId, idCount = pathACounts(e.unit) + P.Push("Q1 summary: buffs=" .. nA .. " id4nonNil=" .. idCount + .. " (id4 ~= nil answers Q1)") + else + P.Push("Q1 UnitBuff: API missing") + end + + -- Q2: descriptor list, buff and debuff halves separately + local buffList = pathBList(guid, 1, 32) + local debuffList = pathBList(guid, 33, 48) + P.Push("Q2 GetUnitField(guid,\"aura\") slots1-32=" .. fmt(buffList)) + P.Push("Q2 GetUnitField(guid,\"aura\") slots33-48=" .. fmt(debuffList) + .. " (compare vs UnitBuff names above -> slot-split check)") + + -- Q3: foreign weapon enchant name + if GetWeaponEnchantInfo then + local ok, w1, w2 = pcall(GetWeaponEnchantInfo, e.unit) + P.Push("Q3 GetWeaponEnchantInfo(unit): ok=" .. fmt(ok) + .. " r1=" .. fmt(w1) .. " r2=" .. fmt(w2)) + end + + -- Q4/Q5: inventory links, raw + parsed enchant field. + -- subType (GetItemInfo return 6, de-shifted by DC_Gear.ItemInfo) + -- is dumped per slot to verify the DC.ENCH_EXEMPT [ASSUMPTION] + -- strings against reality (data/enchants.lua). + local slots = DC_Gear.InvSlots() + local linkCount = 0 + for s = 1, table.getn(slots) do + local inv = slots[s] + local ok, link = pcall(GetInventoryItemLink, e.unit, inv) + if ok and link then + linkCount = linkCount + 1 + local id, ench = DC_Gear.ParseLink(link) + local subType = nil + if id then + local _, _, st = DC_Gear.ItemInfo(id) + subType = st + end + P.Push("Q4/Q5 slot" .. inv .. " link=" .. fmt(link) + .. " -> id=" .. fmt(id) .. " ench=" .. fmt(ench) + .. " subType=" .. fmt(subType)) + end + end + P.Push("Q4 summary: " .. linkCount .. "/" .. table.getn(slots) + .. " slots returned a link (0 for an out-of-sight unit" + .. " answers Q4 negatively)") + P.Push("Q5b: subType above checks the ENCH_EXEMPT assumption" + .. " strings - expected \"Wands\"/\"Thrown\" (slot 18) and" + .. " \"Miscellaneous\" (slot 17), data/enchants.lua") + + -- Q6: resistance read paths (RESIST tab, scan/engine.lua + -- readResists) -- school 2 (Fire) as a representative probe, + -- all three paths dumped raw: does UnitResistance answer for a + -- foreign unit token, does it also accept the GUID directly, + -- and does GetUnitField(guid,"resistances") carry a usable + -- table at all -- or does a foreign read silently return 0? + if UnitResistance then + local ok1, base1, total1 = pcall(UnitResistance, e.unit, 2) + P.Push("Q6 UnitResistance(unit,2): ok=" .. fmt(ok1) + .. " base=" .. fmt(base1) .. " total=" .. fmt(total1)) + if guid then + local ok2, base2, total2 = pcall(UnitResistance, guid, 2) + P.Push("Q6 UnitResistance(guid,2): ok=" .. fmt(ok2) + .. " base=" .. fmt(base2) .. " total=" .. fmt(total2)) + end + else + P.Push("Q6 UnitResistance: API missing") + end + if guid and GetUnitField then + local ok3, t = pcall(GetUnitField, guid, "resistances") + if ok3 and type(t) == "table" then + P.Push("Q6 GetUnitField(guid,\"resistances\"): [1]=" + .. fmt(t[1]) .. " [2]=" .. fmt(t[2]) .. " [3]=" + .. fmt(t[3]) .. " [4]=" .. fmt(t[4]) .. " [5]=" + .. fmt(t[5]) .. " [6]=" .. fmt(t[6]) .. " [7]=" + .. fmt(t[7])) + else + P.Push("Q6 GetUnitField(guid,\"resistances\"): ok=" + .. fmt(ok3) .. " type=" .. type(t)) + end + end + P.Push("") + end + end + if detailed == 0 then + P.Push("no foreign units in group - battery needs a party/raid") + end + + local written = P.Flush() + if written then + say("battery done (" .. detailed .. " foreign units) -> " + .. P.EXPORT_NAME .. ".txt") + end + return written +end + +-- ------------------------------------------------------------------ +-- Part 1: the 1 Hz range run (/dc probe range ) +-- ------------------------------------------------------------------ +local rangeFrame = nil +local rangeName = nil +local rangeRunning = false +local rangeAcc = 0 +local rangeT0 = 0 + +local function rangeTick() + local t = (GetTime and GetTime()) or 0 + local rel = math.floor(t - rangeT0 + 0.5) + local unit = resolveUnit(rangeName) + if not unit then + P.Push("t=" .. rel .. "s target '" .. rangeName .. "' not in roster") + P.Flush() + return + end + local guid = unitGuid(unit) + + local dist = nil + if UnitXP then + local ok, d = pcall(UnitXP, "distanceBetween", "player", unit) + if ok and type(d) == "number" then + dist = d + end + end + local exists = nil + if UnitExists then + local ok, ex = pcall(UnitExists, unit) + if ok then + exists = ex + end + end + local visible = nil + if UnitIsVisible then + local ok, v = pcall(UnitIsVisible, unit) + if ok then + visible = v + end + end + local nA, firstId = pathACounts(unit) + local _, nB = pathBList(guid, 1, 32) + local pos = "nil" + if UnitPosition and guid then + local ok, x, y = pcall(UnitPosition, guid) + if ok and type(x) == "number" then + pos = "ok(" .. string.format("%.1f", x) .. "," + .. string.format("%.1f", y or 0) .. ")" + end + end + + -- part-1 column order (matches the header line written by RangeStart) + P.Push("t=" .. rel .. "s unit=" .. unit + .. " dist=" .. fmt(dist) + .. " exists=" .. fmt(exists) + .. " visible=" .. fmt(visible) + .. " nA=" .. nA + .. " id4first=" .. fmt(firstId) + .. " nB=" .. nB + .. " pos=" .. pos) + P.Flush() +end + +function P.IsRangeRunning() + return rangeRunning +end + +function P.RangeStart(name) + if not CreateFrame then + say("no client (offline) - range run unavailable") + return false + end + if rangeRunning then + say("range run already active for " .. fmt(rangeName) + .. " - stop it first (/dc probe range)") + return false + end + rangeName = name + rangeRunning = true + rangeAcc = 0 + rangeT0 = (GetTime and GetTime()) or 0 + P.Clear() + header("range-run target=" .. name) + P.Push("columns: t | unit | dist | exists | visible | nA=count(UnitBuff) |" + .. " id4first | nB=count(GetUnitField slots 1-32) | pos=UnitPosition") + if not rangeFrame then + rangeFrame = CreateFrame("Frame", "DopingControlProbeFrame") + end + rangeFrame:SetScript("OnUpdate", function() + rangeAcc = rangeAcc + (arg1 or 0) + if rangeAcc >= 1.0 then + rangeAcc = 0 + rangeTick() + end + end) + say("range run started, target " .. name .. ", 1 Hz -> " + .. P.EXPORT_NAME .. ".txt (stop: /dc probe range " .. name .. ")") + return true +end + +function P.RangeStop() + if rangeFrame then + rangeFrame:SetScript("OnUpdate", nil) + end + if rangeRunning then + rangeRunning = false + P.Push("=== range run stopped " .. nowStamp() .. " ===") + P.Flush() + say("range run stopped, " .. table.getn(ring) .. " lines in " + .. P.EXPORT_NAME .. ".txt") + end + rangeName = nil +end + +-- ------------------------------------------------------------------ +-- slash glue: rest of the line after "probe" +-- ------------------------------------------------------------------ +function P.HandleSlash(rest) + rest = rest or "" + rest = string.gsub(rest, "^%s+", "") + rest = string.gsub(rest, "%s+$", "") + if rest == "" then + return P.Battery() + end + local _, _, word = string.find(rest, "^(%S+)") + if word and string.lower(word) == "range" then + if rangeRunning then + P.RangeStop() + return true + end + local _, _, name = string.find(rest, "^%S+%s+(.+)$") + if name then + return P.RangeStart(name) + end + say("usage: /dc probe range ") + return false + end + say("usage: /dc probe | /dc probe range ") + return false +end diff --git a/core/roles.lua b/core/roles.lua new file mode 100644 index 0000000..d58f243 --- /dev/null +++ b/core/roles.lua @@ -0,0 +1,247 @@ +-- DopingControl core/roles.lua +-- Role suggestion heuristic + confirmed-role resolution: five roles, the +-- role hangs on the player NAME, and no heuristic ever decides silently -- +-- a suggestion is always flagged as such and carries a reason string. +-- Badge UX: CLASS_ROLES = ordered plausible roles per class, cycle() = +-- badge right-click (step + immediate confirm). +-- Per-class expectations: +-- ROLE_CLASSES = generated inversion of CLASS_ROLES (ordered classes per +-- role) -- the domain of db.expectations[role][class]. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Roles = DC_Roles or {} + +-- Transient confirmations for the simulated raid (test mode). NEVER written +-- to SavedVariables: synthetic names must not pollute db.roles. +-- Reset by DC_Sim.buildStore() on every (re-)entry into test mode. +DC.simRoles = DC.simRoles or {} + +-- ------------------------------------------------------------------ +-- Plausible roles per class token, ORDERED (the order is +-- the badge right-click cycle order). suggest() below must only ever +-- return a role CONTAINED in the class's list -- property-tested over +-- every class x fact combination in the offline test suite. +-- ------------------------------------------------------------------ +DC_Roles.CLASS_ROLES = { + WARRIOR = { "MELEE", "TANK" }, + ROGUE = { "MELEE" }, + HUNTER = { "RANGED", "MELEE" }, -- melee hunter is a real spec on TWoW + MAGE = { "CASTER" }, + WARLOCK = { "CASTER" }, + PRIEST = { "HEALER", "CASTER" }, + DRUID = { "HEALER", "TANK", "MELEE", "CASTER" }, + SHAMAN = { "HEALER", "TANK", "MELEE", "CASTER" }, + PALADIN = { "HEALER", "TANK", "MELEE" }, +} + +-- ------------------------------------------------------------------ +-- Per-class expectations: +-- ROLE_CLASSES = the INVERSION of CLASS_ROLES -- for each role the +-- ordered list of class tokens that can perform it, in the fixed +-- class order below, filtered per role. GENERATED from CLASS_ROLES so +-- the two tables can never drift apart. +-- Consumers: core/config.lua materializes db.expectations[role][class] +-- over exactly these lists, core/model.lua iterates them for the +-- dynamic-column check, and the options expectation grid renders one +-- indented class row per entry. +-- ------------------------------------------------------------------ +DC_Roles.CLASS_ORDER = { + "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST", + "SHAMAN", "MAGE", "WARLOCK", "DRUID", +} + +DC_Roles.ROLE_CLASSES = {} +do + -- fresh tables on every (re-)load -- regeneration, not accretion + local inv = DC_Roles.ROLE_CLASSES + for i = 1, table.getn(DC_Roles.CLASS_ORDER) do + local cls = DC_Roles.CLASS_ORDER[i] + local list = DC_Roles.CLASS_ROLES[cls] or {} + for j = 1, table.getn(list) do + local role = list[j] + if inv[role] == nil then + inv[role] = {} + end + table.insert(inv[role], cls) + end + end +end + +-- ------------------------------------------------------------------ +-- Facts extraction: derive the suggestion facts from a store player entry. +-- Works on both aura paths: form NAMES from +-- the tooltip path, form SPELL IDS from the GUID path (which delivers ids +-- only). +-- [ASSUMPTION] Form aura spell IDs (vanilla 1.12): Cat Form 768, Bear Form +-- 5487, Dire Bear Form 9634, Moonkin Form 24858, Shadowform 15473. +-- ------------------------------------------------------------------ +DC_Roles.FORM_NAMES = { + ["Cat Form"] = "catForm", + ["Bear Form"] = "bearForm", + ["Dire Bear Form"] = "bearForm", + ["Moonkin Form"] = "moonkin", + ["Shadowform"] = "shadowform", +} + +DC_Roles.FORM_IDS = { + [768] = "catForm", + [5487] = "bearForm", + [9634] = "bearForm", + [24858] = "moonkin", + [15473] = "shadowform", +} + +function DC_Roles.factsFor(player) + local facts = { + hasShield = false, catForm = false, bearForm = false, + shadowform = false, moonkin = false, + } + if not player then + return facts + end + if player.aurasRead and player.auras then + if player.auras.names then + for name, key in pairs(DC_Roles.FORM_NAMES) do + if player.auras.names[name] then + facts[key] = true + end + end + end + if player.auras.ids then + for id, key in pairs(DC_Roles.FORM_IDS) do + if player.auras.ids[id] then + facts[key] = true + end + end + end + end + if player.gearRead and player.gear then + local off = player.gear[17] -- off hand inventory slot + if type(off) == "table" and off.subType == "Shields" then + facts.hasShield = true + end + end + return facts +end + +-- ------------------------------------------------------------------ +-- Suggestion heuristic. ALWAYS returns a +-- (role, reason) pair -- no suggestion without a stated reason (the badge +-- tooltip shows it). +-- ------------------------------------------------------------------ +function DC_Roles.suggest(class, facts) + facts = facts or {} + if class == "WARRIOR" then + if facts.hasShield then + return "TANK", "shield equipped" + end + return "MELEE", "class default" + elseif class == "ROGUE" then + return "MELEE", "class default" + elseif class == "HUNTER" then + return "RANGED", "class default" + elseif class == "MAGE" then + return "CASTER", "class default" + elseif class == "WARLOCK" then + return "CASTER", "class default" + elseif class == "PRIEST" then + if facts.shadowform then + return "CASTER", "Shadowform at scan time" + end + return "HEALER", "class default" + elseif class == "DRUID" then + -- form precedence: bear > cat > moonkin > none (a druid can only be + -- in one form at a time; the order only matters for garbage input) + if facts.bearForm then + return "TANK", "Bear Form at scan time" + end + if facts.catForm then + return "MELEE", "Cat Form at scan time" + end + if facts.moonkin then + return "CASTER", "Moonkin Form at scan time" + end + return "HEALER", "class default" + elseif class == "SHAMAN" then + -- deliberately NOT shield->MELEE: resto shamans + -- carry shields too, so a shield is no enhancement signal here. + -- Plain class default; enhancers get confirmed by hand once. + return "HEALER", "class default" + elseif class == "PALADIN" then + if facts.hasShield then + return "TANK", "shield equipped" + end + return "HEALER", "class default" + end + -- unknown class token: still return a pair, never nil + return "MELEE", "unknown class" +end + +-- ------------------------------------------------------------------ +-- Resolution: confirmed role wins over suggestion. +-- source "sim" -> confirmations live ONLY in DC.simRoles (transient); +-- db.roles is never consulted and never written for +-- simulated players (synthetic names must +-- not end up in SavedVariables). +-- source "scan" -> confirmations live in db.roles (persistent, keyed by +-- player name). +-- Returns role, suggested(bool), reason(string|nil): +-- confirmed -> (role, false, nil) +-- suggested -> (role, true, reason) +-- ------------------------------------------------------------------ +function DC_Roles.resolve(db, storeSource, player) + local name = player and player.name + local confirmed + if storeSource == "sim" then + confirmed = name and DC.simRoles[name] + else + confirmed = name and db and db.roles and db.roles[name] + end + if confirmed then + return confirmed, false, nil + end + local role, reason = DC_Roles.suggest(player and player.class, + DC_Roles.factsFor(player)) + return role, true, reason +end + +-- Confirm a role (badge LEFT-click). Same routing rule as resolve(). +function DC_Roles.confirm(db, storeSource, name, role) + if storeSource == "sim" then + DC.simRoles[name] = role + else + db.roles[name] = role + end +end + +-- ------------------------------------------------------------------ +-- cycle(db, storeSource, name, class, currentRole) -> nextRole +-- Badge RIGHT-click: step to the entry AFTER currentRole +-- in CLASS_ROLES[class] (wrapping; currentRole not in the list -> the +-- FIRST entry) and CONFIRM it immediately -- same persistence routing as +-- confirm() (sim source -> DC.simRoles, else db.roles). +-- Single-entry classes wrap onto themselves (the UI treats right-click +-- as a no-op there and never calls this; calling it anyway is harmless: +-- the same role comes back and is re-confirmed). +-- Unknown class token: no list -> currentRole returned unchanged and +-- NOTHING confirmed (defensive; scan classes are always known tokens). +-- ------------------------------------------------------------------ +function DC_Roles.cycle(db, storeSource, name, class, currentRole) + local list = DC_Roles.CLASS_ROLES[class] + if not list or table.getn(list) == 0 then + return currentRole + end + local n = table.getn(list) + local nextRole = list[1] + for i = 1, n do + if list[i] == currentRole then + nextRole = list[math.mod(i, n) + 1] + break + end + end + DC_Roles.confirm(db, storeSource, name, nextRole) + return nextRole +end diff --git a/core/sim.lua b/core/sim.lua new file mode 100644 index 0000000..085c8d0 --- /dev/null +++ b/core/sim.lua @@ -0,0 +1,1135 @@ +-- DopingControl core/sim.lua +-- Test mode: deterministic simulated raid. +-- DC_Sim.buildStore() returns a store in EXACTLY the shape the real scan +-- produces (source="sim") -- model, aggregation and +-- rendering run unchanged on it, so test mode exercises the real pipeline. +-- +-- Fixed synthetic roster: 40 players (a FULL raid -- this server's raid +-- cap is 40, eight subgroups of five), all 9 classes, role distribution +-- TANK 4 / MELEE 11 / RANGED 6 / CASTER 11 / HEALER 8. No randomness -- +-- every run looks the same, screenshots are comparable. All names start +-- with "Sim" so no synthetic name can be mistaken for (or whispered to) a +-- real player; chat outputs are dead in test mode anyway. Every player +-- also carries subgroup 1..8 (five each, real raid-group shape) so the +-- full-raid layout -- scroll behavior, group headers, column widths, the +-- render cost of forty rows -- is exercised at MAXIMUM occupancy. +-- +-- Coverage built into the roster (every feature is showcased): +-- * every cell state in every tab (HAS / MISSING / UNKNOWN / NOTEXP) +-- * TANK group all readable with 0 gaps in all three tabs (green pill) +-- * exactly 3 offline + 2 read-failed players (5 of 40 unreadable); the +-- read-failed carry distance values (87 / 203) as diagnosis. All 5 +-- unreadable players share subgroup 8 -- a raid subgroup that is +-- ENTIRELY unreadable, the opposite pill extreme from the all-green +-- TANK group above (both a fully-clean and a fully-blank group must +-- render correctly). +-- * 2 unconfirmed roles (Simfury, Simshade -> suggestion badges) +-- * trinkets: epic (quality 4), rare (quality 3), one EMPTY slot 14 +-- * enchant gaps (enchant==0), EMPTY off hand (fury warrior), wand +-- wearer (ranged enchant NOTEXP), holdable off hand (NOTEXP) +-- * HAS via spell-ID match for some players (names empty) and via +-- name match for others (ids empty) -- both model paths are exercised +-- * whisper-eligible rows (readable + gaps) in every tab +-- * FULL icon coverage: every HAS-producing +-- aura name has a real-looking vanilla icon path in auras.textures +-- (built programmatically below, so id-matched and name-matched +-- players are both covered), and every gear entry TABLE carries +-- texture + name + quality -- test mode showcases the icon feature. +-- * DEBUFFS tab: exactly 3 afflicted players +-- -- Simrogue + Simtree share "Resurrection Sickness" (afflicted=2 => +-- first column, count sorting testable), Simfrost carries "Curse of +-- Recklessness" + "Weakened Soul" (two columns from ONE player) => +-- 3 columns total, count-desc then alpha: RS, CoR, WS. Every other +-- READABLE player gets an EMPTY debuffs table (legitimate clean state +-- = HAS cells); the 5 unreadable players keep debuffs nil (UNKNOWN). +-- * far flag: 2 READABLE players beyond DC.FAR_LIMIT=40 +-- (Simhuntara 55, Simfrost 120) => coverage.far == 2; Simbow keeps +-- distance 31 (near), the read-failed distances 87/203 stay pure +-- diagnosis and never count as far. +-- * weapon enchant names: two players carry +-- weaponMainName -- Simtank "Dense Sharpening Stone", Simmage +-- "Brilliant Wizard Oil" (WPN HAS detail = the name); Simwall and +-- every one of the 40-man fill-in players below stay weaponMain=true +-- WITHOUT a name (generic detail path) -- exactly two named enchants +-- in the whole roster, deliberately. +-- * last-known showcase: Simblade (offline) carries +-- lastAuras + lastGear (At = now-540) so all three aura/equipment +-- tabs show dimmed last-known cells (HAS and MISSING both reachable; +-- the cached EMPTY off hand classifies NOTEXP => that cell stays a +-- plain "?"). Simhuntara is THE cross-continent case: online and +-- aura-readable but gearRead=false + gear nil, with lastGear +-- (At = now-1200) -- EQUIPMENT renders the "out of range" row note +-- plus dimmed tiles. IRON RULE: all of it is display-only, the cells +-- stay UNKNOWN in every count. +-- * deterministic clock: all At offsets and scanAt hang +-- off ONE base `now` -- GetTime() when the client provides it (test +-- mode shows plausible live ages), a FIXED 1000 offline so buildStore +-- stays byte-deterministic under the test harness. +-- * RESIST tab: every readable player carries resistsRead=true + a +-- resists table -- the 3 tanks fire 315/240/180 (a visible spread), +-- Simhuntara nature 120 (resist reads work even with gearRead=false -- +-- an independent channel), Simmage frost 85 + arcane 45 (the two- +-- school showcase), everyone else a small deterministic per-school +-- formula (0s occur -- a legitimate resistance, distinct from the 4 +-- unreadable players who carry no resist data at all -> UNKNOWN +-- cells). Simtank also carries a real NECK enchant (>0) -- neck/ring +-- enchants are a server-custom feature (data/enchants.lua) whose +-- expectation defaults to OFF, so this doubles as the "not expected, +-- item still shown" showcase. +-- * HIT tab (its own tab, eight columns: melee, ranged and one per +-- magic school): every showcase of the feature is in the roster -- +-- MEASURED skill Simtank (skillExact 305 => +1.0% hit on the +-- player's side: melee 8 gear -> 9.0 against the flat 8.0 yellow +-- cap), ESTIMATED racial skill Simwall/Simsteel/Simrogue +-- (human/orc/gnome specializations), BOOK candidate Simsteel (ticking +-- "Axes" in the options adds +3 skill, worth +1.6 on his value, on +-- the next refresh -- the cap stays flat at 8.0 because skill counts +-- on the player's side, and the flag lives in the DB, never in this +-- store), AT CAP +-- Simtank/Simsteel/Simbow (melee + ranged), BELOW CAP Simwall (6) +-- and Simfrost (spell 8 of 16), DUAL WIELD Simrogue (dagger + sword: +-- yellow cap 8.0 met by 10, white cap 27.0), NO RANGED WEAPON +-- Simfrost (empty ranged slot) and every wand user (wands are a +-- spell-hit question) => NOTEXP cells, UNREADABLE Simhuntara +-- (hit = { hitRead = false } -- the item tooltips of an out-of-range +-- unit cannot be scanned, exactly like her gear) plus the offline/ +-- read-failed players, who carry no hit table at all. +-- * PER-SCHOOL spread, the reason the tab exists: Simmage is a frost +-- mage with Arcane Focus 5/5 and Elemental Precision 3/3, so her +-- eight columns are NOT one number -- arcane 20.0 (10 generic + 10 +-- talent), fire and frost 16.0 (10 + 6), holy/nature/shadow 10.0. +-- Simlock carries the GEAR side of the same idea: a +3% shadow item +-- line, so his shadow column reads 3 above his other schools. +-- * TALENT READABILITY: a foreign player's talents arrive over the +-- inspect protocol (scan/talents.lua) and may simply not be in yet, +-- which is why hit.talentsRead exists and why a tooltip without them +-- says the number excludes talents for now. Test mode deliberately +-- marks a HANDFUL of players talent-readable (Simtank, Simrogue, +-- Simmage) and leaves the rest false, so both tooltip states are +-- visible side by side in one screenshot -- it is simulated data, not +-- a claim that foreign talents are readable. Those three carry a +-- MEASURED weapon skill as well, because live both facts have the +-- same single source: being the own character. +-- Everyone else gets a deterministic filler pass (per-class main +-- weapon, ranged subtype from their equipped ranged item, small +-- per-index numbers, no race => plain base skill, talentsRead false). +-- All of it is display-only: a hit cell is never a gap, so pills stay +-- untouched. +-- * FULL-RAID FILL-IN (the fifteen players that take the roster from the +-- original 25-player sample to the 40-man cap, one per open role +-- slot): each is deliberately "clean" -- full consumables/class buffs +-- via one canonical name list (unexpected slots simply ignore names +-- they do not need, never a gap), default gearWith() (every enchant +-- slot filled, both trinkets present), weaponMain=true without a +-- name. They ride the SAME index-based filler passes below as every +-- other filler player in the original 25 for resist/hit/debuffs, so +-- their exact numbers are deliberately UNPINNED (only their existence +-- and readability matter) -- every pinned showcase value above still +-- lives on the unchanged original 25. Two of them (Simarrow, +-- Simscout) carry a real ranged weapon so the RANGED group's HIT tab +-- is not entirely NOTEXP; Simveil is the 3rd offline case. Column- +-- width stress: Simox is the shortest name in the roster (5 +-- characters), Simthornweaver and Simshadowmender the longest +-- (14 / 15). +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Sim = DC_Sim or {} + +-- ------------------------------------------------------------------ +-- Built-in role confirmations. Everyone EXCEPT Simfury and Simshade -- +-- those two stay unconfirmed so the suggestion flow (dashed badge, ?-mark, +-- reason tooltip) is visible in test mode. Copied into DC.simRoles by +-- buildStore(); NEVER into db.roles. +-- Simsham is a confirmed MELEE (enhancement) although the class heuristic +-- suggests HEALER -- showcases confirmed-beats-suggested. +-- ------------------------------------------------------------------ +DC_Sim.ROLES = { + Simtank = "TANK", Simwall = "TANK", Simsteel = "TANK", + Simrogue = "MELEE", Simblade = "MELEE", Simstab = "MELEE", + Simkitty = "MELEE", Simsham = "MELEE", + Simhuntara = "RANGED", Simbow = "RANGED", Simtrue = "RANGED", + Simmage = "CASTER", Simfrost = "CASTER", Simzap = "CASTER", + Simlock = "CASTER", Simafflic = "CASTER", Simboom = "CASTER", + Simheala = "HEALER", Simlight = "HEALER", Simtree = "HEALER", + Simchain = "HEALER", Simholy = "HEALER", Simglow = "HEALER", + -- Simfury, Simshade intentionally absent -> suggestion badges + + -- Full-raid fill-in (25 -> 40): one open role slot each, all + -- confirmed (no additional suggestion badges beyond the two above). + Simblock = "TANK", + Simgrit = "MELEE", Simslash = "MELEE", Simox = "MELEE", + Simjolt = "MELEE", Simzeal = "MELEE", + Simarrow = "RANGED", Simscout = "RANGED", Simveil = "RANGED", + Simember = "CASTER", Simdread = "CASTER", Simvoid = "CASTER", + Simthornweaver = "CASTER", + Simshadowmender = "HEALER", Simbloom = "HEALER", +} + +-- ------------------------------------------------------------------ +-- Small builders (fresh tables on every call -- the store must never share +-- table references between builds or players). +-- ------------------------------------------------------------------ + +-- list -> set +local function set(list) + local t = {} + for i = 1, table.getn(list) do + t[list[i]] = true + end + return t +end + +-- ------------------------------------------------------------------ +-- Canonical "clean" aura set for the 40-man fill-in players (see the +-- header): one name per consumable/class-buff slot, always paired with +-- a working ICONS entry (icon coverage stays satisfied). A slot the +-- player's role/class does not expect simply ignores its name -- an +-- unexpected slot is NOTEXP regardless of what auras are present -- so +-- one shared list is safe to reuse across every role and class. +-- cleanAuras() returns a FRESH table on every call (set() already builds +-- a new table each time); the source list itself is read-only. +-- ------------------------------------------------------------------ +local CLEAN_AURA_NAMES = { + "Flask of the Titans", "Well Fed", "Juju Might", "Juju Power", + "Elixir of the Mongoose", "Greater Arcane Elixir", "Mageblood Potion", + "Elixir of Superior Defense", "Elixir of Fortitude", + "Greater Fire Protection", "Mark of the Wild", "Power Word: Fortitude", + "Shadow Protection", "Arcane Intellect", "Soulstone Resurrection", +} + +local function cleanAuras() + return { names = set(CLEAN_AURA_NAMES), ids = {} } +end + +-- ------------------------------------------------------------------ +-- Aura icons: plausible vanilla icon paths for every +-- buff name the sim can turn into a HAS cell. Cosmetic sim data -- paths +-- only have to LOOK real, the client never loads them offline. +-- ------------------------------------------------------------------ +local ICONS = { + -- FLASK + ["Flask of the Titans"] = "Interface\\Icons\\INV_Potion_62", + ["Flask of Supreme Power"] = "Interface\\Icons\\INV_Potion_41", + ["Flask of Distilled Wisdom"] = "Interface\\Icons\\INV_Potion_97", + ["Flask of Chromatic Resistance"] = "Interface\\Icons\\INV_Potion_48", + -- FOOD + ["Well Fed"] = "Interface\\Icons\\Spell_Misc_Food", + ["Dragonbreath Chili"] = "Interface\\Icons\\INV_Drink_17", + -- AP + ["Winterfall Firewater"] = "Interface\\Icons\\INV_Potion_92", + ["Juju Might"] = "Interface\\Icons\\INV_Misc_MonsterScales_07", + -- STR (aura-name variants share the item icon, as for Mageblood below) + ["Juju Power"] = "Interface\\Icons\\INV_Misc_MonsterScales_11", + ["Elixir of Giants"] = "Interface\\Icons\\INV_Potion_61", + ["Elixir of the Giants"] = "Interface\\Icons\\INV_Potion_61", + ["Strike of the Scorpok"] = "Interface\\Icons\\INV_Misc_MonsterScales_09", + ["Rage of Ages"] = "Interface\\Icons\\INV_Scroll_03", + -- AGI + ["Elixir of the Mongoose"] = "Interface\\Icons\\INV_Potion_32", + ["Elixir of Greater Agility"] = "Interface\\Icons\\INV_Potion_94", + ["Greater Agility"] = "Interface\\Icons\\INV_Potion_94", + -- SP + ["Greater Arcane Elixir"] = "Interface\\Icons\\INV_Potion_25", + ["Arcane Elixir"] = "Interface\\Icons\\INV_Potion_30", + ["Elixir of Shadow Power"] = "Interface\\Icons\\INV_Potion_46", + ["Greater Frost Power"] = "Interface\\Icons\\INV_Potion_13", + -- MP5 (both TWoW name variants share the item icon) + ["Mageblood Potion"] = "Interface\\Icons\\INV_Potion_45", + ["Mageblood"] = "Interface\\Icons\\INV_Potion_45", + -- ARM + ["Elixir of Superior Defense"] = "Interface\\Icons\\INV_Potion_66", + ["Elixir of Greater Defense"] = "Interface\\Icons\\INV_Potion_43", + -- HP + ["Elixir of Fortitude"] = "Interface\\Icons\\INV_Potion_44", + ["Health II"] = "Interface\\Icons\\INV_Potion_44", + ["Rumsey Rum Black Label"] = "Interface\\Icons\\INV_Drink_04", + ["Rumsey Rum"] = "Interface\\Icons\\INV_Drink_08", + -- FR + ["Greater Fire Protection"] = "Interface\\Icons\\INV_Potion_24", + ["Fire Protection"] = "Interface\\Icons\\INV_Potion_16", + -- class buffs + ["Arcane Intellect"] = "Interface\\Icons\\Spell_Holy_MagicalSentry", + ["Arcane Brilliance"] = "Interface\\Icons\\Spell_Holy_ArcaneIntellect", + ["Mark of the Wild"] = "Interface\\Icons\\Spell_Nature_Regeneration", + ["Gift of the Wild"] = "Interface\\Icons\\Spell_Nature_GiftoftheWild", + ["Power Word: Fortitude"] = "Interface\\Icons\\Spell_Holy_WordFortitude", + ["Prayer of Fortitude"] = "Interface\\Icons\\Spell_Holy_PrayerOfFortitude", + ["Shadow Protection"] = "Interface\\Icons\\Spell_Shadow_AntiShadow", + ["Soulstone Resurrection"] = "Interface\\Icons\\Spell_Shadow_SoulGem", +} + +-- Debuff cosmetics. Resurrection Sickness and Weakened Soul genuinely +-- share one icon on 1.12 (both use +-- Spell_Holy_AshesToAshes -- realism over showcase variety). +local DEBUFF_ICONS = { + ["Resurrection Sickness"] = "Interface\\Icons\\Spell_Holy_AshesToAshes", + ["Curse of Recklessness"] = "Interface\\Icons\\Spell_Shadow_UnholyStrength", + ["Weakened Soul"] = "Interface\\Icons\\Spell_Holy_AshesToAshes", +} + +-- debuff literal builder: names + ids given, textures from DEBUFF_ICONS +-- (fresh tables per call -- store builds must never share references) +local function debuffsOf(namesList, idsList) + local d = { names = {}, ids = {}, textures = {} } + for i = 1, table.getn(namesList) do + local nm = namesList[i] + d.names[nm] = true + if DEBUFF_ICONS[nm] then + d.textures[nm] = DEBUFF_ICONS[nm] + end + end + for i = 1, table.getn(idsList) do + d.ids[idsList[i]] = true + end + return d +end + +-- auras -> textures table (store shape: auras.textures[name] = icon path). +-- Names are taken directly; ids are resolved against the REAL data tables +-- (every table name that carries one of the player's ids gets its icon), +-- so whatever alias DC_Model's id->name index picks, the texture is there. +local function texturesFor(auras) + local t = {} + for name in pairs(auras.names) do + if ICONS[name] then + t[name] = ICONS[name] + end + end + if DC.CONSUMABLES then + for name, def in pairs(DC.CONSUMABLES) do + if def.spellID and def.spellID > 0 and auras.ids[def.spellID] + and ICONS[name] then + t[name] = ICONS[name] + end + end + end + if DC.CLASSBUFFS then + for name, def in pairs(DC.CLASSBUFFS) do + if def.ids then + for i = 1, table.getn(def.ids) do + if auras.ids[def.ids[i]] and ICONS[name] then + t[name] = ICONS[name] + end + end + end + end + end + return t +end + +-- Main-hand item subtype per class for the hit filler pass below (the +-- weapon skill the melee-hit cap is derived from). Plausible vanilla +-- choices, nothing more -- the showcase players carry explicit literals. +local HIT_MAIN_SUB = { + WARRIOR = "One-Handed Swords", + PALADIN = "Two-Handed Maces", + HUNTER = "Polearms", + ROGUE = "Daggers", + PRIEST = "Staves", + SHAMAN = "One-Handed Maces", + MAGE = "Staves", + WARLOCK = "Staves", + DRUID = "Staves", +} + +-- All 16 equipment slots the scan reads (11 enchant + neck + 2 rings + +-- 2 trinket invSlots). +local INV_SLOTS = { 1, 2, 3, 15, 5, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 } + +-- Per-slot base item cosmetics: EVERY gear entry table carries texture, +-- name and quality (the UI renders item icons). +local SLOT_BASE = { + [1] = { name = "Simulated Faceguard", quality = 3, texture = "Interface\\Icons\\INV_Helmet_09" }, + [2] = { name = "Simulated Choker", quality = 3, texture = "Interface\\Icons\\INV_Jewelry_Necklace_07" }, + [3] = { name = "Simulated Spaulders", quality = 3, texture = "Interface\\Icons\\INV_Shoulder_01" }, + [15] = { name = "Simulated Drape", quality = 3, texture = "Interface\\Icons\\INV_Misc_Cape_16" }, + [5] = { name = "Simulated Breastplate", quality = 4, texture = "Interface\\Icons\\INV_Chest_Plate04" }, + [9] = { name = "Simulated Bracers", quality = 3, texture = "Interface\\Icons\\INV_Bracer_13" }, + [10] = { name = "Simulated Gauntlets", quality = 3, texture = "Interface\\Icons\\INV_Gauntlets_28" }, + [7] = { name = "Simulated Legplates", quality = 4, texture = "Interface\\Icons\\INV_Pants_04" }, + [8] = { name = "Simulated Sabatons", quality = 3, texture = "Interface\\Icons\\INV_Boots_Plate_06" }, + [16] = { name = "Simulated Edge", quality = 4, texture = "Interface\\Icons\\INV_Sword_39" }, + [17] = { name = "Simulated Barrier", quality = 3, texture = "Interface\\Icons\\INV_Shield_06" }, + [18] = { name = "Simulated Repeater", quality = 2, texture = "Interface\\Icons\\INV_Weapon_Crossbow_02" }, + [11] = { name = "Simulated Signet", quality = 3, texture = "Interface\\Icons\\INV_Jewelry_Ring_21" }, + [12] = { name = "Simulated Band", quality = 2, texture = "Interface\\Icons\\INV_Jewelry_Ring_14" }, + [13] = { name = "Simulated Charm", quality = 2, texture = "Interface\\Icons\\INV_Misc_OrnateBox" }, + [14] = { name = "Simulated Insignia", quality = 3, texture = "Interface\\Icons\\INV_Jewelry_Talisman_07" }, +} + +-- Base gear: every slot filled and enchanted (enchant 900+invSlot except +-- neck/rings/trinkets), every entry with name/quality/texture from +-- SLOT_BASE. Table overrides are MERGED with the slot cosmetics (missing +-- name/quality/texture filled in from SLOT_BASE, everything else -- id, +-- enchant, subType, own cosmetics -- kept); "EMPTY" stays "EMPTY". +-- Neck/rings (2/11/12) default to enchant=0 like trinkets -- but unlike +-- trinkets they ARE enchantable on this server (data/enchants.lua, kind +-- "ench"), so Simtank's override below demonstrates a real neck enchant +-- (their expectation defaults to OFF, so by default this renders the new +-- "not expected, item still shown" tile -- see core/model.lua). +local function gearWith(over) + local g = {} + for i = 1, table.getn(INV_SLOTS) do + local inv = INV_SLOTS[i] + local base = SLOT_BASE[inv] + local e = { id = 5000 + inv, enchant = 900 + inv, + quality = base.quality, name = base.name, + texture = base.texture } + if inv == 2 or inv == 11 or inv == 12 or inv == 13 or inv == 14 then + e.enchant = 0 -- neck/rings/trinkets: enchant-free baseline + end + g[inv] = e + end + if over then + for inv, v in pairs(over) do + if type(v) == "table" then + local base = SLOT_BASE[inv] + if v.name == nil then + v.name = base.name + end + if v.quality == nil then + v.quality = base.quality + end + if v.texture == nil then + v.texture = base.texture + end + end + g[inv] = v + end + end + return g +end + +-- ------------------------------------------------------------------ +-- The roster. Deterministic literals only. +-- ------------------------------------------------------------------ +function DC_Sim.buildStore() + -- deterministic clock (see header): base for scanAt + -- and the last-known At offsets below + local now = 1000 + if type(GetTime) == "function" then + now = GetTime() + end + + -- reset transient sim confirmations to the built-in set (fresh copy) + DC.simRoles = {} + for name, role in pairs(DC_Sim.ROLES) do + DC.simRoles[name] = role + end + + local players = {} + local function add(p) + table.insert(players, p) + end + + -- ================= TANKS (3) -- all readable, 0 gaps in every tab ==== + + -- Simtank: spell-ID-only auras (names empty) -> proves the ID match + -- path alone. Thrown weapon -> ranged enchant NOTEXP. Epic + rare + -- trinkets (WoW quality: 4 = epic, 3 = rare). Neck enchant demo (D6): + -- a real neck enchant>0 -- NECK's expectation defaults to OFF, so this + -- shows the "not expected, item still shown, bright, no marker" tile + -- by default; enabling the checkbox for TANK/WARRIOR would flip it to + -- HAS with a green corner marker (same data, no sim change needed). + -- Fire resistance 315 -- highest of the three tanks (showcase spread). + add{ name = "Simtank", class = "WARRIOR", online = true, + guid = "0xF530000000000001", subgroup = 1, + aurasRead = true, + auras = { names = {}, ids = set({ 17626, 15852, 16323, 17538, + 11348, 3593, 17543, 9885, 10938, 10958 }) }, + -- FLASK Titans, FOOD Chili, STR JujuPower, AGI Mongoose, ARM SupDef, + -- HP Fortitude, FR GFPP, MOTW r7, PWF r6, SPROT r3 + weaponMain = true, weaponMainName = "Dense Sharpening Stone", + gearRead = true, + resistsRead = true, resists = { [2] = 315 }, + -- hit: the MEASURED-skill showcase (what the own character looks + -- like) -- skillExact means the 305 came from the skill window, + -- which lowers the yellow cap to 7.0; melee 8 sits above it. + -- talentsRead with NO talent numbers is its own state and a real + -- one: a protection warrior has no hit talent at all, so this is + -- a VERIFIED zero rather than the "not received yet" case below. + race = "Human", + hit = { melee = 8, spell = 0, schools = {}, hitRead = true, + mainSub = "One-Handed Maces", offSub = "Shields", + rangedSub = "Thrown", skillName = "Maces", + skill = 305, skillExact = true, talentsRead = true }, + gear = gearWith({ + [17] = { id = 5117, enchant = 917, subType = "Shields", quality = 3 }, + [18] = { id = 5118, enchant = 0, subType = "Thrown", + name = "Simulated Throwing Axe" }, + [2] = { id = 5299, enchant = 950, name = "Simulated Neck Chain" }, + [13] = { id = 5213, enchant = 0, quality = 4, name = "Simulated Drake Talisman", + texture = "Interface\\Icons\\INV_Misc_Bone_10" }, + [14] = { id = 5214, enchant = 0, quality = 3, name = "Simulated Hand of Justice", + texture = "Interface\\Icons\\INV_Jewelry_Talisman_01" }, + }) } + + -- Simwall: name-only auras (ids empty = sparse) -> proves the name + -- fallback path alone. Gun with scope (enchant>0) -> ranged HAS. + -- Deliberately weaponMain=true WITHOUT weaponMainName (so + -- the generic "main hand temp enchant" detail path stays exercised). + -- Fire resistance 240. + add{ name = "Simwall", class = "WARRIOR", online = true, + guid = "0xF530000000000002", subgroup = 1, + aurasRead = true, + auras = { names = set({ "Flask of the Titans", "Well Fed", + "Juju Power", "Elixir of the Mongoose", + "Elixir of Superior Defense", "Elixir of Fortitude", + "Greater Fire Protection", "Mark of the Wild", + "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + weaponMain = true, gearRead = true, + resistsRead = true, resists = { [2] = 240 }, + -- hit: the BELOW-cap showcase -- a human swordsman, so the racial + -- specialization is ESTIMATED into the skill (300 + 5 => cap 7.0) + -- and 6 still falls short of it + race = "Human", + hit = { melee = 6, spell = 0, schools = {}, hitRead = true, + talentsRead = false, + mainSub = "One-Handed Swords", offSub = "Shields", + rangedSub = "Guns" }, + gear = gearWith({ + [17] = { id = 5127, enchant = 917, subType = "Shields", quality = 3 }, + [18] = { id = 5128, enchant = 33, subType = "Guns", + name = "Simulated Blunderbuss" }, + }) } + + -- Simsteel: mixed matching -- ids for everything with a known spell ID, + -- "Well Fed" (id-less entry) by name. NOTE the model's strict rule: once + -- a player has ANY ids, names of id-bearing entries no longer count, so + -- a "mixed" player may only name-match id-less entries. Bow with scope. + -- Fire resistance 180 -- lowest of the three tanks. + add{ name = "Simsteel", class = "WARRIOR", online = true, + guid = "0xF530000000000003", subgroup = 1, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 17629, 11405, 11334, 11349, 25804, 7233, + 21849, 21562, 10957 }) }, + -- FLASK ChromRes, STR Giants, AGI GreaterAgi, ARM GreaterDef, + -- HP RumseyBlack, FR FireProt, MOTW Gift, PWF Prayer, SPROT r2 + weaponMain = true, gearRead = true, + resistsRead = true, resists = { [2] = 180 }, + -- hit: the BOOK showcase -- an orc axeman (racial +5 => cap 7.0); + -- ticking "Axes" for this player in the options adds another +3 + -- skill and drops the cap to 6.4 on the very next refresh (the + -- book flag lives in the DB, never in the store) + race = "Orc", + hit = { melee = 8, spell = 0, schools = {}, hitRead = true, + talentsRead = false, + mainSub = "One-Handed Axes", offSub = "Shields", + rangedSub = "Bows" }, + gear = gearWith({ + [17] = { id = 5137, enchant = 917, subType = "Shields", quality = 3 }, + [18] = { id = 5138, enchant = 33, subType = "Bows", + name = "Simulated Longbow" }, + }) } + + -- ================= MELEE (6) ========================================= + + -- Simfury: UNCONFIRMED -> heuristic suggests MELEE ("class default"; + -- no shield). EMPTY off hand (fury with a 2H habit) -> NOTEXP under the + -- model's exemption resolution (no item on an exemptable slot = nothing + -- to enchant). Missing HP + FR consumables, missing PWF + SPROT class + -- buffs, head enchant missing -> whisper-eligible in every tab. + add{ name = "Simfury", class = "WARRIOR", online = true, + guid = "0xF530000000000004", subgroup = 1, + aurasRead = true, + auras = { names = set({ "Well Fed", "Juju Might", "Juju Power", + "Elixir of the Mongoose", "Mark of the Wild" }), ids = {} }, + weaponMain = true, gearRead = true, + gear = gearWith({ + [1] = { id = 5141, enchant = 0 }, -- head enchant missing + [17] = "EMPTY", -- empty off hand -> gap + [18] = { id = 5148, enchant = 0, subType = "Thrown" }, + }) } + + -- Simrogue: ID matches for everything id-bearing, "Well Fed" by name. + -- EMPTY trinket slot 14 (the one empty trinket of the roster). + -- FR + SPROT missing. Afflicted 1/3: Resurrection Sickness (shared + -- with Simtree -> RS afflicted=2 = first debuff column). + add{ name = "Simrogue", class = "ROGUE", online = true, + guid = "0xF530000000000005", subgroup = 2, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 16329, 17538, 16323, 3593, 9885, 10938 }) }, + -- AP JujuMight, AGI Mongoose, STR JujuPower, HP Fortitude, + -- MOTW r7, PWF r6 + debuffs = debuffsOf({ "Resurrection Sickness" }, { 15007 }), + weaponMain = true, gearRead = true, + -- hit: the DUAL-WIELD showcase -- two weapon subtypes, so the cell + -- carries the yellow cap (gnome dagger racial => 7.0, met by 9) + -- AND the white dual-wield cap (26.0) in its tooltip. + -- Also the TALENT showcase on the melee side: the 9 is 4 from + -- gear plus 5 from the rogue's 5/5 melee-hit talent, and the + -- tooltip names both parts instead of one lump sum. + race = "Gnome", + hit = { melee = 9, gearMelee = 4, talentMelee = 5, + talentsRead = true, + -- measured skill goes WITH the read talents: both come + -- from the own character in the live client, so a + -- talent-readable sim player carries a measured skill + -- too (305 is what the gnome dagger racial estimates + -- anyway, so the cap is the same either way) + skill = 305, skillExact = true, skillName = "Daggers", + spell = 0, schools = {}, hitRead = true, + mainSub = "Daggers", offSub = "One-Handed Swords", + rangedSub = "Thrown" }, + gear = gearWith({ + [18] = { id = 5158, enchant = 0, subType = "Thrown" }, + [12] = "EMPTY", -- empty ring 2 -> gap + [14] = "EMPTY", -- empty trinket -> gap + }) } + + -- Simblade: OFFLINE (auras/gear nil, weaponMain nil, no distance -- + -- a disconnected unit cannot be measured). Carries the + -- runtime cache's LAST-KNOWN snapshot from before the disconnect -- + -- lastAuras (HP/FR consumables + SPROT class buff absent => dimmed + -- MISSING reachable, the rest dimmed HAS) and lastGear (wrist + -- enchant 0 => dimmed MISSING; EMPTY off hand => cached classify + -- NOTEXP => NO cell.last, the plain "?" stays). No lastWeaponMain: + -- the WPN cell shows what a channel WITHOUT cache looks like. + -- IRON RULE: display-only -- Simblade stays UNKNOWN in every count. + local bladeAuras = { names = set({ "Well Fed", "Juju Might", + "Juju Power", "Elixir of the Mongoose", "Mark of the Wild", + "Power Word: Fortitude" }), ids = {} } + bladeAuras.textures = texturesFor(bladeAuras) + add{ name = "Simblade", class = "ROGUE", online = false, + guid = "0xF530000000000006", subgroup = 8, + aurasRead = false, gearRead = false, + unreadableReason = "offline", + lastAuras = bladeAuras, lastAurasAt = now - 540, + lastGear = gearWith({ + [9] = { id = 5162, enchant = 0 }, -- wrist: dimmed MISSING + [17] = "EMPTY", -- cached NOTEXP -> no .last + [18] = { id = 5163, enchant = 0, subType = "Thrown" }, + }), + lastGearAt = now - 540 } + + -- Simstab: name matching, only gap on consumables = no temp weapon + -- enchant; class buffs complete; two enchant gaps. + add{ name = "Simstab", class = "ROGUE", online = true, + guid = "0xF530000000000007", subgroup = 2, + aurasRead = true, + auras = { names = set({ "Well Fed", "Juju Might", "Juju Power", + "Elixir of Greater Agility", "Elixir of Fortitude", + "Fire Protection", "Mark of the Wild", + "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + weaponMain = false, gearRead = true, + gear = gearWith({ + [9] = { id = 5169, enchant = 0 }, -- wrist enchant missing + [7] = { id = 5167, enchant = 0 }, -- legs enchant missing + [18] = { id = 5168, enchant = 0, subType = "Thrown" }, + }) } + + -- Simkitty: druid in Cat Form (confirmed MELEE anyway). AP/AGI/MOTW via + -- spell ID, FOOD + form by name; STR/HP/FR/WPN missing; druid: EMPTY + -- ranged + EMPTY off hand (staff) -> NOTEXP (exemptable slots). + add{ name = "Simkitty", class = "DRUID", online = true, + guid = "0xF530000000000008", subgroup = 2, + aurasRead = true, + auras = { names = set({ "Cat Form", "Well Fed" }), + ids = set({ 16329, 17538, 9885 }) }, + weaponMain = false, gearRead = true, + gear = gearWith({ + [17] = "EMPTY", + [18] = "EMPTY", + }) } + + -- Simsham: enhancement -- CONFIRMED MELEE although the heuristic would + -- say HEALER. Full consumables/class buffs (ids + "Well Fed" by name); + -- shaman: EMPTY ranged slot -> NOTEXP. + -- Note (roles stay as-is, no + -- shipped default changes): a shaman is also the per-class + -- expectation poster child -- a TANK-confirmed shaman would get + -- expectations.TANK.SHAMAN.AI = true (INT for the mana-fed tank kit) + -- flipped by the user in the options grid; AI ships as the CASTER/HEALER + -- role default for every performing class. + add{ name = "Simsham", class = "SHAMAN", online = true, + guid = "0xF530000000000009", subgroup = 2, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 17038, 11405, 17538, 25804, 17543, + 9885, 10938, 10958 }) }, + -- AP Firewater, STR Giants, AGI Mongoose, HP RumseyBlack, FR GFPP, + -- MOTW r7, PWF r6, SPROT r3 + weaponMain = true, gearRead = true, + gear = gearWith({ + [17] = { id = 5197, enchant = 917, subType = "Shields", quality = 3 }, + [18] = "EMPTY", + }) } + + -- ================= RANGED (3) ======================================== + + -- Simhuntara: AP/AGI/MOTW via spell ID; HP/FR + PWF/SPROT missing. + -- Far case 1/2: readable at distance 55 (>= FAR_LIMIT 40). + -- THE cross-continent case -- the aura paths reach + -- anywhere, gear inspection does not: gearRead=false + gear nil on an + -- ONLINE, aura-readable player, with the runtime cache's lastGear + -- attached (a 20-minutes-old snapshot). EQUIPMENT renders the + -- "out of range" row note + dimmed tiles; nothing counts (IRON RULE). + -- Nature resistance 120 -- resist reads are their OWN channel, + -- independent of gearRead=false above (a resistance read is neither + -- an aura nor a gear read). + add{ name = "Simhuntara", class = "HUNTER", online = true, + guid = "0xF53000000000000A", subgroup = 4, distance = 55, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 16329, 17538, 9885 }) }, + weaponMain = true, gearRead = false, + resistsRead = true, resists = { [3] = 120 }, + -- hit: THE unreadable showcase -- hit is summed from item + -- tooltips, so it dies with the same range limit as the gear read + -- (resistances survive it, they are their own channel) + hit = { hitRead = false }, + lastGear = gearWith({ + [18] = { id = 5178, enchant = 33, subType = "Guns", + name = "Simulated Rifle" }, + }), + lastGearAt = now - 1200 } + + -- Simbow: fully clean row (green contrast inside a non-green group). + add{ name = "Simbow", class = "HUNTER", online = true, + guid = "0xF53000000000000B", subgroup = 3, distance = 31, + aurasRead = true, + auras = { names = set({ "Well Fed", "Juju Might", + "Elixir of Greater Agility", "Rumsey Rum", + "Greater Fire Protection", "Mark of the Wild", + "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + weaponMain = true, gearRead = true, + -- hit: the RANGED showcase -- a real bow, so the ranged column is + -- a question at all; 8 meets its 8.0 cap (a dwarf's racial is on + -- guns, not bows, so nothing lowers this one) + race = "Dwarf", + hit = { melee = 8, spell = 0, schools = {}, hitRead = true, + talentsRead = false, + mainSub = "Polearms", rangedSub = "Bows" }, + gear = gearWith({ + [18] = { id = 5188, enchant = 33, subType = "Bows", + name = "Simulated Warbow" }, + }) } + + -- Simtrue: READ FAILED (online, but the aura path returned zero auras). + -- distance recorded as diagnosis, never as filter. + add{ name = "Simtrue", class = "HUNTER", online = true, + guid = "0xF53000000000000C", subgroup = 8, distance = 203, + aurasRead = false, gearRead = false, + unreadableReason = "read failed" } + + -- ================= CASTERS (7) ======================================= + + -- Simmage: everything id-bearing via spell ID, FOOD by name; FR + -- missing. Wand -> ranged NOTEXP, holdable frill -> off hand NOTEXP. + -- Frost 85 + arcane 45 -- the two-school showcase (every other + -- resist-carrying player below has only one school set explicitly). + add{ name = "Simmage", class = "MAGE", online = true, + guid = "0xF53000000000000D", subgroup = 4, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 17628, 23028, 17539, 24363, 9885, 10938, 10958 }) }, + -- FLASK Supreme, AI Brilliance, SP GAE, MP5 Mageblood, MOTW, PWF, SPROT + weaponMain = true, weaponMainName = "Brilliant Wizard Oil", + gearRead = true, + resistsRead = true, resists = { [4] = 85, [6] = 45 }, + -- hit: THE per-school showcase and the reason the tab has eight + -- columns. 10% generic spell hit from gear, and a frost mage's + -- two school talents on top: Arcane Focus 5/5 (+10 arcane) and + -- Elemental Precision 3/3 (+6 frost AND fire). Her columns + -- therefore read arcane 20.0, fire 16.0, frost 16.0, everything + -- else 10.0 -- three different numbers on ONE caster, which no + -- single spell-hit column could ever show. The wand makes the + -- ranged column NOTEXP and the orb in the off hand is not a + -- weapon, so no dual-wield note either. + race = "Gnome", + hit = { melee = 0, spell = 10, gearSpell = 10, schools = {}, + talentsRead = true, talentSpell = 0, + talentSchools = { arcane = 10, fire = 6, frost = 6 }, + -- measured skill rides along with the read talents (see + -- Simrogue): a staff has no racial, so 300 is also what + -- the estimate would have produced + skill = 300, skillExact = true, skillName = "Staves", + hitRead = true, mainSub = "Staves", + offSub = "Miscellaneous", rangedSub = "Wands" }, + gear = gearWith({ + [17] = { id = 5207, enchant = 0, subType = "Miscellaneous", + name = "Simulated Orb" }, + [18] = { id = 5208, enchant = 0, subType = "Wands", + name = "Simulated Wand" }, + }) } + + -- Simfrost: name-only, several gaps (MP5/FR/WPN missing; MOTW/PWF/SPROT + -- missing; head+shoulder enchants missing; EMPTY ranged on a mage -- + -- forgot the wand). Afflicted 2/3: Curse of Recklessness + Weakened + -- Soul (TWO debuff columns from one player). Far case 2/2: readable + -- at distance 120. + add{ name = "Simfrost", class = "MAGE", online = true, + guid = "0xF53000000000000E", subgroup = 4, distance = 120, + aurasRead = true, + auras = { names = set({ "Flask of Supreme Power", "Well Fed", + "Arcane Elixir", "Arcane Intellect" }), ids = {} }, + debuffs = debuffsOf({ "Curse of Recklessness", "Weakened Soul" }, + { 11717, 6788 }), + weaponMain = false, gearRead = true, + -- hit: BELOW the spell cap (8 of 16) and an EMPTY ranged slot -- + -- the second NOTEXP path (no ranged item at all, not just a wand) + race = "Undead", + hit = { melee = 0, spell = 8, schools = {}, hitRead = true, + talentsRead = false, mainSub = "Staves" }, + gear = gearWith({ + [1] = { id = 5211, enchant = 0 }, + [3] = { id = 5212, enchant = 0 }, + [18] = "EMPTY", + }) } + + -- Simzap: OFFLINE. + add{ name = "Simzap", class = "MAGE", online = false, + guid = "0xF53000000000000F", subgroup = 8, + aurasRead = false, gearRead = false, + unreadableReason = "offline" } + + -- Simlock: THE wand warlock (ranged enchant NOTEXP via subtype). + -- Name-only reader (ids empty) so the "Mageblood" TWoW alias entry is + -- exercised via the name fallback. One gap: SPROT. + add{ name = "Simlock", class = "WARLOCK", online = true, + guid = "0xF530000000000010", subgroup = 5, + aurasRead = true, + auras = { names = set({ "Well Fed", "Flask of Supreme Power", + "Greater Arcane Elixir", "Mageblood", "Fire Protection", + "Arcane Intellect", "Mark of the Wild", + "Power Word: Fortitude" }), + ids = {} }, + weaponMain = true, gearRead = true, + -- hit: the school-specific GEAR showcase, and the counterpart to + -- Simmage's talents -- an item line worth +3% shadow spell hit + -- lifts his shadow column to 9.0 while every other school reads + -- 6.0. talentsRead stays false: this fixture models a player whose + -- inspect reply (scan/talents.lua) has not arrived yet, so the + -- number is honestly + -- gear-only and every tooltip on this row says so (a warlock + -- would realistically add shadow talents on top of this). + hit = { melee = 0, spell = 6, gearSpell = 6, + schools = { Shadow = 3 }, + talentsRead = false, + hitRead = true, mainSub = "Staves", rangedSub = "Wands" }, + gear = gearWith({ + [18] = { id = 5218, enchant = 0, subType = "Wands", + name = "Simulated Ghastly Wand" }, + }) } + + -- Simafflic: many consumable gaps; Soulstone on a CASTER -> SOUL cell + -- is NOTEXP although the aura is present (expectation wins). + add{ name = "Simafflic", class = "WARLOCK", online = true, + guid = "0xF530000000000011", subgroup = 5, + aurasRead = true, + auras = { names = set({ "Well Fed", "Soulstone Resurrection" }), + ids = {} }, + weaponMain = false, gearRead = true, + gear = gearWith({ + [8] = { id = 5228, enchant = 0 }, -- feet enchant missing + [15] = { id = 5225, enchant = 0 }, -- back enchant missing + [18] = { id = 5229, enchant = 0, subType = "Wands" }, + }) } + + -- Simshade: UNCONFIRMED shadow priest -> heuristic suggests CASTER + -- ("Shadowform at scan time"). Otherwise a completely clean row: + -- a suggestion badge is not a gap. + add{ name = "Simshade", class = "PRIEST", online = true, + guid = "0xF530000000000012", subgroup = 5, + aurasRead = true, + auras = { names = set({ "Shadowform", "Flask of Supreme Power", + "Well Fed", "Elixir of Shadow Power", "Mageblood Potion", + "Greater Fire Protection", "Arcane Intellect", + "Mark of the Wild", "Power Word: Fortitude", + "Shadow Protection" }), ids = {} }, + weaponMain = true, gearRead = true, + gear = gearWith({ + [18] = { id = 5238, enchant = 0, subType = "Wands" }, + }) } + + -- Simboom: moonkin (confirmed CASTER). FLASK/MP5/FR/WPN missing, + -- MOTW/PWF/SPROT missing, wrist enchant missing, EMPTY ranged. + add{ name = "Simboom", class = "DRUID", online = true, + guid = "0xF530000000000013", subgroup = 6, + aurasRead = true, + auras = { names = set({ "Moonkin Form", "Well Fed", + "Greater Arcane Elixir", "Arcane Intellect" }), ids = {} }, + weaponMain = false, gearRead = true, + gear = gearWith({ + [9] = { id = 5249, enchant = 0 }, + [18] = "EMPTY", + }) } + + -- ================= HEALERS (6) ======================================= + + -- Simheala: complete healer, all id-bearing buffs via spell ID (epic + -- trinket, holdable off hand -> NOTEXP). Clean row. + add{ name = "Simheala", class = "PRIEST", online = true, + guid = "0xF530000000000014", subgroup = 6, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 17627, 20765, 24363, 3593, 7233, + 10157, 9885, 10938, 10958 }) }, + -- FLASK Wisdom, SOUL r5, MP5 Mageblood, HP Fortitude, FR FireProt, + -- AI r5, MOTW r7, PWF r6, SPROT r3 + weaponMain = true, gearRead = true, + gear = gearWith({ + [17] = { id = 5257, enchant = 0, subType = "Miscellaneous", + name = "Simulated Prayer Book" }, + [18] = { id = 5258, enchant = 0, subType = "Wands" }, + [13] = { id = 5263, enchant = 0, quality = 4, name = "Simulated Eye of the Naga", + texture = "Interface\\Icons\\INV_Jewelry_Talisman_06" }, + }) } + + -- Simlight: READ FAILED, distance 87. + add{ name = "Simlight", class = "PRIEST", online = true, + guid = "0xF530000000000015", subgroup = 8, distance = 87, + aurasRead = false, gearRead = false, + unreadableReason = "read failed" } + + -- Simtree: HP/FR/WPN missing, SOUL missing, hands enchant missing, + -- EMPTY ranged. "Mageblood" alias again on the MP5 slot. Afflicted + -- 3/3: shares Resurrection Sickness with Simrogue (RS afflicted=2). + add{ name = "Simtree", class = "DRUID", online = true, + guid = "0xF530000000000016", subgroup = 6, + aurasRead = true, + auras = { names = set({ "Flask of Distilled Wisdom", "Well Fed", + "Mageblood", "Arcane Intellect", "Mark of the Wild", + "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + debuffs = debuffsOf({ "Resurrection Sickness" }, { 15007 }), + weaponMain = false, gearRead = true, + gear = gearWith({ + [10] = { id = 5270, enchant = 0 }, + [18] = "EMPTY", + }) } + + -- Simchain: resto shaman, SPROT + SOUL missing, EMPTY ranged -> NOTEXP. + add{ name = "Simchain", class = "SHAMAN", online = true, + guid = "0xF530000000000017", subgroup = 7, + aurasRead = true, + auras = { names = set({ "Well Fed" }), + ids = set({ 17627, 24363, 25804, 7233, 10156, 9885, 10938 }) }, + -- FLASK Wisdom, MP5 Mageblood, HP RumseyBlack, FR FireProt, + -- AI r4, MOTW r7, PWF r6 + weaponMain = true, gearRead = true, + gear = gearWith({ + [17] = { id = 5277, enchant = 917, subType = "Shields", quality = 3 }, + [18] = "EMPTY", + }) } + + -- Simholy: name-only healer, complete on auras incl. SOUL; chest + -- enchant missing, EMPTY ranged (paladin), holdable off hand NOTEXP. + add{ name = "Simholy", class = "PALADIN", online = true, + guid = "0xF530000000000018", subgroup = 7, + aurasRead = true, + auras = { names = set({ "Flask of Distilled Wisdom", "Well Fed", + "Mageblood Potion", "Elixir of Fortitude", + "Greater Fire Protection", "Arcane Intellect", + "Mark of the Wild", "Power Word: Fortitude", + "Shadow Protection", "Soulstone Resurrection" }), ids = {} }, + weaponMain = true, gearRead = true, + gear = gearWith({ + [5] = { id = 5285, enchant = 0 }, + [17] = { id = 5287, enchant = 0, subType = "Miscellaneous", + name = "Simulated Libram of Hope" }, + [18] = "EMPTY", + }) } + + -- Simglow: HP via the id-less "Rumsey Rum" name entry (spellID 0) WHILE + -- ids are present -- exercises exactly the model rule that id-less + -- names still count for players with ids. SPROT missing, EMPTY ranged. + add{ name = "Simglow", class = "PALADIN", online = true, + guid = "0xF530000000000019", subgroup = 7, + aurasRead = true, + auras = { names = set({ "Well Fed", "Rumsey Rum" }), + ids = set({ 17627, 21850, 21564, 24363, 7233, 1461, 20762 }) }, + -- FLASK Wisdom, MOTW Gift, PWF Prayer, MP5 Mageblood, FR FireProt, + -- AI r3, SOUL r2 + weaponMain = true, gearRead = true, + gear = gearWith({ + [17] = { id = 5297, enchant = 917, subType = "Shields", quality = 3 }, + [18] = "EMPTY", + }) } + + -- ================= FULL-RAID FILL-IN (25 -> 40, see header) ========= + -- Every player below is deliberately "clean": cleanAuras() covers + -- every consumable/class-buff slot any role could expect, default + -- gearWith() leaves every enchant slot filled and both trinkets + -- present, weaponMain=true carries no name (the generic WPN detail + -- path, like Simwall -- keeps the "exactly two named enchants" count + -- unchanged). Resist/hit/debuffs come from the index-based filler + -- passes below, exactly like every other filler player in the + -- original 25 -- their numbers are deliberately unpinned. + + -- Simblock: the 4th tank (protection paladin) -- keeps the TANK group + -- entirely readable with 0 gaps (the green-pill case stays a green + -- pill at 4 members). RNGD is class-exempt for paladins either way. + add{ name = "Simblock", class = "PALADIN", online = true, + guid = "0xF53000000000001A", subgroup = 1, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simgrit", class = "WARRIOR", online = true, + guid = "0xF53000000000001B", subgroup = 2, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simslash", class = "ROGUE", online = true, + guid = "0xF53000000000001C", subgroup = 3, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + -- Simox: melee-spec hunter (a real TWoW spec, see DC_Roles.CLASS_ROLES) + -- and the column-width showcase: the shortest name in the roster. + add{ name = "Simox", class = "HUNTER", online = true, + guid = "0xF53000000000001D", subgroup = 3, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simjolt", class = "SHAMAN", online = true, + guid = "0xF53000000000001E", subgroup = 3, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simzeal", class = "PALADIN", online = true, + guid = "0xF53000000000001F", subgroup = 3, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + -- Simarrow/Simscout: real ranged weapons (unlike the other fill-in + -- players' default ranged slot) so the RANGED group's HIT tab is not + -- entirely NOTEXP -- the hit filler pass below reads gear[18].subType. + add{ name = "Simarrow", class = "HUNTER", online = true, + guid = "0xF530000000000020", subgroup = 4, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, + gear = gearWith({ + [18] = { id = 5426, enchant = 33, subType = "Bows", + name = "Simulated Huntsbow" }, + }) } + + add{ name = "Simscout", class = "HUNTER", online = true, + guid = "0xF530000000000021", subgroup = 4, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, + gear = gearWith({ + [18] = { id = 5427, enchant = 33, subType = "Guns", + name = "Simulated Scattergun" }, + }) } + + -- Simveil: the 3rd offline case (with Simblade/Simzap: 3 offline + 2 + -- read-failed = 5 of 40 unreadable). Deliberately in subgroup 8 with + -- the other 4 unreadable players -- a raid subgroup that is entirely + -- unreadable, the stress case from the header. + add{ name = "Simveil", class = "HUNTER", online = false, + guid = "0xF530000000000022", subgroup = 8, + aurasRead = false, gearRead = false, + unreadableReason = "offline" } + + add{ name = "Simember", class = "MAGE", online = true, + guid = "0xF530000000000023", subgroup = 5, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simdread", class = "WARLOCK", online = true, + guid = "0xF530000000000024", subgroup = 5, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simvoid", class = "PRIEST", online = true, + guid = "0xF530000000000025", subgroup = 6, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + -- Simthornweaver: moonkin druid and one of the two long-name + -- column-width showcases (14 characters). + add{ name = "Simthornweaver", class = "DRUID", online = true, + guid = "0xF530000000000026", subgroup = 6, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + -- Simshadowmender: resto shaman and the other long-name showcase + -- (15 characters, the longest name in the roster). + add{ name = "Simshadowmender", class = "SHAMAN", online = true, + guid = "0xF530000000000027", subgroup = 7, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + add{ name = "Simbloom", class = "DRUID", online = true, + guid = "0xF530000000000028", subgroup = 7, + aurasRead = true, auras = cleanAuras(), + weaponMain = true, gearRead = true, gear = gearWith() } + + -- Icon coverage pass: give every readable player + -- a textures table covering all of their HAS-producing buff names -- + -- derived programmatically from names + ids, so both model match paths + -- deliver an icon. Deterministic (pure function of the literals above). + -- Debuff pass: every READABLE player without a debuff + -- literal gets the EMPTY debuffs shape -- zero debuffs is a legitimate + -- clean state (HAS cells), only the 5 unreadable players stay nil + -- (UNKNOWN cells). + -- Resist filler pass: every readable player who did NOT get an + -- explicit resists literal above (the 3 tanks + Simhuntara + Simmage) + -- gets small, deterministic, per-school-varied values -- a pure + -- function of the player's roster index so re-builds stay + -- byte-identical. Small mod bases (7/8/9/10/11) guarantee zeros show + -- up repeatedly across the roster (a legitimate 0 resistance, not a + -- read failure -- resistsRead is what carries that distinction). The 5 + -- unreadable players (Simblade/Simzap/Simveil offline, Simtrue/ + -- Simlight read failed) never get resistsRead/resists at all -- + -- UNKNOWN cells. + for i = 1, table.getn(players) do + local p = players[i] + if p.auras then + p.auras.textures = texturesFor(p.auras) + end + if p.aurasRead and p.debuffs == nil then + p.debuffs = { names = {}, ids = {}, textures = {} } + end + if p.online ~= false and p.aurasRead and p.resistsRead == nil then + p.resistsRead = true + p.resists = { + [2] = math.mod(i * 3, 7), + [3] = math.mod(i * 3 + 2, 8), + [4] = math.mod(i * 3 + 4, 9), + [5] = math.mod(i * 3 + 6, 10), + [6] = math.mod(i * 3 + 8, 11), + } + end + -- Hit filler pass: every readable player WITHOUT an explicit hit + -- literal above gets a deterministic set (pure function of the + -- roster index, so re-builds stay byte-identical). Small mod + -- bases put a few players on the melee cap (8) and leave everyone + -- below the spell cap (17) -- the players AT the spell cap are + -- the explicit ones. The ranged subtype comes from the player's + -- own equipped ranged item, so a wand user stays NOTEXP and a + -- thrown-weapon user gets a real ranged column. The 5 unreadable + -- players keep no hit table at all -> UNKNOWN cells. + if p.online ~= false and p.aurasRead and p.hit == nil then + local rangedSub = nil + local rangedItem = p.gear and p.gear[18] + if type(rangedItem) == "table" then + rangedSub = rangedItem.subType + end + p.hit = { + melee = math.mod(i * 2, 9), + spell = math.mod(i * 3, 12), + schools = {}, + hitRead = true, + mainSub = HIT_MAIN_SUB[p.class], + rangedSub = rangedSub, + -- the live starting state for everyone but the own + -- character: no inspect reply has arrived yet + -- (scan/talents.lua), so the numbers above are gear-only + -- and every tooltip on these rows says so + + talentsRead = false, + } + -- no race token: the estimate then lands on the plain base + -- skill, which is exactly what an unknown/new race token does + -- in the live path too + end + end + + return { scanAt = now, source = "sim", players = players } +end diff --git a/data/classbuffs.lua b/data/classbuffs.lua new file mode 100644 index 0000000..1e66bbb --- /dev/null +++ b/data/classbuffs.lua @@ -0,0 +1,68 @@ +-- DopingControl data/classbuffs.lua +-- Class-buffs tab: +-- slot definitions + buff name -> slot table with rank spell IDs. +-- +-- [ASSUMPTION] Rank IDs below are from vanilla 1.12 knowledge and are +-- not individually verified in-game. Verify via +-- the probe dump before trusting ID matching; TWoW may add extra ranks. +-- Name-based detection alone is sufficient for tab purposes EXCEPT the +-- "Shadow Protection" name collision: the priest buff and the old +-- protection-potion aura (7242) share the name -> ID-before-name matching +-- is mandatory once both tabs are live. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- ------------------------------------------------------------------ +-- Slot definitions (array, order = column order). Same field shape as +-- DC.SLOTS_CONSUMABLES. Slot ids: AI/MOTW/PWF/SPROT/EMB/SOUL; +-- tile labels/sub-labels: INT/MTW/FRT/SP/EMB/SST. EMB (Emerald Blessing) +-- carries NO seed entry in data/expectations.lua -- see that file's +-- header for why -- so it starts as an inactive dynamic column, exactly +-- like the equipment tab's T1/T2. +-- ------------------------------------------------------------------ +DC.SLOTS_CLASSBUFFS = { + { id = "AI", label = "INT", sub = "Arcane", full = "Arcane Intellect", kind = "aura", + items = "Arcane Intellect / Brilliance (mage)" }, + { id = "MOTW", label = "MTW", sub = "Wild", full = "Mark of the Wild", kind = "aura", + items = "Mark / Gift of the Wild (druid)" }, + { id = "PWF", label = "FRT", sub = "Priest", full = "Power Word: Fortitude", kind = "aura", + items = "Power Word / Prayer of Fortitude (priest)" }, + { id = "SPROT", label = "SP", sub = "situat.", full = "Shadow Protection", kind = "aura", + items = "Shadow Protection (priest) - situational per boss" }, + { id = "EMB", label = "EMB", sub = "hit", full = "Emerald Blessing", kind = "aura", + items = "Emerald Blessing (druid, Emerald Sanctum questline) - also grants +1% spell hit, see Resistances" }, + { id = "SOUL", label = "SST", sub = "healers", full = "Soulstone", kind = "aura", + items = "Soulstone (warlock) - expected only for healers" }, +} + +-- ------------------------------------------------------------------ +-- Buff name -> slot + rank IDs ([ASSUMPTION], see header). +-- ------------------------------------------------------------------ +DC.CLASSBUFFS = { + ["Arcane Intellect"] = { slot = "AI", ids = { 1459, 1460, 1461, 10156, 10157 } }, + ["Arcane Brilliance"] = { slot = "AI", ids = { 23028 } }, + ["Mark of the Wild"] = { slot = "MOTW", ids = { 1126, 5232, 6756, 5234, 8907, 9884, 9885 } }, + ["Gift of the Wild"] = { slot = "MOTW", ids = { 21849, 21850 } }, + ["Power Word: Fortitude"] = { slot = "PWF", ids = { 1243, 1244, 1245, 2791, 10937, 10938 } }, + ["Prayer of Fortitude"] = { slot = "PWF", ids = { 21562, 21564 } }, + ["Shadow Protection"] = { slot = "SPROT", ids = { 976, 10957, 10958 } }, -- name collides with potion aura 7242 + ["Prayer of Shadow Protection"] = { slot = "SPROT", ids = { 27683 } }, + -- raid-wide druid buff on this server; both spell ids seen for it + ["Emerald Blessing"] = { slot = "EMB", ids = { 57108, 57109 } }, + ["Soulstone Resurrection"] = { slot = "SOUL", ids = { 20707, 20762, 20763, 20764, 20765, 20766 } }, +} + +-- ------------------------------------------------------------------ +-- Merge rank IDs into the global spellID -> slotId map started by +-- data/consumables.lua (slot ids are unique across tabs -> no ambiguity; +-- the ID sets are disjoint, test_data.lua asserts this). +-- ------------------------------------------------------------------ +DC.SPELL_TO_SLOT = DC.SPELL_TO_SLOT or {} +for name, def in pairs(DC.CLASSBUFFS) do + local n = table.getn(def.ids) + for i = 1, n do + DC.SPELL_TO_SLOT[def.ids[i]] = def.slot + end +end diff --git a/data/consumables.lua b/data/consumables.lua new file mode 100644 index 0000000..c8f6aaa --- /dev/null +++ b/data/consumables.lua @@ -0,0 +1,132 @@ +-- DopingControl data/consumables.lua +-- Consumables tab: slot definitions + buff->slot table + spellID->slot map. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- ------------------------------------------------------------------ +-- Slot definitions (array, order = column order). +-- Fields: id (stable key, used by DEFAULT_EXPECT / model / UI), +-- label (column tile abbrev), sub (tile sub-label), full (long name, +-- used in whisper/report), kind ("aura" | "weapon"), +-- items (tooltip example text, optional). +-- Separator decision (once for all data files): items strings are +-- tooltip/FontString-only, so they may use the middle-dot list separator +-- "\194\183" (UTF-8 middle dot renders fine in own +-- FontStrings; the group headers already use the same glyph). Em dashes +-- stay ASCII "-" (that glyph is unproven in the 1.12 font). SENT +-- chat lines remain pure ASCII -- they never use these +-- strings. +-- ------------------------------------------------------------------ +DC.SLOTS_CONSUMABLES = { + { id = "FLASK", label = "FLK", sub = "Flask", full = "Flask", kind = "aura", + items = "Flask of the Titans \194\183 Distilled Wisdom \194\183 Supreme Power" }, + { id = "FOOD", label = "FOD", sub = "Food", full = "Food buff", kind = "aura", + items = "Well Fed - e.g. Grilled Squid \194\183 Blessed Sunfruit" }, + { id = "AP", label = "AP", sub = "Attack", full = "Attack power", kind = "aura", + items = "Winterfall Firewater \194\183 Juju Might" }, + { id = "STR", label = "STR", sub = "Str.", full = "Strength", kind = "aura", + items = "Juju Power \194\183 Elixir of Giants" }, + { id = "AGI", label = "AGI", sub = "Agi.", full = "Agility", kind = "aura", + items = "Elixir of the Mongoose" }, + { id = "SP", label = "SP", sub = "Spell", full = "Spell power", kind = "aura", + items = "Greater Arcane Elixir \194\183 Shadow Power \194\183 Firepower" }, + { id = "MP5", label = "MP5", sub = "Mana", full = "Mana regen", kind = "aura", + items = "Mageblood Potion \194\183 Nightfin Soup" }, + { id = "ARM", label = "ARM", sub = "Armor", full = "Armor", kind = "aura", + items = "Elixir of Superior Defense" }, + { id = "HP", label = "HP", sub = "Stam.", full = "Stamina/HP", kind = "aura", + items = "Elixir of Fortitude \194\183 Rumsey Rum Black Label" }, + { id = "FR", label = "FR", sub = "fire", full = "Fire protection", kind = "aura", + items = "Greater Fire Protection Potion - situational per boss; enable/disable via the expectation matrix" }, + { id = "WPN", label = "WPN", sub = "temp.", full = "Weapon (temporary)", kind = "weapon", -- NOT an aura slot + items = "Dense Sharpening Stone \194\183 Brilliant Wizard/Mana Oil - permanent enchant: Equipment tab" }, +} + +-- ------------------------------------------------------------------ +-- Buff name -> slot table. +-- spellID = 0 means "match by name only" (no known aura spell ID). +-- ------------------------------------------------------------------ +DC.CONSUMABLES = { + -- FLASK + ["Flask of the Titans"] = { slot = "FLASK", spellID = 17626, item = "Flask of the Titans" }, + ["Flask of Supreme Power"] = { slot = "FLASK", spellID = 17628, item = "Flask of Supreme Power" }, + ["Flask of Distilled Wisdom"] = { slot = "FLASK", spellID = 17627, item = "Flask of Distilled Wisdom" }, + ["Flask of Chromatic Resistance"] = { slot = "FLASK", spellID = 17629, item = "Flask of Chromatic Resistance" }, + ["Flask of Petrification"] = { slot = "FLASK", spellID = 17624, item = "Flask of Petrification" }, + + -- FOOD (buff names, not item names; TWoW custom foods likely all show "Well Fed" - unverified) + ["Well Fed"] = { slot = "FOOD", spellID = 0, item = "Grilled Squid etc." }, + -- ["Food"] (the eating tick) deliberately REMOVED: it is + -- the sit-and-eat channel aura, not a food buff -- it made the FOOD + -- cell green for anyone currently chewing (field-tested). Only + -- lasting food buffs count. + ["Increased Stamina"] = { slot = "FOOD", spellID = 0, item = "stamina food" }, + ["Dragonbreath Chili"] = { slot = "FOOD", spellID = 15852, item = "Dragonbreath Chili" }, + + -- AP + ["Winterfall Firewater"] = { slot = "AP", spellID = 17038, item = "Winterfall Firewater" }, + ["Juju Might"] = { slot = "AP", spellID = 16329, item = "Juju Might" }, + + -- STR + ["Juju Power"] = { slot = "STR", spellID = 16323, item = "Juju Power" }, + ["Elixir of Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants" }, + ["Elixir of the Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants" }, -- AURA name on this client (measured live: SpellInfo(11405) = "Elixir of the Giants"); the item is named without "the", and matching runs on the aura name first + ["Elixir of Brute Force"] = { slot = "STR", spellID = 17537, item = "Elixir of Brute Force" }, + ["Strike of the Scorpok"] = { slot = "STR", spellID = 10669, item = "Strike of the Scorpok" }, -- measured live in a raid (26 players); was tallied as unknown + ["Rage of Ages"] = { slot = "STR", spellID = 0, item = "R.O.I.D.S." }, -- aura name differs from the item; own spell ID not measured yet + + -- AGI + ["Elixir of the Mongoose"] = { slot = "AGI", spellID = 17538, item = "Elixir of the Mongoose" }, + ["Elixir of Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility" }, + ["Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility" }, -- AURA name (the item name never appears as a buff); was tallied as unknown 14x + ["Elixir of Agility"] = { slot = "AGI", spellID = 11328, item = "Elixir of Agility" }, + + -- SP (merged: arcane_elixir + school_elixir + dreamtonic + dreamshard) + ["Greater Arcane Elixir"] = { slot = "SP", spellID = 17539, item = "Greater Arcane Elixir" }, + ["Arcane Elixir"] = { slot = "SP", spellID = 11390, item = "Arcane Elixir" }, + ["Elixir of Shadow Power"] = { slot = "SP", spellID = 11474, item = "Elixir of Shadow Power" }, + ["Elixir of Frost Power"] = { slot = "SP", spellID = 21920, item = "Elixir of Frost Power" }, + ["Greater Frost Power"] = { slot = "SP", spellID = 56544, item = "Elixir of Greater Frost Power" }, -- AURA name (TurtleWoW-added). NOT measured in-game: taken from a TurtleWoW spell database (56544 = "Greater Frost Power", "Increases frost spell damage by up to 40 for 3600 sec.") plus the item name in pfQuest-turtle's item DB; the aura drops "Elixir of" the same way Giants/Agility above do + ["Elixir of Greater Firepower"] = { slot = "SP", spellID = 26276, item = "Elixir of Greater Firepower" }, + ["Elixir of Firepower"] = { slot = "SP", spellID = 7844, item = "Elixir of Firepower" }, + ["Dreamtonic"] = { slot = "SP", spellID = 45489, item = "Dreamtonic" }, -- use-spell ID; the aura's own ID is unverified + ["Dreamshard Elixir"] = { slot = "SP", spellID = 45427, item = "Dreamshard Elixir" }, -- use-spell ID; the aura's own ID is unverified + + -- MP5 + ["Mageblood Potion"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion" }, + ["Mageblood"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion" }, -- TWoW buff-name variant (old Data.lua kept both) + + -- ARM + ["Elixir of Superior Defense"] = { slot = "ARM", spellID = 11348, item = "Elixir of Superior Defense" }, + ["Elixir of Greater Defense"] = { slot = "ARM", spellID = 11349, item = "Elixir of Greater Defense" }, + + -- HP (merged: def_fortitude + alcohol STA variants) + ["Elixir of Fortitude"] = { slot = "HP", spellID = 3593, item = "Elixir of Fortitude" }, + ["Health II"] = { slot = "HP", spellID = 3593, item = "Elixir of Fortitude" }, -- AURA name on this client (measured live: SpellInfo(3593) = "Health II") + ["Rumsey Rum Black Label"] = { slot = "HP", spellID = 25804, item = "Rumsey Rum Black Label" }, + ["Rumsey Rum"] = { slot = "HP", spellID = 0, item = "Rumsey Rum" }, + ["Rumsey Rum Light"] = { slot = "HP", spellID = 0, item = "Rumsey Rum Light" }, + ["Medivh's Merlot"] = { slot = "HP", spellID = 57106, item = "Medivh's Merlot" }, -- use-spell ID; the aura's own ID is unverified + ["Gordok Green Grog"] = { slot = "HP", spellID = 0, item = "Gordok Green Grog" }, + + -- FR + ["Greater Fire Protection"] = { slot = "FR", spellID = 17543, item = "Greater Fire Protection Potion" }, + ["Fire Protection"] = { slot = "FR", spellID = 7233, item = "Fire Protection Potion" }, + + -- WPN: intentionally NO aura entries - temp enchant is not a buff +} + +-- ------------------------------------------------------------------ +-- spellID -> slotId map, generated from DC.CONSUMABLES (spellID > 0). +-- Collision rule: at classify time an ID match beats a +-- name match. data/classbuffs.lua later MERGES its rank IDs into this same +-- map (slot ids are unique across tabs, so one flat map is safe). +-- ------------------------------------------------------------------ +DC.SPELL_TO_SLOT = {} +for name, def in pairs(DC.CONSUMABLES) do + if def.spellID and def.spellID > 0 then + DC.SPELL_TO_SLOT[def.spellID] = def.slot + end +end diff --git a/data/enchants.lua b/data/enchants.lua new file mode 100644 index 0000000..e2b28d9 --- /dev/null +++ b/data/enchants.lua @@ -0,0 +1,95 @@ +-- DopingControl data/enchants.lua +-- Equipment tab: 14 enchant slots + 2 item-tile slots (both trinkets), in +-- paperdoll order, with 1.12 inventory slot IDs. +-- Enchant columns are checked present/missing only (enchant ID in the item +-- link ~= 0); an EMPTY enchant slot is always a gap (core/model.lua), so it +-- is never gated by the expectation checkbox -- only the "has an enchant" +-- question is. Item-tile slots (trinkets) show the equipped item and +-- ignore expectations entirely; only an EMPTY slot counts as a gap there. +-- +-- Server note: neck and both rings ARE enchantable on this server (a +-- server-custom feature beyond vanilla WoW, where they take no enchant) -- +-- NECK/R1/R2 are therefore kind="ench" like any armor slot, not item +-- tiles. Their expectation defaults to OFF (data/expectations.lua): unlike +-- the ARMOR enchant slots, a neck/ring enchant is not assumed baseline +-- raid gear, so the columns only track it once a role/class opts in via +-- the options grid. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- ------------------------------------------------------------------ +-- Slot definitions (array, order = column order). Same field shape as the +-- other tabs plus invSlot (GetInventoryItemLink slot number). Ids +-- HEAD/SHLD/... with tile labels HEA/SHO/... +-- ------------------------------------------------------------------ +DC.SLOTS_EQUIPMENT = { + { id = "HEAD", label = "HEA", sub = "slot 1", full = "Head", kind = "ench", invSlot = 1 }, + { id = "NECK", label = "NCK", sub = "slot 2", full = "Neck", kind = "ench", invSlot = 2 }, -- enchantable on this server (custom, unlike vanilla) + { id = "SHLD", label = "SHO", sub = "slot 3", full = "Shoulders", kind = "ench", invSlot = 3 }, + { id = "BACK", label = "BCK", sub = "slot 15", full = "Back", kind = "ench", invSlot = 15 }, + { id = "CHST", label = "CHE", sub = "slot 5", full = "Chest", kind = "ench", invSlot = 5 }, + { id = "WRST", label = "WRI", sub = "slot 9", full = "Wrist", kind = "ench", invSlot = 9 }, + { id = "HAND", label = "HND", sub = "slot 10", full = "Hands", kind = "ench", invSlot = 10 }, + { id = "LEGS", label = "LEG", sub = "slot 7", full = "Legs", kind = "ench", invSlot = 7 }, + { id = "FEET", label = "FEE", sub = "slot 8", full = "Feet", kind = "ench", invSlot = 8 }, + { id = "MAIN", label = "MH", sub = "slot 16", full = "Main hand", kind = "ench", invSlot = 16 }, + { id = "OFFH", label = "OH", sub = "slot 17", full = "Off hand", kind = "ench", invSlot = 17 }, + { id = "RNGD", label = "RNG", sub = "slot 18", full = "Ranged (scope)", kind = "ench", invSlot = 18 }, -- scope counts as the enchant + { id = "R1", label = "R1", sub = "slot 11", full = "Ring 1", kind = "ench", invSlot = 11 }, -- enchantable on this server (custom, unlike vanilla) + { id = "R2", label = "R2", sub = "slot 12", full = "Ring 2", kind = "ench", invSlot = 12 }, -- enchantable on this server (custom, unlike vanilla) + { id = "T1", label = "T1", sub = "slot 13", full = "Trinket 1", kind = "trinket", invSlot = 13 }, + { id = "T2", label = "T2", sub = "slot 14", full = "Trinket 2", kind = "trinket", invSlot = 14 }, +} + +-- ------------------------------------------------------------------ +-- Item-subtype exemptions: whether an enchant applies at all is a +-- property of the equipped ITEM, not of the role. If the equipped item's +-- itemSubType (GetItemInfo return 6 on TWoW 1.18.1) +-- is listed for the slot, the cell classifies as NOTEXP instead +-- of MISSING. +-- +-- Shape: DC.ENCH_EXEMPT[slotId][itemSubType] = true +-- +-- [ASSUMPTION] English-client subtype strings: wands = "Wands", off-hand +-- frills/holdables = "Miscellaneous", thrown = "Thrown" (thrown weapons +-- cannot take a scope -- same item-property principle as wands; "Thrown" +-- is a deliberate extension -- +-- kept because dropping it would show thrown users a +-- false MISSING scope). All three strings are on the probe checklist: +-- the /dc probe battery dumps GetItemInfo subType (return 6) for every +-- equipped slot ("Q5b" lines, core/probe.lua) -- a mismatch means the +-- exemption silently never fires and must be corrected here. +-- ------------------------------------------------------------------ +DC.ENCH_EXEMPT = { + RNGD = { ["Wands"] = true, ["Thrown"] = true }, -- Wands: no scope; Thrown: no scope [ASSUMPTION] + OFFH = { ["Miscellaneous"] = true }, -- held-in-off-hand frills: not enchantable +} + +-- ------------------------------------------------------------------ +-- Class-based enchant exemptions: +-- whole CLASSES for which an ench slot never applies, +-- regardless of what (if anything) is equipped. DC_Model.classify checks +-- this FIRST, before gearRead/EMPTY/expectation logic, and returns +-- NOTEXP (detail "no ranged enchant for this class"). +-- +-- Shape: DC.ENCH_CLASS_EXEMPT[slotId][classToken] = true +-- (classToken = english GetRaidRosterInfo return-6 token) +-- +-- RNGD: shamans/druids/priests/mages/warlocks carry wands/totems/idols/ +-- relics or nothing there -- no scope ever applies. +-- PALADIN added: vanilla paladins have a permanently EMPTY ranged +-- slot (no ranged/relic item exists for them on 1.12), so a scope can +-- never apply either. +-- ------------------------------------------------------------------ +DC.ENCH_CLASS_EXEMPT = { + RNGD = { + SHAMAN = true, + DRUID = true, + PRIEST = true, + MAGE = true, + WARLOCK = true, + PALADIN = true, -- permanently empty ranged slot in vanilla + }, +} diff --git a/data/expectations.lua b/data/expectations.lua new file mode 100644 index 0000000..7323479 --- /dev/null +++ b/data/expectations.lua @@ -0,0 +1,122 @@ +-- DopingControl data/expectations.lua +-- Default expectation matrix DC.DEFAULT_EXPECT[role][slotId] = true. +-- One flat slot-id namespace across all three tabs (ids are unique). +-- +-- This table STAYS the role x slot +-- SEED. The saved/live shape is per class -- +-- db.expectations[role][class][slotId] -- materialized from these lines +-- by core/config.lua (DC.EnsureExpectations) for every class that can +-- perform the role (DC_Roles.ROLE_CLASSES). Class-differentiated +-- DEFAULTS are deliberately NOT shipped; the one +-- motivating example lives here as a comment only: +-- a TANK-confirmed SHAMAN wants AI = true (Arcane Intellect for the +-- mana-fed tank kit) while a TANK WARRIOR does not -- the user flips +-- expectations.TANK.SHAMAN.AI in the options grid. AI stays +-- a CASTER/HEALER role default for every performing class. +-- +-- Role-line choices: +-- Consumables: standard raid role lines. +-- Class buffs: SOUL only HEALER, AI only CASTER/HEALER, SPROT situational +-- for all five -- like FR. EMB (Emerald Blessing) has NO +-- seed entry: the buff comes from a raid questline and most +-- druids do not have it. The column therefore stays hidden +-- until someone enables it per role/class in the options +-- grid; the dynamic-column rule makes an unexpected slot +-- disappear entirely (same treatment as T1/T2 below). +-- Equipment: ALL armor enchant slots for ALL roles; +-- per-item exemptions (wand/holdable) are handled at +-- classify time via DC.ENCH_EXEMPT, not here. +-- NECK/R1/R2 (enchantable on this server, a custom feature +-- beyond vanilla) default to OFF for every role: unlike +-- the armor slots, a neck/ring enchant is not assumed +-- baseline raid gear -- enable it per role/class in the +-- options grid when it matters. T1/T2 (trinket item tiles) +-- carry NO seed entry at all -- the model ignores +-- expectations for tile-kind slots entirely (item +-- present/absent decides, always shown, never gated). +-- +-- Known debatable rows -- documented deliberately, the shipped defaults +-- win: +-- * TANK+AP: some tank consumable lists include Winterfall +-- Firewater; the default has no AP for TANK. +-- * MELEE+FLASK: some melee lists include Flask of the Titans; the +-- default has no FLASK for MELEE. +-- * MELEE/RANGED+HP: some melee lists have neither Fortitude nor +-- alcohol; HP=true is the weakest row of the matrix. +-- * RANGED+AP/WPN: Firewater/Juju Might are MELEE attack power; ranged +-- AP comes from food. Arguably AP=false, WPN=false +-- for RANGED; the default says true. +-- * HEALER+SP: healer sets may carry Dreamshard (+healing), which the +-- 11-slot merge puts into SP; the default has no SP for +-- HEALER -> Dreamshard is invisible for healers by +-- default. +-- * Equipment: an alternative default would differentiate per role +-- (OFFH only TANK/MELEE, RNGD only RANGED, +-- casters/healers neither); deliberately flattened to +-- all-roles + ENCH_EXEMPT item checks. +-- +-- This table is the pristine seed: core/config.lua materializes it into +-- db.expectations (per class, deep copies) on first load; the options +-- editor mutates only the materialized copies, never this table. +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC.DEFAULT_EXPECT = { + TANK = { + -- consumables + FLASK = true, FOOD = true, STR = true, AGI = true, ARM = true, + HP = true, FR = true, WPN = true, + -- class buffs + MOTW = true, PWF = true, SPROT = true, + -- equipment (RNGD default only for RANGED: + -- scopes only matter for hunters; flip in the options + -- expectation matrix anytime) + HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true, + HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true, + }, + MELEE = { + -- consumables + FOOD = true, AP = true, STR = true, AGI = true, + HP = true, FR = true, WPN = true, + -- class buffs + MOTW = true, PWF = true, SPROT = true, + -- equipment + HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true, + HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true, + }, + RANGED = { + -- consumables + FOOD = true, AP = true, AGI = true, + HP = true, FR = true, WPN = true, + -- class buffs + MOTW = true, PWF = true, SPROT = true, + -- equipment + HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true, + HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true, + RNGD = true, + }, + -- CASTER/HEALER: RNGD also dropped by role default (their classes are + -- additionally exempt via ENCH_CLASS_EXEMPT - belt and suspenders) + CASTER = { + -- consumables + FLASK = true, FOOD = true, SP = true, MP5 = true, + FR = true, WPN = true, + -- class buffs + AI = true, MOTW = true, PWF = true, SPROT = true, + -- equipment + HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true, + HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true, + }, + HEALER = { + -- consumables + FLASK = true, FOOD = true, MP5 = true, + HP = true, FR = true, WPN = true, + -- class buffs (SOUL: expected ONLY here) + AI = true, MOTW = true, PWF = true, SPROT = true, SOUL = true, + -- equipment + HEAD = true, SHLD = true, BACK = true, CHST = true, WRST = true, + HAND = true, LEGS = true, FEET = true, MAIN = true, OFFH = true, + }, +} diff --git a/data/hit.lua b/data/hit.lua new file mode 100644 index 0000000..5bced99 --- /dev/null +++ b/data/hit.lua @@ -0,0 +1,319 @@ +-- DopingControl data/hit.lua +-- Hit-chance tables for the HIT tab: what GEAR and AURAS contribute, plus +-- the caps every column is judged against. The third contributor, +-- TALENTS, lives in its own file (data/talenthit.lua) because it is a +-- different kind of source -- looked up by talent name and rank rather +-- than parsed out of a tooltip. +-- +-- WHY TOOLTIP PARSING (measured, not assumed): this client family exposes +-- NO hit API at all -- GetHitModifier, GetSpellHitModifier and +-- GetCombatRating are all absent (verified on the live client). The only +-- readable source of a player's +hit is the text of the equipped items' +-- tooltips, which IS readable for foreign units in range via +-- GameTooltip:SetInventoryItem(unit, slot). Everything in this file exists +-- to turn those tooltip lines into a number and to bound that number +-- against the caps of the current patch. +-- +-- WHAT CANNOT BE READ (in principle, not a missing feature): a foreign +-- player's WEAPON SKILL. The server never sends PLAYER_SKILL_INFO for +-- anyone but the own character and no inspect-skill channel exists, so +-- weapon skill is MEASURED for the own character (GetSkillLineInfo, see +-- scan/hit.lua) and ESTIMATED for everyone else -- base skill + racial +-- specialization + an optional per-player "weapon skill book" flag the +-- user sets in the options. Every consumer must present the foreign value +-- as an assumption, never as a measurement. +-- +-- PATCH BASIS (current server patch): weapon-skill scaling was rewritten +-- to LINEAR, the old 305-skill breakpoint was removed and the hidden 1% +-- suppression was dropped. Consequences encoded below: the yellow melee +-- hit cap against a boss three levels above the player is 8% (it was 9% +-- before the rewrite) and the dual-wield WHITE cap is 27%. The spell hit +-- cap is 16%, derived from the server's own clamp (see DC.HIT_CAPS below) +-- rather than measured -- it is a single constant here so a later +-- correction changes one line. +-- +-- English client assumed: both the tooltip patterns and the weapon-skill +-- names below are English strings. On a localized client nothing matches +-- and the hit columns simply read 0 rather than showing wrong numbers. +-- +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- ------------------------------------------------------------------ +-- Magic schools that can carry a school-specific spell-hit line. +-- Order is display order for tooltip detail lines. +-- +-- The CAPITALIZED spelling is load-bearing twice over: it is the word that +-- appears in the item tooltip ("Improves your chance to hit with Fire +-- spells by 1%"), so it is both the generated pattern text below and the +-- key of the parsed `schools` table. +-- ------------------------------------------------------------------ +DC.HIT_SCHOOLS = { "Arcane", "Fire", "Frost", "Holy", "Nature", "Shadow" } + +-- ------------------------------------------------------------------ +-- DC.HIT_SCHOOL_KEY -- lowercase school id -> the CAPITALIZED key used by +-- everything that came out of a tooltip. +-- +-- Two spellings exist because two sources exist and neither may be bent to +-- the other: tooltip text dictates "Fire", while the column definitions +-- (DC.SLOTS_HIT, core/const.lua) and the talent tables +-- (DC.TALENT_HIT, data/talenthit.lua) are hand-written data that follows +-- the file-wide lowercase-id convention. This table is GENERATED from +-- DC.HIT_SCHOOLS so the two can never drift apart, and DC_Hit.SchoolHit +-- (scan/hit.lua) is the one function callers should use instead of +-- indexing either table by hand. +-- ------------------------------------------------------------------ +DC.HIT_SCHOOL_KEY = {} +for i = 1, table.getn(DC.HIT_SCHOOLS) do + local school = DC.HIT_SCHOOLS[i] + DC.HIT_SCHOOL_KEY[string.lower(school)] = school +end + +-- ------------------------------------------------------------------ +-- DC.HIT_PATTERNS -- ARRAY (order matters, FIRST MATCH WINS), each entry +-- { pattern = , +-- kind = "melee" | "spell" | "both" | } +-- +-- kind semantics (folded by DC_Hit.SumGear): +-- "melee" generic physical hit. 1.12 has no separate ranged-hit affix: +-- the same "chance to hit" line applies to melee AND ranged +-- attacks, so the MELEE bucket feeds both hit columns. +-- "spell" spell hit against every school. +-- "both" "spells and attacks": adds to melee AND spell. +-- school-specific spell hit (e.g. "Fire"): it does NOT raise +-- the general spell-hit number, it is carried separately and +-- shown as an extra, because it only helps that one school. +-- +-- Ordering rule: the more specific phrasings come first. The four +-- phrasings do not actually overlap as literals ("hit with spells and +-- attacks by", "hit with Fire spells by", "hit with spells by", "hit by"), +-- but the order keeps that from becoming load-bearing if a pattern is ever +-- widened. +-- +-- Extension point: a phrasing that is not listed here is SILENTLY IGNORED +-- (never guessed at). Server-custom item text that grants hit therefore +-- needs its pattern added here -- this table is the single place. +-- ------------------------------------------------------------------ +DC.HIT_PATTERNS = { + -- combined affix ("spells and attacks") + { pattern = "Improves your chance to hit with spells and attacks by (%d+)%%", + kind = "both" }, +} + +-- school-specific spell hit, generated so the school list stays the single +-- source of truth ("Improves your chance to hit with Fire spells by 1%") +for i = 1, table.getn(DC.HIT_SCHOOLS) do + local school = DC.HIT_SCHOOLS[i] + table.insert(DC.HIT_PATTERNS, { + pattern = "Improves your chance to hit with " .. school + .. " spells by (%d+)%%", + kind = school, + }) +end + +-- general spell hit, then the generic (melee + ranged) hit line +table.insert(DC.HIT_PATTERNS, + { pattern = "Improves your chance to hit with spells by (%d+)%%", + kind = "spell" }) +table.insert(DC.HIT_PATTERNS, + { pattern = "Improves your chance to hit by (%d+)%%", + kind = "melee" }) + +-- ------------------------------------------------------------------ +-- DC.WEAPON_SKILL_OF_SUBTYPE -- GetItemInfo itemSubType -> weapon SKILL +-- name (the string GetSkillLineInfo reports for that weapon). +-- +-- Absent key = "not a weapon" and that is load-bearing: off-hand items +-- whose subtype is missing here (shields, held-in-off-hand items, totems, +-- idols, librams, fishing poles) are what tells the caller that the player +-- is NOT dual-wielding. +-- +-- Verified live on this client: "One-Handed Maces" (plus the non-weapon +-- subtypes "Shields" and "Totems", deliberately absent). The remaining +-- strings follow the standard 1.12 weapon subclass naming and are +-- UNVERIFIED -- each one is only a lookup key, so a wrong string degrades +-- to "unknown weapon" (no skill assumption, no dual-wield claim), never to +-- a wrong number. +-- +-- Two mappings are worth calling out: +-- "Fist Weapons" -> "Unarmed": fist weapons run on the Unarmed skill +-- line, they have no skill of their own. +-- "Wands" -> "Wands": the skill line exists, but whether wand attacks +-- obey weapon skill or spell hit is DISPUTED on this patch. The +-- mapping only names the skill line; the ranged hit column +-- deliberately ignores wands (see DC.RANGED_ATTACK_SUBTYPE). +-- ------------------------------------------------------------------ +DC.WEAPON_SKILL_OF_SUBTYPE = { + ["One-Handed Axes"] = "Axes", + ["Two-Handed Axes"] = "Two-Handed Axes", + ["One-Handed Maces"] = "Maces", -- verified live + ["Two-Handed Maces"] = "Two-Handed Maces", + ["One-Handed Swords"] = "Swords", + ["Two-Handed Swords"] = "Two-Handed Swords", + ["Daggers"] = "Daggers", + ["Fist Weapons"] = "Unarmed", -- see header + ["Polearms"] = "Polearms", + ["Staves"] = "Staves", + ["Bows"] = "Bows", + ["Crossbows"] = "Crossbows", + ["Guns"] = "Guns", + ["Thrown"] = "Thrown", + ["Wands"] = "Wands", -- see header +} + +-- Subtypes that make the RANGED hit question meaningful, i.e. things that +-- actually swing as a ranged ATTACK. Wands are excluded on purpose (their +-- hit mechanic is disputed, see above) and so is every non-weapon relic; +-- a player without one of these gets no ranged hit column value at all. +DC.RANGED_ATTACK_SUBTYPE = { + ["Bows"] = true, + ["Crossbows"] = true, + ["Guns"] = true, + ["Thrown"] = true, +} + +-- ------------------------------------------------------------------ +-- DC.CLASS_WEAPON_SKILLS -- class token -> ordered array of the weapon +-- skills that class can train (Vanilla 1.12 base list). Every name here is +-- a VALUE of DC.WEAPON_SKILL_OF_SUBTYPE above, same spelling -- the +-- options editor's Skill books tab (ui/options.lua, O.SkillBookRows) +-- writes db.skillBooks[player][skill] keyed by these exact strings, so a +-- typo here would silently create a book entry the hit calc never reads. +-- +-- This server can diverge from the Vanilla baseline (extra or missing +-- trainable weapons for a class). If a player is seen wielding a skill +-- that is not in their class's list here, the Skill books tab still +-- offers it -- a WORN skill always rides along regardless of the class +-- list (see O.SkillBookRows) -- but if that turns out to be the NORMAL +-- case for a class rather than a one-off exception, extend the list below +-- rather than leaning on the worn-skill fallback forever. +-- ------------------------------------------------------------------ +DC.CLASS_WEAPON_SKILLS = { + WARRIOR = { "Axes", "Two-Handed Axes", "Maces", "Two-Handed Maces", + "Swords", "Two-Handed Swords", "Daggers", "Unarmed", + "Polearms", "Staves", "Bows", "Crossbows", "Guns", + "Thrown" }, + PALADIN = { "Axes", "Two-Handed Axes", "Maces", "Two-Handed Maces", + "Swords", "Two-Handed Swords", "Polearms" }, + HUNTER = { "Axes", "Two-Handed Axes", "Swords", "Two-Handed Swords", + "Daggers", "Unarmed", "Polearms", "Staves", "Bows", + "Crossbows", "Guns", "Thrown" }, + ROGUE = { "Daggers", "Unarmed", "Maces", "Swords", "Bows", + "Crossbows", "Guns", "Thrown" }, + PRIEST = { "Daggers", "Maces", "Staves", "Wands" }, + SHAMAN = { "Axes", "Two-Handed Axes", "Maces", "Two-Handed Maces", + "Daggers", "Unarmed", "Staves" }, + MAGE = { "Daggers", "Swords", "Staves", "Wands" }, + WARLOCK = { "Daggers", "Swords", "Staves", "Wands" }, + DRUID = { "Maces", "Two-Handed Maces", "Daggers", "Unarmed", + "Staves", "Polearms" }, +} + +-- ------------------------------------------------------------------ +-- Weapon skill baseline and modifiers. +-- +-- DC.BASE_WEAPON_SKILL: a level-60 character caps every weapon skill at +-- 300 (5 x level). This tool is a raid tool, so 60 is assumed for the +-- estimate of FOREIGN players; the own character is measured and never +-- uses this number. +-- +-- DC.RACIAL_SKILL_BONUS: the racial weapon specialization bonus. The value +-- is DISPUTED -- the classic value is +5, and a server-side change to +3 +-- has been reported but is UNVERIFIED. It is ONE constant on purpose: +-- changing this single line changes every racial bonus and every derived +-- cap. +-- +-- DC.SKILL_BOOK_BONUS: a quest weapon-skill book grants +3 skill. It is +-- invisible on other players (see the header), which is why it is an +-- opt-in per-player checkbox in the options rather than a scan result. +-- ------------------------------------------------------------------ +DC.BASE_WEAPON_SKILL = 300 +DC.RACIAL_SKILL_BONUS = 5 +DC.SKILL_BOOK_BONUS = 3 + +-- ------------------------------------------------------------------ +-- DC.RACE_WEAPON_SKILL -- race -> { [weapon skill name] = bonus }. +-- +-- Keys: BOTH spellings are listed for every race that has a bonus -- the +-- display name ("Night Elf") and the file token ("NightElf", "Scourge"), +-- because the race string reaches us from UnitRace, whose two returns are +-- exactly those two spellings. Aliases point at the SAME table, so a race +-- is never counted twice. +-- +-- Races without a weapon specialization (night elves, tauren, undead) are +-- deliberately absent: an absent race resolves to "no bonus", which is +-- also what an unknown/newer race token resolves to (nil-safe by design). +-- ------------------------------------------------------------------ +local B = DC.RACIAL_SKILL_BONUS + +local HUMAN_SKILLS = { + ["Swords"] = B, ["Two-Handed Swords"] = B, + ["Maces"] = B, ["Two-Handed Maces"] = B, +} +local ORC_SKILLS = { + ["Axes"] = B, ["Two-Handed Axes"] = B, +} +local DWARF_SKILLS = { ["Guns"] = B } +local TROLL_SKILLS = { ["Bows"] = B, ["Thrown"] = B } +local GNOME_SKILLS = { ["Daggers"] = B, ["Swords"] = B } +-- races without a weapon specialization share ONE empty table +local NO_SKILLS = {} + +DC.RACE_WEAPON_SKILL = { + ["Human"] = HUMAN_SKILLS, + ["Orc"] = ORC_SKILLS, + ["Dwarf"] = DWARF_SKILLS, + ["Troll"] = TROLL_SKILLS, + ["Gnome"] = GNOME_SKILLS, + -- no weapon specialization; listed for readability, empty on purpose + ["Night Elf"] = NO_SKILLS, + ["NightElf"] = NO_SKILLS, + ["Tauren"] = NO_SKILLS, + ["Undead"] = NO_SKILLS, + ["Scourge"] = NO_SKILLS, +} + +-- ------------------------------------------------------------------ +-- DC.HIT_CAPS -- hit percentages needed against a boss THREE LEVELS above +-- the player on the current patch, at base weapon skill (300): +-- meleeYellow 8 -- special attacks / abilities ("yellow" swings) +-- meleeWhiteDW 27 -- auto attacks while DUAL WIELDING (the 19% dual- +-- wield penalty rides on top); a single weapon's white +-- swings share the yellow cap +-- ranged 8 -- ranged attacks behave like yellow melee here +-- spell 16 -- the server clamps spell hit chance to 99%, so 1% +-- miss can never be removed: base miss vs a +3-level +-- boss is 17%, which makes 16% the effective cap +-- +-- DC.CAP_PER_SKILL: every weapon skill point ABOVE 300 lowers the melee/ +-- ranged cap by 0.2 percentage points (linear scaling, see header). A +-- racial specialization (+5 skill) therefore lowers the yellow cap from +-- 8.0 to 7.0, and a weapon skill book (+3) is worth about 0.6% hit. +-- ------------------------------------------------------------------ +DC.HIT_CAPS = { + meleeYellow = 8, + meleeWhiteDW = 27, + ranged = 8, + spell = 16, +} + +DC.CAP_PER_SKILL = 0.2 + +-- ------------------------------------------------------------------ +-- DC.HIT_AURAS -- hit granted by AURAS rather than gear, folded on top of +-- the gear sum by scan/engine.lua's BuildPlayer (no extra scan: the buff +-- names are already in the store from the same aura read the class-buff +-- tab uses). Keys are buff NAMES (matched against player.auras.names); +-- values are { melee=, ranged=, spell= } percentages -- every field is +-- OPTIONAL, since a hit-granting aura need not touch all three columns. +-- ------------------------------------------------------------------ +DC.HIT_AURAS = { + -- raid-wide druid buff: +1% SPELL hit only (also grants movement + -- speed and casting mana regen, which do not concern this addon) -- + -- verified against the in-game tooltip text, so this is deliberately + -- NOT a melee/ranged entry; add further hit-granting auras here (a + -- food buff with hit would be the likeliest addition) + ["Emerald Blessing"] = { spell = 1 }, +} diff --git a/data/resist.lua b/data/resist.lua new file mode 100644 index 0000000..5ba02bf --- /dev/null +++ b/data/resist.lua @@ -0,0 +1,139 @@ +-- DopingControl data/resist.lua +-- Resistance-reconstruction tables for the RESIST tab: what GEAR tooltips +-- and RACE grant, mirroring data/hit.lua's split for the HIT tab (patterns +-- + constants here, the pure parser in scan/resist.lua). +-- +-- WHY RECONSTRUCTION (measured, not assumed -- see scan/hit.lua's header +-- for the full story): the server does not transmit +-- UNIT_FIELD_RESISTANCES for foreign players at all (Player:: +-- InitVisibleBits() never sets the bit for anyone but the owning client), +-- so UnitResistance/GetUnitField can NEVER answer for a foreign unit -- +-- this is not a corner case scan/engine.lua's three read paths occasionally +-- miss, it is the only path that can ever ANSWER for one. The equipped +-- items' tooltips are readable for foreign units in range +-- (GameTooltip:SetInventoryItem), exactly like the HIT tab's +hit lines, +-- so the total is rebuilt from the same source: item stat text, plus the +-- racial bonus below (which no tooltip ever states). +-- +-- DC.RESIST_SCHOOLS -- the five schools that carry a resistance stat, in +-- TOOLTIP capitalization ("Fire Resistance"), paired with the +-- UnitResistance() school index used throughout the addon (core/const.lua +-- DC.SLOTS_RESIST: 2 fire / 3 nature / 4 frost / 5 shadow / 6 arcane; 0 +-- physical and 1 holy are never queried -- there is no "Holy Resistance" +-- item stat in 1.12, which is also why this list has no sixth entry). +-- Kept in sync with DC.SLOTS_RESIST BY HAND (self-contained, like +-- DC.HIT_SCHOOLS/DC.SLOTS_HIT in data/hit.lua and core/const.lua) -- +-- test_resist.lua pins the two against each other so they cannot drift +-- apart silently. +DopingControl = DopingControl or {} +local DC = DopingControl + +DC.RESIST_SCHOOLS = { + { name = "Fire", school = 2 }, + { name = "Nature", school = 3 }, + { name = "Frost", school = 4 }, + { name = "Shadow", school = 5 }, + { name = "Arcane", school = 6 }, +} + +-- DC.RESIST_SCHOOL_NUM -- tooltip school name -> UnitResistance() index, +-- GENERATED from DC.RESIST_SCHOOLS so the two tables cannot drift apart +-- (same idea as DC.HIT_SCHOOL_KEY in data/hit.lua). +DC.RESIST_SCHOOL_NUM = {} +for i = 1, table.getn(DC.RESIST_SCHOOLS) do + local e = DC.RESIST_SCHOOLS[i] + DC.RESIST_SCHOOL_NUM[e.name] = e.school +end + +-- ------------------------------------------------------------------ +-- DC.RESIST_PATTERNS -- ARRAY (order matters, FIRST MATCH WINS), each +-- entry { pattern = , kind = | "all" }. +-- +-- Measured tooltip forms (2026-08-02, own-character tooltip, English +-- client): +-- "+10 Arcane Resistance" -- PLAIN stat line, no "Equip:" +-- "+10 Fire Resistance" -- (item base stats print this way, +-- "+10 Nature Resistance" -- unlike +hit which is always an +-- "+1 Fire Resistance" -- "Equip:" trigger line) +-- "Equip: +8 All Resistances." -- multi-school EQUIP trigger -- +-- applies to ALL FIVE schools at +-- once; a parser keyed on the five +-- single-school patterns alone would +-- silently miss this line entirely +-- (the trap this table exists to +-- close, see scan/resist.lua +-- R.SumLines). +-- +-- Both anchored at the LINE START (^): a stat line is the line's entire +-- content on this client, so anchoring guards against a flavor-text +-- sentence that happens to contain "Fire Resistance" mid-phrase. No +-- trailing anchor: the plain form has nothing after the number+word (WoW +-- classic tooltip text is fixed, so this cannot mismatch), and the Equip +-- form is allowed to keep trailing punctuation. +-- +-- Extension point (mirrors DC.HIT_PATTERNS): a phrasing not listed here is +-- SILENTLY IGNORED, never guessed at. An "Equip: +N Resistance." +-- single-school equip form is conceivable on this server but UNMEASURED -- +-- it is deliberately absent until someone reads it off a real tooltip. +-- ------------------------------------------------------------------ +DC.RESIST_PATTERNS = {} +for i = 1, table.getn(DC.RESIST_SCHOOLS) do + local e = DC.RESIST_SCHOOLS[i] + table.insert(DC.RESIST_PATTERNS, { + pattern = "^%+(%d+) " .. e.name .. " Resistance", + kind = e.name, + }) +end +table.insert(DC.RESIST_PATTERNS, { + pattern = "^Equip: %+(%d+) All Resistances", + kind = "all", +}) + +-- ------------------------------------------------------------------ +-- DC.RACE_RESIST -- race -> { [UnitResistance school index] = bonus }. +-- INNATE racial resistance, granted by the race itself rather than any +-- item -- invisible to a tooltip scan, so it has to be added on top of the +-- gear sum by hand (scan/engine.lua's E.AssembleResists gear-fallback +-- path) or the reconstructed total would silently understate every member +-- of these five races by exactly this amount. +-- +-- Keys: BOTH spellings for every race that has a bonus (display name and +-- file token), same reasoning as DC.RACE_WEAPON_SKILL in data/hit.lua -- +-- scan/hit.lua's H.ReadUnit stores `token or display` as the race passed +-- through the store, so either spelling must resolve. Aliases point at the +-- SAME table (never a separate copy), so a race can never end up counted +-- with two different bonus tables that could drift apart. +-- +-- Verified racial resistances (task brief, 2026-08-02): +-- Dwarf +10 Frost +-- Gnome +10 Arcane +-- Night Elf +10 Nature +-- Tauren +10 Nature +-- Undead/Forsaken +10 Shadow +-- Human, Orc, Troll: none +-- +-- WARNING -- NOT COVERED: TurtleWoW's two extra playable races, Goblin and +-- High Elf/Blood Elf. Their racial resistance (if they even have one) is +-- UNKNOWN -- it is not guessed here. Both are simply ABSENT from this +-- table, which is safe by construction: DC_Resist.RaceBonus (scan/ +-- resist.lua) returns an empty bonus table for any race it does not +-- recognize, exactly the same degrade an unlisted/future race token gets. +-- Do not "fix" this by inventing a number -- add the real one once it is +-- measured, the same way the five below were. +-- ------------------------------------------------------------------ +local DWARF_RESIST = { [4] = 10 } -- frost +local GNOME_RESIST = { [6] = 10 } -- arcane +local NIGHTELF_RESIST = { [3] = 10 } -- nature +local TAUREN_RESIST = { [3] = 10 } -- nature +local UNDEAD_RESIST = { [5] = 10 } -- shadow + +DC.RACE_RESIST = { + ["Dwarf"] = DWARF_RESIST, + ["Gnome"] = GNOME_RESIST, + ["Night Elf"] = NIGHTELF_RESIST, + ["NightElf"] = NIGHTELF_RESIST, + ["Tauren"] = TAUREN_RESIST, + ["Undead"] = UNDEAD_RESIST, + ["Scourge"] = UNDEAD_RESIST, +} diff --git a/data/talenthit.lua b/data/talenthit.lua new file mode 100644 index 0000000..15da822 --- /dev/null +++ b/data/talenthit.lua @@ -0,0 +1,171 @@ +-- DopingControl data/talenthit.lua +-- The THIRD source of hit, next to gear and auras: TALENTS. +-- +-- WHY ITS OWN FILE: gear hit is parsed from item tooltips, talent hit is +-- looked up by talent NAME and rank. The two arrive over completely +-- different paths -- GetTalentInfo for the own character, the server's +-- inspect protocol (scan/talents.lua) for everyone else -- and only meet +-- again in DC_Hit.TalentHit. Keeping the talent tables apart from +-- data/hit.lua keeps that split visible in the file layout instead of +-- hiding it inside one big table. +-- +-- WHAT COUNTS AS SPELL HIT HERE: several talents are worded as "reduces +-- the chance that the opponent can resist your spells by X%". +-- That is not a resistance mechanic -- it runs through the server's +-- resist-miss spell modifier, i.e. it IS spell hit for that school, and it +-- stacks with +hit from items. They are therefore modelled as +-- school-specific spell hit and not as anything resistance-shaped. +-- +-- BUCKETS -- an entry is +-- { name = , +-- tree = , +-- ranks = , +-- melee = { }, -- generic physical hit (melee column) +-- ranged = { }, -- generic ranged hit (ranged column) +-- spell = { }, -- GENERIC spell hit: every school +-- offhand = { }, -- OFF-HAND swings only, see below +-- dwExtra = { }, -- extra melee hit while DUAL WIELDING +-- schools = { [] = { } } } +-- Every bucket is optional; an absent bucket contributes nothing. School +-- ids are lowercase to match DC.SLOTS_HIT (core/const.lua); the +-- CAPITALIZED tooltip spelling that gear parsing produces is bridged by +-- DC.HIT_SCHOOL_KEY (data/hit.lua). +-- +-- `offhand` is deliberately NOT part of the melee number. Off-hand hit +-- applies to off-hand swings only, so adding it to the melee column would +-- claim a main-hand hit chance the player does not have. It is carried so +-- the cell tooltip can mention it. +-- +-- MATCHING IS BY NAME: the reader (DC_Hit.ReadSelfTalents) hands in +-- { [talent name] = rank }, because GetTalentInfo returns names, and +-- tab/index positions shift whenever a tree is rebalanced while names +-- almost never do. A talent this file does not list is ignored; a talent +-- listed here that no longer exists simply never matches. Both directions +-- degrade to "no talent hit counted", never to a wrong number. +-- +-- English client assumed, like the tooltip patterns in data/hit.lua: on a +-- localized client no name matches and the talent contribution reads 0. +-- +-- WHAT IS DELIBERATELY EXCLUDED (each one grants "hit" in its tooltip text +-- but not hit that belongs in any column of this tab): +-- * PET hit -- hunter "Bestial Precision", warlock "Demonic Precision". +-- They raise the PET's hit chance (and its weapon skill / inherited +-- ratios); the player's own swings and casts are untouched. +-- * ENEMY DEBUFFS -- rogue "Blinding Haze" LOWERS the hit chance of +-- targets hit by Distract. It is the opposite sign and lands on +-- someone else. +-- * SINGLE-SPELL hit -- paladin "Improved Hand of Reckoning" buys hit +-- for one ability only. A column that claimed it would overstate holy +-- hit for every other spell the paladin casts. +-- * WEAPON-TYPE hit -- mage "Wand Specialization" (wands) and priest +-- "Wand Specialization" (wands and bows). Wand and bow attacks are +-- neither generic spell hit nor -- for the priest -- the generic +-- ranged-attack hit this tab's ranged column measures (that column +-- only exists for a real ranged ATTACK weapon, see +-- DC.RANGED_ATTACK_SUBTYPE), so counting them would misplace them. +-- * TEMPORARY procs -- e.g. the second half of the shaman's Elemental +-- Devastation, which grants spell hit for 10 sec after a melee crit. +-- Only the permanent, always-on half is counted; a scan is a snapshot +-- and a proc that happens to be up would make one row incomparable +-- with the next. +-- +-- Pure Lua 5.0, no WoW API -- dofile-loadable offline. + +DopingControl = DopingControl or {} +local DC = DopingControl + +-- ------------------------------------------------------------------ +-- DC.TALENT_HIT -- [class token (UnitClass file token, e.g. "MAGE")] = +-- array of talent entries. Classes with no hit talent at all are absent. +-- ------------------------------------------------------------------ +DC.TALENT_HIT = { + + -- Generic hit: melee AND every spell school, from ONE talent. The + -- three entries below are the only talents in the game that raise + -- both halves at once, which is why hybrids can be capped on both + -- sides without spending a single item budget on it. + DRUID = { + { name = "Natural Weapons", tree = "Balance", ranks = 3, + melee = { 1, 2, 3 }, spell = { 1, 2, 3 } }, + }, + + PALADIN = { + { name = "Precision", tree = "Protection", ranks = 3, + melee = { 1, 2, 3 }, spell = { 1, 2, 3 } }, + }, + + SHAMAN = { + -- the talent's second half (melee crits grant 3/6/9% spell hit + -- for 10 sec) is a TEMPORARY proc and is not counted -- see the + -- exclusion list in the header + { name = "Elemental Devastation", tree = "Elemental", ranks = 3, + melee = { 1, 2, 3 }, spell = { 1, 2, 3 } }, + }, + + -- Melee only. + ROGUE = { + -- "with melee weapons": no ranged half, even though rogues can + -- carry a thrown weapon + { name = "Precision", tree = "Combat", ranks = 5, + melee = { 1, 2, 3, 4, 5 } }, + }, + + HUNTER = { + -- the talent says "hit chance" without naming a weapon, so the + -- base half feeds the melee AND the ranged column. + -- + -- dwExtra is the "and by an additional X% while dual wielding" + -- half. The wording does not say which swings the EXTRA applies + -- to; a dual-wield condition is a melee-build condition, so it is + -- counted for MELEE ONLY. That is the reading that cannot + -- overstate a hunter's ranged hit -- the number that decides + -- whether the player is capped where it matters. Should the extra + -- turn out to be generic, this under-reports ranged by up to 3%, + -- which is the safe direction (same trade-off as the uncounted + -- set bonuses in scan/hit.lua). + { name = "Surefooted", tree = "Survival", ranks = 3, + melee = { 1, 2, 3 }, ranged = { 1, 2, 3 }, + dwExtra = { 1, 2, 3 } }, + }, + + -- Off-hand only. + WARRIOR = { + -- off-hand swings only: NOT added to the melee column, carried + -- for the tooltip (see the `offhand` note in the header) + { name = "Dual Wield Specialization", tree = "Fury", ranks = 5, + offhand = { 2, 4, 6, 8, 10 } }, + }, + + -- School-specific spell hit. + MAGE = { + { name = "Arcane Focus", tree = "Arcane", ranks = 5, + schools = { arcane = { 2, 4, 6, 8, 10 } } }, + -- one talent, two schools, full value in each: it is not split + { name = "Elemental Precision", tree = "Frost", ranks = 3, + schools = { frost = { 2, 4, 6 }, fire = { 2, 4, 6 } } }, + }, + + PRIEST = { + { name = "Shadow Focus", tree = "Shadow", ranks = 5, + schools = { shadow = { 2, 4, 6, 8, 10 } } }, + -- "your Holy and Discipline spells": Discipline is a SPELL LINE, + -- not a magic school -- its damaging spells are holy school + -- anyway, so the whole talent maps onto holy. There is no + -- "discipline" column to put the other half in, and inventing one + -- would be a school this client's damage model does not have. + { name = "Piercing Light", tree = "Discipline", ranks = 3, + schools = { holy = { 2, 4, 6 } } }, + }, + + WARLOCK = { + -- "your Affliction spells": another SPELL-LINE talent rather than + -- a school talent. Affliction spells (Corruption, Curse of Agony, + -- Drain Life, Siphon Life, Death Coil, ...) are shadow school, so + -- the talent is mapped onto shadow. A warlock's fire spells + -- (Immolate, Shadowburn's destruction siblings) get nothing from + -- it, which is exactly why the fire and shadow columns of this tab + -- can differ by 10% for the same character. + { name = "Suppression", tree = "Affliction", ranks = 5, + schools = { shadow = { 2, 4, 6, 8, 10 } } }, + }, +} diff --git a/features/minimap.lua b/features/minimap.lua new file mode 100644 index 0000000..7a91cbc --- /dev/null +++ b/features/minimap.lua @@ -0,0 +1,118 @@ +-- DopingControl features/minimap.lua +-- Minimap button, standard hand-rolled 1.12 pattern: +-- LEFT click toggles the main window, RIGHT +-- click opens the options. +-- +-- Design facts (in-game verified on 1.12): +-- - Hand-rolled, no LibDBIcon on 1.12. +-- - pfUI-safe: parent = Minimap, frame NAME contains "Minimap", +-- SetFrameStrata("HIGH") + SetFrameLevel(9) -- MEDIUM hides under pfUI. +-- /dc options stays as the fallback path. +-- - Border recipe (vanilla FrameXML MiniMapTrackingButton): 54px +-- MiniMap-TrackingBorder anchored TOPLEFT(0,0) on a 33px button. The +-- ring is deliberately off-centre within the texture -- do NOT centre it. +-- - Drag + click coexist: drag flag on OnDragStart/Stop, plain clicks +-- still fire OnClick. +-- - Radius = Minimap:GetWidth()/2 + 5 (follows resized minimaps). +-- +-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then"; +-- BuildButton/PlaceButton only touch the WoW API when called at runtime. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Minimap = DC_Minimap or {} + +local button = nil +local function db() return DopingControlDB end + +local function PlaceButton(angleDeg) + if not button then return end + local radius = (Minimap:GetWidth() / 2) + 5 + local rad = angleDeg * math.pi / 180 + button:ClearAllPoints() + button:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * radius, math.sin(rad) * radius) +end + +local function BuildButton() + if button then return end + local btn = CreateFrame("Button", "DopingControlMinimapButton", Minimap) + btn:SetWidth(33); btn:SetHeight(33) + btn:SetFrameStrata("HIGH"); btn:SetFrameLevel(9) + + local icon = btn:CreateTexture("DopingControlMinimapIcon", "ARTWORK") + icon:SetTexture("Interface\\Icons\\INV_Potion_97") -- the addon's fixed icon + icon:SetWidth(20); icon:SetHeight(20) + icon:SetPoint("CENTER", btn, "CENTER", 0, 0) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) -- standard action-button crop + + local border = btn:CreateTexture("DopingControlMinimapBorder", "OVERLAY") + border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + border:SetWidth(54); border:SetHeight(54) + border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0) + + btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btn:RegisterForDrag("LeftButton") + + btn:SetScript("OnDragStart", function() this.isDragging = true end) + btn:SetScript("OnDragStop", function() this.isDragging = false end) + btn:SetScript("OnUpdate", function() + if not this.isDragging then return end + local mx, my = Minimap:GetCenter() + local cx, cy = GetCursorPosition() + local scale = Minimap:GetEffectiveScale() + cx, cy = cx / scale, cy / scale + local angle = math.deg(math.atan2(cy - my, cx - mx)) + db().minimapAngle = angle -- persist live while dragging + PlaceButton(angle) + end) + + -- LEFT = main window toggle (same as /dc), RIGHT = options. + btn:SetScript("OnClick", function() + if arg1 == "RightButton" then DC_Options.Toggle() else DC_Matrix.Toggle() end + end) + btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + GameTooltip:SetText("DopingControl", 1, 1, 1) + GameTooltip:AddLine("Left click: show / hide the window", 0.8, 0.8, 0.8) + GameTooltip:AddLine("Right click: options", 0.8, 0.8, 0.8) + GameTooltip:AddLine("Drag: move around the minimap", 0.8, 0.8, 0.8) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() GameTooltip:Hide() end) + button = btn + PlaceButton(db().minimapAngle or 215) +end + +-- Honours the "Show minimap button" checkbox (db.showMinimapButton); +-- ui/options.lua calls this after toggling the setting. +function DC_Minimap.Update() + if not db().showMinimapButton then + if button then button:Hide() end + return + end + BuildButton() + PlaceButton(db().minimapAngle or 215) + button:Show() +end + +-- Reload survival: build on ADDON_LOADED for +-- THIS addon (SavedVariables populated by then), then UnregisterEvent. +-- NEVER on PLAYER_ENTERING_WORLD (would re-run and leak textures). +if CreateFrame then + local ev = CreateFrame("Frame", "DopingControlMinimapEventFrame", UIParent) + ev:RegisterEvent("ADDON_LOADED") + ev:SetScript("OnEvent", function() + if event == "ADDON_LOADED" and arg1 == "DopingControl" then + -- This handler fires BEFORE core/init.lua's (registration order + -- follows TOC load order), so defaults may not be filled yet on a + -- first-ever load. EnsureDefaults is idempotent -- run it here so + -- showMinimapButton/minimapAngle are valid. + DopingControlDB = DopingControlDB or {} + DC.EnsureDefaults(DopingControlDB) + DC_Minimap.Update() + ev:UnregisterEvent("ADDON_LOADED") + end + end) +end diff --git a/scan/aura.lua b/scan/aura.lua new file mode 100644 index 0000000..3fd9798 --- /dev/null +++ b/scan/aura.lua @@ -0,0 +1,496 @@ +-- DopingControl scan/aura.lua +-- Buff reading for one unit, two independent paths, merged: +-- Path A (unit token): UnitBuff(unit, i), i = 1..32 -- buff NAME via a +-- hidden tooltip (DopingControlScanTip, the standard 1.12 +-- hidden-tooltip scanner pattern), spell ID via the +-- 4th return value (a SuperWoW extension; unverified on other client +-- builds -- covered by the probe battery). +-- UnitBuff compacts its list: stop at the first nil texture. +-- Path B (GUID): GetUnitField(guid, "aura") -- nampower descriptor table, +-- 48 ONE-BASED slots of spell ids (1-32 buffs, 33-48 debuffs). The +-- table is SPARSE (in-game verified: e.g. slots +-- 1,2,5,6 filled with 3,4 empty), so iterate all 32 buff slots and +-- skip empties instead of breaking at the first gap. +-- Both paths are pcall-wrapped per call (hard rule). +-- +-- Merge (pure, offline-tested): union of the ID sets, names from path A. +-- Buff icons: auras.textures[name] = icon path (UnitBuff return 1) for +-- every path-A buff that yielded a name; ids without a name are OPTIONALLY +-- resolved via SuperWoW SpellInfo(id) (return 3 = icon) when the function +-- exists. textures is OPTIONAL/partial by design -- every consumer +-- nil-guards, nothing downstream may require it. +-- Disagreements between the two ID sets are counted (symmetric difference, +-- only when BOTH paths yielded at least one ID -- an absent path is missing +-- data, not a disagreement) and accumulated by the engine into +-- DC.probeStats. Zero USABLE auras (name- or id-carrying) from both paths +-- => aurasRead = false (a failed read, never "has nothing"; +-- texture-only path-A rows carry no evidence and do not count). +-- +-- Unknown auras are collected, never silently dropped: +-- an aura is unknown when neither its name is in DC.CONSUMABLES / +-- DC.CLASSBUFFS nor its spell ID in DC.SPELL_TO_SLOT. Path-B-only IDs +-- normally carry no name and are reported as "spell:", UNLESS the +-- caller supplies idNames (see A.Assemble below): with a resolved name, +-- a path-B-only aura is judged the same way a path-A one is (known +-- consumable/class-buff name => not unknown at all) and, if still +-- unknown, is reported as that name instead of "spell:" -- and only +-- once, even when the same aura also showed up unresolved via path A +-- (this is what keeps the same aura from being counted twice: once as a +-- name from path A, once as "spell:" from path B, which used to +-- happen whenever path A never delivered a spell ID at all, making the +-- id-based dedup a no-op). +-- +-- DEBUFFS: +-- ReadUnit additionally reads UnitDebuff(unit, i) 1..16 (same hidden +-- tooltip via SetUnitDebuff, texture = return 1, spell id = 4th return if +-- present) and the debuff half of the SAME GetUnitField "aura" fetch +-- (one-based slots 33-48). AssembleDebuffs merges them into the store +-- shape players[i].debuffs = { names, ids, textures } (sibling of auras). +-- Debuff READABILITY rides on the existing aurasRead flag -- zero debuffs +-- is a LEGITIMATE clean state (empty sets), NEVER a read failure, and it +-- never influences aurasRead. Debuff names are NOT collected into +-- DC.unknownSeen (that channel is consumable tracking); +-- nothing is silently dropped either way, because the DEBUFFS tab builds +-- its columns dynamically from exactly this data. +-- +-- Pure Lua 5.0, dofile-loadable offline (WoW wiring only inside functions +-- that run in-game; tooltip frame is created lazily). + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Aura = DC_Aura or {} +local A = DC_Aura + +-- ================================================================== +-- Pure part +-- ================================================================== + +-- Assemble(rawA, rawB, idNames) -> auras, aurasRead, disagreements, unknown +-- rawA : array of { name = string|nil, id = number|nil, +-- texture = string|nil } (path A; texture is the 1st +-- UnitBuff return, optional) +-- rawB : array of spell ids (numbers, path B) +-- idNames : OPTIONAL { [spellId] = "Buff Name" } for path-B ids, +-- filled by the WoW-side caller (SuperWoW SpellInfo lookup -- +-- see A.ReadUnit). Omitted in tests/offline callers, in which +-- case unknown path-B ids are reported as "spell:" exactly +-- as before (no behavior change without it). +-- Returns: +-- auras : { names = { [name]=true }, ids = { [id]=true }, +-- textures = { [name]=iconPath } } +-- (store shape; textures may be empty/partial -- +-- consumers nil-guard) +-- aurasRead : bool -- true iff at least one USABLE aura arrived: a +-- path-A entry carrying a name or an id, or a path-B id. +-- Entries with NEITHER (UnitBuff texture present but the +-- tooltip read failed and no 4th return) are information- +-- free: counting them would flip a partially failed read +-- from UNKNOWN to all-slots-MISSING -- exactly what the +-- "zero auras => read failed" rule +-- exists to prevent. +-- disagreements : number of ids present in exactly one of the two ID +-- sets; 0 unless both sets are non-empty +-- unknown : array of unknown-aura keys (name, or "spell:") +-- trim(s) -> s with leading/trailing whitespace stripped. Lua-5.0-safe: +-- string.find with a captured "middle" group, no string-method syntax. +local function trim(s) + local _, _, middle = string.find(s, "^%s*(.-)%s*$") + return middle +end + +-- isKnownAuraName(name, cons, cbuf) -> true if `name` is a recognized +-- consumable/class-buff key -- tried EXACT first, then (only if that +-- misses) its trimmed form. SpellInfo() aura names can carry stray +-- leading/trailing whitespace on this client (measured live: +-- SpellInfo(7254) = "Nature Protection " with a trailing space) that a +-- hand-authored table key never has; without this fallback such a name +-- never matches its own table entry. Exact-first matters because a +-- table key legitimately containing whitespace (should one ever exist) +-- must still win over a coincidental trimmed collision. +local function isKnownAuraName(name, cons, cbuf) + if not name then + return false + end + if cons[name] or cbuf[name] then + return true + end + local trimmed = trim(name) + if trimmed ~= name and (cons[trimmed] or cbuf[trimmed]) then + return true + end + return false +end + +function A.Assemble(rawA, rawB, idNames) + rawA = rawA or {} + rawB = rawB or {} + idNames = idNames or {} + local names = {} + local ids = {} + local idsA = {} + local idsB = {} + local textures = {} -- [buffName] = icon path + local namedIds = {} -- ids that arrived WITH a name (path A rows) + local usable = 0 -- entries that carry actual evidence (see header) + + local nA = table.getn(rawA) + for i = 1, nA do + local e = rawA[i] + local hasInfo = false + if e.name then + names[e.name] = true + hasInfo = true + if e.texture then + textures[e.name] = e.texture + end + end + if type(e.id) == "number" and e.id > 0 then + idsA[e.id] = true + ids[e.id] = true + hasInfo = true + if e.name then + namedIds[e.id] = true + end + end + if hasInfo then + usable = usable + 1 + end + end + + local nB = table.getn(rawB) + for i = 1, nB do + local id = rawB[i] + if type(id) == "number" and id > 0 then + idsB[id] = true + ids[id] = true + usable = usable + 1 + end + end + + local aurasRead = usable > 0 + + -- Optional icon resolution for ids that carried no name (path-B-only + -- ids, or nameless path-A rows): SuperWoW SpellInfo(id) -> name, rank, + -- icon (3rd return). Runtime-only enhancement -- SpellInfo is nil in + -- offline tests and may be absent in-game (non-SuperWoW client); + -- textures simply stays partial then. Path-A textures win (never + -- overwritten): UnitBuff saw the actual aura, SpellInfo is a lookup. + if SpellInfo then + for id in pairs(ids) do + if not namedIds[id] then + local okSI, nm, _, icon = pcall(SpellInfo, id) + if okSI and nm and icon and textures[nm] == nil then + textures[nm] = icon + end + end + end + end + + -- disagreement count: symmetric difference of the ID sets, but only + -- when both paths actually delivered IDs (else it is path absence) + local disagreements = 0 + if next(idsA) ~= nil and next(idsB) ~= nil then + for id in pairs(idsA) do + if not idsB[id] then + disagreements = disagreements + 1 + end + end + for id in pairs(idsB) do + if not idsA[id] then + disagreements = disagreements + 1 + end + end + end + + -- unknown-aura collection (no silent discard) + local unknown = {} + local unknownNamesA = {} -- names already added to `unknown` from path A + local cons = DC.CONSUMABLES or {} + local cbuf = DC.CLASSBUFFS or {} + local s2s = DC.SPELL_TO_SLOT or {} + for i = 1, nA do + local e = rawA[i] + -- name-known OR id-known -> known. Deliberately NOT an + -- if/elseif chain gated on "no name": a row can carry a name + -- that fails to match a table key exactly (SpellInfo() aura- + -- name spelling vs. the hand-authored table key) while still + -- carrying a correct spell id -- the id must be checked + -- regardless of whether a name is present (belied a 26-man + -- raid: a correctly ID-tracked consumable was discarded 38 + -- times because its live aura-name spelling did not match the + -- table key it was filed under). + local known = isKnownAuraName(e.name, cons, cbuf) + if not known and type(e.id) == "number" and s2s[e.id] then + known = true + end + if not known then + if e.name then + table.insert(unknown, e.name) + unknownNamesA[e.name] = true + elseif e.id then + table.insert(unknown, "spell:" .. e.id) + end + -- entry with neither name nor id carries no information: skip + end + end + -- path-B-only ids: with a resolved name (idNames, WoW-side SpellInfo + -- lookup) an id is judged by name exactly like a path-A entry -- + -- known consumable/class-buff name is not unknown, and a name already + -- reported from path A is not reported again (this is the dedup: on + -- clients where UnitBuff never yields a spell ID, idsA stays empty + -- and the id-based check below is a no-op, so the same aura would + -- otherwise be counted once as a name and once as "spell:"). + -- Without a resolved name, behavior is unchanged: "spell:". + for i = 1, nB do + local id = rawB[i] + if type(id) == "number" and id > 0 and not idsA[id] and not s2s[id] then + local nm = idNames[id] + if not nm then + table.insert(unknown, "spell:" .. id) + elseif isKnownAuraName(nm, cons, cbuf) then + -- known by resolved name (exact or trimmed -- same rule + -- as the path-A loop above): not unknown, nothing to add + elseif unknownNamesA[nm] then + -- already reported via path A under this name: skip + else + table.insert(unknown, nm) + end + end + end + + return { names = names, ids = ids, textures = textures }, + aurasRead, disagreements, unknown +end + +-- SplitAuraField(t) -> buffIds, debuffIds (two arrays of spell ids) +-- Splits ONE GetUnitField(guid, "aura") descriptor table into its buff +-- half (one-based slots 1-32) and debuff half (slots 33-48). The table is +-- SPARSE (in-game verified, see header) -- every slot is visited, empties +-- and non-positive/non-number values are skipped, no break at gaps. +-- Pure -- offline-tested in test_scan_parse.lua. +function A.SplitAuraField(t) + local buffs = {} + local debuffs = {} + if type(t) == "table" then + for i = 1, 32 do + local v = t[i] + if type(v) == "number" and v > 0 then + table.insert(buffs, v) + end + end + for i = 33, 48 do + local v = t[i] + if type(v) == "number" and v > 0 then + table.insert(debuffs, v) + end + end + end + return buffs, debuffs +end + +-- AssembleDebuffs(rawDA, rawDB) -> debuffs +-- rawDA : array of { name = string|nil, id = number|nil, +-- texture = string|nil } (UnitDebuff path, same row +-- shape as the buff path A) +-- rawDB : array of spell ids (GetUnitField debuff half, slots 33-48) +-- Returns the store shape: +-- { names = { [name]=true }, ids = { [id]=true }, +-- textures = { [name]=iconPath } } +-- Merge mirrors A.Assemble: union of the ID sets, names + textures from +-- the tooltip path, optional SpellInfo icon resolution for nameless ids. +-- NO read flag is derived here (readability = the existing aurasRead +-- flag; empty sets are a legitimate clean state) and NO unknown list is +-- returned (debuffs never feed DC.unknownSeen -- see header). +function A.AssembleDebuffs(rawDA, rawDB) + rawDA = rawDA or {} + rawDB = rawDB or {} + local names = {} + local ids = {} + local textures = {} + local namedIds = {} + + local nA = table.getn(rawDA) + for i = 1, nA do + local e = rawDA[i] + if e.name then + names[e.name] = true + if e.texture then + textures[e.name] = e.texture + end + end + if type(e.id) == "number" and e.id > 0 then + ids[e.id] = true + if e.name then + namedIds[e.id] = true + end + end + end + + local nB = table.getn(rawDB) + for i = 1, nB do + local id = rawDB[i] + if type(id) == "number" and id > 0 then + ids[id] = true + end + end + + -- optional icon resolution for nameless ids (mirrors A.Assemble: + -- SpellInfo is a SuperWoW runtime lookup, nil offline; tooltip-path + -- textures are never overwritten) + if SpellInfo then + for id in pairs(ids) do + if not namedIds[id] then + local okSI, nm, _, icon = pcall(SpellInfo, id) + if okSI and nm and icon and textures[nm] == nil then + textures[nm] = icon + end + end + end + end + + return { names = names, ids = ids, textures = textures } +end + +-- ================================================================== +-- WoW part (runtime only; every call pcall-wrapped) +-- ================================================================== + +-- Hidden tooltip scanner (standard 1.12 pattern). +-- The frame is NAMED, and the name is REQUIRED rather than cosmetic: the +-- line FontStrings are read back via getglobal("TextLeft1"). +local scanTip = nil + +local function ensureTip() + if not scanTip and CreateFrame then + scanTip = CreateFrame("GameTooltip", "DopingControlScanTip", + nil, "GameTooltipTemplate") + scanTip:SetOwner(WorldFrame, "ANCHOR_NONE") + end + return scanTip +end + +-- shared accessor (core/probe.lua reuses the same hidden tooltip instead +-- of creating a second frame under the same global name) +function A.GetScanTip() + return ensureTip() +end + +-- raises an error on bad units -> always called through pcall +local function tipBuffName(unit, buffIndex) + scanTip:ClearLines() + scanTip:SetUnitBuff(unit, buffIndex) + local textObj = getglobal("DopingControlScanTipTextLeft1") + if textObj then + return textObj:GetText() + end + return nil +end + +-- debuff twin of tipBuffName (same hidden tooltip, SetUnitDebuff); +-- raises an error on bad units -> always called through pcall +local function tipDebuffName(unit, debuffIndex) + scanTip:ClearLines() + scanTip:SetUnitDebuff(unit, debuffIndex) + local textObj = getglobal("DopingControlScanTipTextLeft1") + if textObj then + return textObj:GetText() + end + return nil +end + +-- ReadUnit(unit, guid) -> rawA, rawB, rawDA, rawDB, idNames +-- rawA/rawB : buff shapes as taken by A.Assemble +-- rawDA/rawDB: debuff shapes as taken by A.AssembleDebuffs (rawDA from +-- the UnitDebuff 1..16 loop, rawDB = debuff half slots +-- 33-48 of the SAME GetUnitField fetch as rawB) +-- idNames : { [spellId] = "Buff Name" } for the ids in rawB, resolved +-- via SuperWoW SpellInfo (same lookup A.Assemble already +-- uses for icons); taken by A.Assemble's optional 3rd +-- parameter. Extra return value -- existing callers that +-- only capture rawA..rawDB are unaffected. +function A.ReadUnit(unit, guid) + local rawA = {} + local rawDA = {} + if UnitBuff then + local tip = ensureTip() + for i = 1, 32 do + -- 4th return = SuperWoW spell id (unverified on other builds; probe Q1) + local ok, texture, stacks, dtype, auraID = pcall(UnitBuff, unit, i) + if not ok or not texture then + break + end + local name = nil + if tip then + local ok2, nm = pcall(tipBuffName, unit, i) + if ok2 then + name = nm + end + end + local id = nil + if type(auraID) == "number" and auraID > 0 then + id = auraID + end + -- texture (UnitBuff return 1) rides along so Assemble can key + -- it under the tooltip name (auras.textures) + table.insert(rawA, { name = name, id = id, texture = texture }) + end + end + if UnitDebuff then + -- mirror of the buff loop: 16 debuff slots on 1.12, list is + -- compacted (stop at the first nil texture), name via the same + -- hidden tooltip, spell id via 4th return if present + local tip = ensureTip() + for i = 1, 16 do + local ok, texture, stacks, dtype, auraID = pcall(UnitDebuff, unit, i) + if not ok or not texture then + break + end + local name = nil + if tip then + local ok2, nm = pcall(tipDebuffName, unit, i) + if ok2 then + name = nm + end + end + local id = nil + if type(auraID) == "number" and auraID > 0 then + id = auraID + end + table.insert(rawDA, { name = name, id = id, texture = texture }) + end + end + + local rawB = {} + local rawDB = {} + if GetUnitField and guid then + local ok, t = pcall(GetUnitField, guid, "aura") + if ok and type(t) == "table" then + -- ONE fetch carries both halves (sparse 48-slot table): + -- buffs 1-32, debuffs 33-48 -- split without extra API calls + rawB, rawDB = A.SplitAuraField(t) + end + end + + -- name resolution for path-B ids (SuperWoW SpellInfo lookup, same + -- pattern/guarding as the icon resolution in A.Assemble): path-B ids + -- never carry a name of their own, so every id here is a candidate. + -- Runtime-only, pcall-wrapped, nil-safe; stays empty offline/without + -- SuperWoW, which leaves A.Assemble's unknown-aura reporting exactly + -- as before ("spell:"). + local idNames = {} + if SpellInfo then + local nB = table.getn(rawB) + for i = 1, nB do + local id = rawB[i] + if type(id) == "number" and id > 0 then + local okSI, nm = pcall(SpellInfo, id) + if okSI and nm then + idNames[id] = nm + end + end + end + end + + return rawA, rawB, rawDA, rawDB, idNames +end diff --git a/scan/engine.lua b/scan/engine.lua new file mode 100644 index 0000000..39ad35c --- /dev/null +++ b/scan/engine.lua @@ -0,0 +1,1103 @@ +-- DopingControl scan/engine.lua +-- Scan orchestration: roster snapshot, per-unit reads (reach/aura/gear/ +-- weapon), OnUpdate batching (5 units per frame), store +-- assembly and the DC.OnStoreUpdated() completion hook +-- (wired by core/init.lua). +-- +-- Triggers: +-- * manual: DC_Scan.Start(force) +-- * READY_CHECK: registered DEFENSIVELY via pcall (unverified as a +-- native client event on 1.12; if the +-- registration throws, the feature silently does not exist). Honors +-- db.readyCheckScan and never fires in test mode (sim store). +-- * roster change (RAID_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED): raid +-- indices are VOLATILE after these events, +-- so a running scan is restarted with a fresh roster snapshot; a +-- finished store is flagged stale via DC.storeInvalid. +-- No UNIT_AURA registration at all: UNIT_AURA is unreliable for raidN +-- tokens on 1.12 -- we scan on demand instead. +-- +-- Diagnostics filled here: +-- DC.probeStats = { scans, unitsPathA, unitsPathB, unitsBoth, +-- idDisagreements, lastScan = { unitsA, unitsB, both, +-- disagreements } } +-- -- cumulative A/B aura-path coverage + ID-set +-- disagreement counts (scan/aura.lua merge) +-- DC.unknownSeen = { [nameOrSpellKey] = count } -- auras no table knows +-- (no silent discard, dumped by /dc unknown) +-- +-- Last-known cache: DC.lastKnown -- see the +-- "Last-known cache" block below. THE IRON RULE: cached data is DISPLAY-ONLY; +-- it never counts as HAS/MISSING in gaps, ready pills, sums, footer or +-- report. A flask seen 20 minutes ago is not verified preparation. +-- +-- RESIST tab data: resistances are read via THREE DIRECT fallback paths +-- (unit token, GUID, GetUnitField descriptor -- see readResists/ +-- AssembleResists below), independent of the aura/gear reads, plus a +-- FOURTH gear-reconstruction path for foreign players (added 2026-08-02): +-- the server never transmits UNIT_FIELD_RESISTANCES for anyone but the +-- owning client, so the three direct paths can only ever answer for the +-- own character -- every foreign player's resistances are rebuilt from +-- equipped-item tooltips (scan/hit.lua's per-item cache, scan/resist.lua's +-- pure parser) plus the racial bonus (data/resist.lua DC.RACE_RESIST). All +-- four ride into the store as p.resists/p.resistsRead/p.resistsSource +-- ("token"|"guid"|"field"|"gear"|nil for self). +-- +-- HIT tab data: scan/hit.lua sums the +hit of the equipped items from +-- their tooltips -- there is no hit API on this client -- and rides into +-- the store as p.hit (its own hitRead flag lives INSIDE that table) plus +-- p.race, which the display layer needs for the racial weapon-skill +-- estimate of foreign players. The hit read reuses the item links the gear +-- read already fetched, so it costs one extra link call (the waist, which +-- has no enchant column) plus one tooltip build per item that is not in +-- scan/hit.lua's per-item cache. This SAME read now also produces +-- hit.resistGear/resistGearRead (see above), which is why scanOne runs the +-- hit read BEFORE the resist read despite RESIST sitting before HIT on the +-- tab bar. +-- +-- WEAPON SKILL is readable for the OWN CHARACTER ONLY, and is stated as +-- such rather than guessed for everyone else: MEASURED via +-- GetSkillLineInfo and stored as hit.skill/hit.skillExact, while foreign +-- weapon skill has no channel and stays an estimate made at display time. +-- +-- TALENT HIT (data/talenthit.lua) has TWO sources that meet in the same +-- fields: GetTalentInfo for the own character, and this server's inspect +-- protocol (scan/talents.lua) for everyone else. Both fill +-- hit.talentMelee/talentRanged/talentSpell/talentOffhand/talentSchools +-- with hit.talentsRead = true. A player whose reply has not arrived yet +-- keeps talentsRead = false and zeroed talent fields, and their displayed +-- number EXCLUDES talents until it does. +-- +-- Pure Lua 5.0, dofile-loadable offline: NormalizeWeapon / +-- NormalizeWeaponName / AssembleResists / BuildPlayer / NewStore / +-- UpdateLastKnown / SweepLastKnown are pure (offline-tested); everything +-- that touches frames or the WoW API sits behind `if CreateFrame then`. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Scan = DC_Scan or {} +local E = DC_Scan + +E.BATCH = 5 -- units processed per OnUpdate frame + +DC.probeStats = DC.probeStats or { + scans = 0, unitsPathA = 0, unitsPathB = 0, unitsBoth = 0, + idDisagreements = 0, + lastScan = { unitsA = 0, unitsB = 0, both = 0, disagreements = 0 }, +} +DC.unknownSeen = DC.unknownSeen or {} + +-- ================================================================== +-- Pure part +-- ================================================================== + +-- NormalizeWeapon(isSelf, v, readable) -> weaponMain (true|false|nil, store shape) +-- self : v = 1st return of GetWeaponEnchantInfo() (hasMainEnchant); +-- the own unit is always readable => never nil +-- foreign: v = 1st return of GetWeaponEnchantInfo(unit) (SuperWoW): a name +-- string => true. `readable` says whether the unit's main hand +-- could be read at all and is what makes nil decidable. +-- +-- Measured 2026-08-02 across a 26-man raid, every unit with a readable main +-- hand: nil came back 14 times, a name 11 times, the empty string NEVER. So +-- the old ""-means-no-imbue contract (documented here as "unverified") is +-- dead code, and nil carries BOTH real cases at once: "carries no imbue" and +-- "we could not read this unit". Treating nil as unknown -- as this function +-- did -- meant the WPN column could never report a gap: all 14 players +-- without a weapon buff showed a grey "?" and were counted as unreadable, so +-- they never reached the gap tally, the footer or a whisper. That is the one +-- thing a raid buff checker exists to say. +-- +-- `readable` separates the two: a unit whose main-hand item link resolves is +-- readable, so nil from the enchant call means there is genuinely no imbue +-- (=> false, a real gap). Without the hint the old behaviour stands (nil), +-- which keeps every existing caller and the self path untouched. +function E.NormalizeWeapon(isSelf, v, readable) + if isSelf then + if v then + return true + end + return false + end + if v == nil then + if readable == true then + return false + end + return nil + end + if v == "" then + return false + end + return true +end + +-- NormalizeWeaponName(v) -> weaponMainName (string|nil, store shape). +-- v = 1st return of GetWeaponEnchantInfo(unit) +-- (SuperWoW name path): non-empty string => the temp-enchant name; +-- anything else => nil (measured in-game: nil at cross-continent +-- distance -- acceptable, the weapon cell stays UNKNOWN per the existing +-- weaponMain rules). +function E.NormalizeWeaponName(v) + if type(v) == "string" and v ~= "" then + return v + end + return nil +end + +-- BuildPlayer(base, online, distance, auraRes, gearRes, weaponMain, +-- weaponMainName, resRes) +-- base : { name, class (english token), guid|nil, subgroup|nil } +-- online : bool (roster-first, reach fallback -- merged by the caller) +-- auraRes : nil | { read = bool, auras = { names={}, ids={} }, +-- debuffs = nil | { names={}, ids={}, textures={} } } +-- (readability rides on +-- read/aurasRead -- empty debuff sets are a legitimate clean +-- state, never a failure) +-- gearRes : nil | { read = bool, gear = table|nil } +-- weaponMainName : string|nil -- main-hand temp-enchant NAME +-- (self: tooltip scan via +-- DC_Gear.ReadSelfWeaponEnchantName, foreign: SuperWoW +-- GetWeaponEnchantInfo(unit) name); rides into the store as +-- p.weaponMainName -- DC_Model uses it as the weapon HAS +-- detail when set +-- resRes : nil | { read = bool, resists = table|nil, source = string|nil } +-- (E.AssembleResists' three returns, same read/wrapper +-- convention as auraRes/gearRes) -- rides into the store as +-- p.resists/p.resistsRead/p.resistsSource; independent of +-- aurasRead/gearRead (a resistance read is neither an aura +-- nor a gear read) +-- hitRes : nil | { hit = table|nil, race = string|nil } (DC_Hit.ReadUnit's +-- two returns). DELIBERATELY SHAPED DIFFERENTLY from the +-- wrappers above: there is no `read` key, because the hit +-- table carries its OWN hitRead flag and rides into the store +-- EVEN WHEN the read failed (p.hit.hitRead == false). Every +-- consumer must therefore test p.hit and p.hit.hitRead -- +-- the zeros in a failed read are not a measurement. `race` is +-- the race token, stored as p.race: the display layer needs it +-- to estimate a FOREIGN player's weapon skill (racial +-- specialization), which no API can read. +-- +-- Hit-granting AURAS (DC.HIT_AURAS, data/hit.lua) are folded +-- in RIGHT HERE, not inside scan/hit.lua's ReadUnit -- that +-- module only ever sees item tooltips, never the aura scan. +-- No extra client call: p.auras was already assembled above +-- by the SAME scan. The split is kept so the cell tooltip can +-- show every source apart: hit.gearMelee/hit.gearSpell are the +-- tooltip-parsed sums (unchanged from scan/hit.lua), +-- hit.auraMelee/hit.auraRanged/hit.auraSpell are the aura +-- sums, hit.talentMelee/talentRanged/talentSpell/ +-- talentOffhand/talentSchools are the TALENT sums the scan +-- filled in for the own character (normalized to zeros here +-- for everyone else, with hit.talentsRead saying which it +-- was), and hit.melee/hit.spell become the TOTAL -- the field +-- every existing display-layer caller already reads. +-- hit.aurasRead mirrors p.aurasRead at the moment this ran, so +-- a failed aura channel shows as "aura sources unknown" +-- (ui/matrix.lua) instead of a silent zero; hit.talentsRead +-- does the same job for talents. +-- Deliberately independent of any expectation/role table -- +-- BuildPlayer never sees one -- so a hit-granting aura counts +-- here even when its class-buff column (e.g. EMB) is not +-- expected/active for this player at all. +-- -> one store player entry. Readability rule +-- (binding): online==false => "offline"; else aurasRead==false +-- (aura paths yielded ZERO auras) => "read failed"; else nil. +function E.BuildPlayer(base, online, distance, auraRes, gearRes, weaponMain, + weaponMainName, resRes, hitRes) + local p = { + name = base.name, + class = base.class, + online = (online and true) or false, + guid = base.guid, + subgroup = base.subgroup, + distance = distance, + aurasRead = false, + auras = nil, + weaponMain = weaponMain, + weaponMainName = weaponMainName, + gearRead = false, + gear = nil, + resistsRead = false, + resists = nil, + resistsSource = nil, + hit = nil, + race = nil, + unreadableReason = nil, + } + if auraRes and auraRes.read then + p.aurasRead = true + p.auras = auraRes.auras + p.debuffs = auraRes.debuffs + end + if gearRes and gearRes.read then + p.gearRead = true + p.gear = gearRes.gear + end + if resRes and resRes.read then + p.resistsRead = true + p.resists = resRes.resists + p.resistsSource = resRes.source + end + -- hit: attached WHATEVER the outcome (see the hitRes doc above) -- + -- readability lives in p.hit.hitRead, not in a wrapper flag + if hitRes then + p.hit = hitRes.hit + p.race = hitRes.race + end + -- hit-granting auras (DC.HIT_AURAS, data/hit.lua) folded on top of the + -- gear sum -- see the hitRes doc above for the field split and why + -- this runs here rather than in scan/hit.lua + if p.hit then + -- SET bonuses are part of the scanned totals but are reported + -- separately, so the tooltip can print the number as an addition + -- (items + set bonuses + auras + talents) rather than one merged + -- figure. "gear" therefore means ITEMS ONLY from here on: the set + -- part is subtracted out, never counted twice. + p.hit.setMelee = p.hit.setMelee or 0 + p.hit.setSpell = p.hit.setSpell or 0 + p.hit.setSchools = p.hit.setSchools or {} + p.hit.gearMelee = (p.hit.melee or 0) - p.hit.setMelee + p.hit.gearSpell = (p.hit.spell or 0) - p.hit.setSpell + p.hit.aurasRead = p.aurasRead + -- TALENTS (data/talenthit.lua): filled from GetTalentInfo for the + -- own character and from this server's inspect protocol + -- (scan/talents.lua) for everyone else. Whoever has not answered + -- YET keeps talentsRead = false and clean zeros. They are + -- normalized HERE so no consumer has to nil-check them and so a + -- hand-built or older store (sim, tests) folds identically. + if p.hit.talentsRead == nil then + p.hit.talentsRead = false + end + p.hit.talentMelee = p.hit.talentMelee or 0 + p.hit.talentRanged = p.hit.talentRanged or 0 + p.hit.talentSpell = p.hit.talentSpell or 0 + p.hit.talentOffhand = p.hit.talentOffhand or 0 + p.hit.talentSchools = p.hit.talentSchools or {} + -- the aura half only exists when the aura channel worked: a FAILED + -- read must not leave a zero that reads like a measurement, which + -- is why auraMelee/auraRanged/auraSpell stay nil there (the cell + -- tooltip tells "no aura present" from "aura sources unknown" by + -- exactly this) + local auraMelee, auraSpell = 0, 0 + if p.aurasRead and DC_Hit and DC_Hit.AuraHit then + local a = DC_Hit.AuraHit(p.auras and p.auras.names) + p.hit.auraMelee = a.melee + p.hit.auraRanged = a.ranged + p.hit.auraSpell = a.spell + auraMelee = a.melee + auraSpell = a.spell + end + -- the TOTALS every display-layer caller reads. The ranged column + -- has no total of its own on purpose: it shares the melee GEAR + -- number (this client has no separate ranged-hit item stat) but + -- has its own aura and talent halves, so it is composed as + -- gearMelee + auraRanged + talentRanged where it is shown. + p.hit.melee = p.hit.gearMelee + auraMelee + p.hit.talentMelee + p.hit.spell = p.hit.gearSpell + auraSpell + p.hit.talentSpell + end + if not p.online then + p.unreadableReason = "offline" + elseif not p.aurasRead then + p.unreadableReason = "read failed" + end + return p +end + +-- AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback) +-- -> resists (table|nil), resistsRead (bool), source (string|nil) +-- +-- Three DIRECT read paths, tried in priority order (token > guid > field); +-- each row array may be empty (the WoW-only caller skips a path once an +-- earlier one already delivered usable data -- see scanOne below): +-- tokenRows/guidRows : { school = 2..6, ok = bool, total = number|nil } +-- (raw pcall(UnitResistance, unitOrGuid, school) +-- results -- `total` is UnitResistance's 2nd +-- return; base/bonus/minus are not used here) +-- fieldRows : { school = 2..6, val = number|nil } (raw +-- GetUnitField(guid, "resistances") readout, +-- already split into rows by the caller) +-- +-- A path counts as USABLE when at least one of its rows has ok==true (or, +-- for fieldRows, a numeric val) -- the first usable path wins outright, +-- its rows become `resists` (missing schools simply absent from the +-- table; classify() defaults an absent school to 0). Field-path values +-- are trusted ONLY when positive (a lone unverified foreign zero is not +-- accepted from that path -- see the honesty rule below); token/guid +-- values are trusted at face value, including legitimate zeros. +-- +-- Honesty rule (binding): resistsRead = true only when +-- isSelf, OR the assembled `resists` contains at least one total > 0. A +-- foreign UnitResistance call can silently return 0 for an out-of-sight/ +-- out-of-range unit -- a LONE zero from a foreign read is not trusted (it +-- could be a real 0 resistance or a failed read that happens to default +-- to 0); a MIX with at least one real positive value is. `source` names +-- the path that won ("token"|"guid"|"field") for foreign reads, and is +-- always nil for the self read (self is unconditionally trusted, so the +-- specific path is not interesting -- it is always "token" in practice, +-- UnitResistance("player", school)). +-- +-- FOURTH path -- gear reconstruction (added 2026-08-02, see +-- scan/resist.lua and data/resist.lua's headers for the full rationale): +-- the server never transmits UNIT_FIELD_RESISTANCES for a foreign player +-- at all (Player::InitVisibleBits() only sets the bit for the owning +-- client), so paths 1-3 above are not merely "the usual case" for a +-- foreign unit -- they can NEVER succeed for one. This is the only path +-- that can ever answer for a foreign player, tried only once none of the +-- three DIRECT paths produced anything trustworthy. +-- gearFallback : nil, or { resistGear = { [school]=total }, +-- resistGearRead = bool, race = string|nil } -- exactly +-- DC_Hit.ReadUnit's hit.resistGear/hit.resistGearRead +-- plus its race return, so the WoW-only caller can just +-- hand its `hit`/`race` locals through unpacked. +-- Gated on resistGearRead, NOT on "did any school come back positive": a +-- tooltip-based read has no silent-zero ambiguity the way UnitResistance +-- does for an out-of-range foreign unit (a GameTooltip:SetInventoryItem +-- call either returns real lines or it visibly fails, see +-- scan/hit.lua's header) -- once resistGearRead is true, an all-zero +-- gear+racial total is a GENUINE verified zero, not a guess. +-- NEVER taken for the own character (`not isSelf`): the own character's +-- token path is always usable (see the honesty rule above) and must keep +-- priority over any reconstruction, per the addon's binding rule that a +-- MEASURED value always outranks a rebuilt one. +-- Racial bonus (DC_Resist.RaceBonus, data/resist.lua's DC.RACE_RESIST) is +-- added on top here rather than inside scan/hit.lua: it is not a gear +-- fact, and scan/hit.lua's job stops at "what the equipped items say". +-- Existence-guarded like every other DC_Resist call -- without that +-- module this path simply never activates. +function E.AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback) + -- `any` decides which path WINS, so it has to mean "this path carried + -- evidence". For a foreign unit an all-zero row set is not evidence + -- (see readResists): it would otherwise beat a descriptor path that + -- actually has the numbers. Zeros are still kept in `out` -- a mixed + -- read (0 fire, 24 nature) is legitimate and complete. + local function usableRows(rows) + local out = {} + local any = false + rows = rows or {} + for i = 1, table.getn(rows) do + local r = rows[i] + if r.ok and type(r.total) == "number" then + out[r.school] = r.total + if isSelf or r.total > 0 then + any = true + end + end + end + return out, any + end + + local resists, source + local tok, tokAny = usableRows(tokenRows) + if tokAny then + resists, source = tok, "token" + else + local gid, gidAny = usableRows(guidRows) + if gidAny then + resists, source = gid, "guid" + else + local fld = {} + local fldAny = false + local rows = fieldRows or {} + for i = 1, table.getn(rows) do + local r = rows[i] + -- field path: positive values only (see header) -- + -- a zero/nil field entry carries no evidence + if type(r.val) == "number" and r.val > 0 then + fld[r.school] = r.val + fldAny = true + end + end + if fldAny then + resists, source = fld, "field" + end + end + end + + if resists ~= nil then + local anyPositive = false + for _, v in pairs(resists) do + if v > 0 then + anyPositive = true + end + end + if isSelf or anyPositive then + if isSelf then + source = nil + end + return resists, true, source + end + -- all-zero foreign read from a direct path: falls through to the + -- gear fallback below exactly like "no direct path answered at + -- all" -- an untrusted all-zero carries no more evidence than no + -- read at all (see the honesty rule above) + end + + -- FOURTH path: gear reconstruction (see the header addendum above) -- + -- never for the own character, and only once resistGearRead proves at + -- least one item tooltip was actually read this scan. + if not isSelf and gearFallback and gearFallback.resistGearRead then + local g = {} + for school, v in pairs(gearFallback.resistGear or {}) do + g[school] = v + end + if DC_Resist and DC_Resist.RaceBonus then + local racial = DC_Resist.RaceBonus(gearFallback.race) + for school, v in pairs(racial) do + g[school] = (g[school] or 0) + v + end + end + return g, true, "gear" + end + + return nil, false, nil +end + +-- NewStore(players, scanAt) -> store (source = "scan") +function E.NewStore(players, scanAt) + return { + scanAt = scanAt, + source = "scan", + players = players or {}, + } +end + +-- ================================================================== +-- Last-known cache +-- ================================================================== +-- DC.lastKnown[name] = { auras=, aurasAt=, +-- debuffs=, debuffsAt=, gear=, gearAt=, weaponMain=, +-- weaponMainName=, weaponAt= } +-- Runtime-only: lives on DC, NEVER in DopingControlDB/SavedVariables. +-- Holds REFERENCES to the store tables (every scan builds fresh tables, +-- so a cache entry never aliases a live player's current data). + +DC.lastKnown = DC.lastKnown or {} + +-- Hard entry cap for the cap-safety sweep. +E.CACHE_CAP = 80 + +-- UpdateLastKnown(cache, p, now) -- both halves of the cache rule: +-- * successful channels overwrite the cache channel + its At stamp: +-- aurasRead=true (debuffs ride on the aura read -- they share its +-- success and its stamp), gearRead=true, weaponMain non-nil (false +-- included: a verified "no temp enchant" IS a successful read, and it +-- clears any stale cached name); +-- * failed channels attach the cached references onto the store player: +-- p.lastAuras/p.lastAurasAt, p.lastGear/p.lastGearAt, +-- p.lastWeaponMain/p.lastWeaponMainName/p.lastWeaponAt -- nil when no +-- cache. (No lastDebuffs: the DEBUFFS tab has no last-known channel +-- -- live afflictions only.) +-- Failure never erases an older cache entry, and a failed read never +-- CREATES an entry. +function E.UpdateLastKnown(cache, p, now) + if not p.name then + return + end + local entry = cache[p.name] + if p.aurasRead or p.gearRead or p.weaponMain ~= nil then + if not entry then + entry = {} + cache[p.name] = entry + end + end + if p.aurasRead then + entry.auras = p.auras + entry.aurasAt = now + entry.debuffs = p.debuffs + entry.debuffsAt = now + end + if p.gearRead then + entry.gear = p.gear + entry.gearAt = now + end + if p.weaponMain ~= nil then + entry.weaponMain = p.weaponMain + entry.weaponMainName = p.weaponMainName + entry.weaponAt = now + end + if not entry then + return + end + if not p.aurasRead then + p.lastAuras = entry.auras + p.lastAurasAt = entry.aurasAt + end + if not p.gearRead then + p.lastGear = entry.gear + p.lastGearAt = entry.gearAt + end + if p.weaponMain == nil then + p.lastWeaponMain = entry.weaponMain + p.lastWeaponMainName = entry.weaponMainName + p.lastWeaponAt = entry.weaponAt + end +end + +-- SweepLastKnown(cache, rosterSet, cap) -- cap-safety eviction (item 1). +-- rosterSet = { [name]=true } of the CURRENT roster. Roster members are +-- never evicted (their rows may need the data right now); once the cache +-- holds more than cap entries, roster-absent entries drop oldest-first +-- (oldest = smallest NEWEST At across the entry's channels; name is the +-- deterministic tiebreaker) until the size is back at cap. +function E.SweepLastKnown(cache, rosterSet, cap) + cap = cap or E.CACHE_CAP + rosterSet = rosterSet or {} + local size = 0 + for _ in pairs(cache) do + size = size + 1 + end + if size <= cap then + return + end + local cand = {} + for name, entry in pairs(cache) do + if not rosterSet[name] then + local newest = 0 + if entry.aurasAt and entry.aurasAt > newest then + newest = entry.aurasAt + end + if entry.debuffsAt and entry.debuffsAt > newest then + newest = entry.debuffsAt + end + if entry.gearAt and entry.gearAt > newest then + newest = entry.gearAt + end + if entry.weaponAt and entry.weaponAt > newest then + newest = entry.weaponAt + end + table.insert(cand, { name = name, at = newest }) + end + end + table.sort(cand, function(a, b) + if a.at ~= b.at then + return a.at < b.at + end + return a.name < b.name + end) + local i = 1 + local n = table.getn(cand) + while size > cap and i <= n do + cache[cand[i].name] = nil + size = size - 1 + i = i + 1 + end +end + +-- ================================================================== +-- WoW part +-- ================================================================== +if CreateFrame then + + local queue = {} -- pending roster entries + local results = {} -- store player entries, roster order + local running = false + local restartPending = false + + -- BuildRoster() -> array of { unit, name, class, online, guid, + -- subgroup }. online is true|false for raid entries (roster flag, + -- GetRaidRosterInfo return 8 = SERVER TRUTH) and nil for party + -- entries (no roster flag exists there -- scanOne falls back to the + -- reach result); "player" is trivially true. + -- Snapshot semantics: raid indices are volatile, so this + -- is (re)taken at scan (re)start, never cached across roster events. + -- Public: core/probe.lua reuses it for the probe battery. + function E.BuildRoster() + local list = {} + local nraid = 0 + if GetNumRaidMembers then + nraid = GetNumRaidMembers() or 0 + end + if nraid > 0 then + for i = 1, nraid do + -- 11 returns on 1.12; fileName (english class token) is + -- position 6, online position 8 + local ok, name, rank, subgroup, level, class, fileName, + zone, online = pcall(GetRaidRosterInfo, i) + if ok and name then + local unit = "raid" .. i + local guid = nil + if UnitExists then + -- SuperWoW: 2nd return of UnitExists = GUID + local ok2, ex, g = pcall(UnitExists, unit) + if ok2 and ex and g then + guid = g + end + end + table.insert(list, { + unit = unit, name = name, class = fileName, + online = (online and true) or false, + guid = guid, subgroup = subgroup, + }) + end + end + else + local units = { "player", "party1", "party2", "party3", "party4" } + for i = 1, 5 do + local unit = units[i] + local exists = false + local guid = nil + if UnitExists then + local ok, ex, g = pcall(UnitExists, unit) + if ok and ex then + exists = true + if g then + guid = g + end + end + end + if exists then + local name = UnitName(unit) + local classToken = nil + if UnitClass then + local ok, loc, token = pcall(UnitClass, unit) + if ok then + classToken = token + end + end + -- party path has NO roster online flag: online stays + -- nil so scanOne falls back to the reach result; + -- the own unit is trivially online + local online = nil + if unit == "player" then + online = true + end + if name then + table.insert(list, { + unit = unit, name = name, class = classToken, + online = online, guid = guid, subgroup = nil, + }) + end + end + end + end + return list + end + + -- readWeapon(unit) -> weaponMain, weaponMainName + -- (the NAME rides along -- self via the hidden-tooltip scan, foreign + -- via the SuperWoW GetWeaponEnchantInfo(unit) name return) + local function readWeapon(unit) + if not GetWeaponEnchantInfo then + return nil, nil + end + local isSelf = false + if UnitIsUnit then + local ok, v = pcall(UnitIsUnit, unit, "player") + if ok and v then + isSelf = true + end + end + if isSelf then + local ok, hasMain = pcall(GetWeaponEnchantInfo) + if not ok then + return nil, nil + end + local main = E.NormalizeWeapon(true, hasMain) + local name = nil + if main and DC_Gear and DC_Gear.ReadSelfWeaponEnchantName then + name = DC_Gear.ReadSelfWeaponEnchantName() + end + return main, name + end + local ok, mainName = pcall(GetWeaponEnchantInfo, unit) + if not ok then + return nil, nil + end + -- A nil name is ambiguous on its own (see E.NormalizeWeapon): it means + -- either "no imbue" or "this unit is not readable". The main-hand item + -- link decides -- if the slot resolves, the unit IS readable and the + -- missing imbue is a real gap. Measured: all 14 nil cases in a raid had + -- a resolvable main hand, i.e. they were genuinely unbuffed. + local readable = nil + if mainName == nil and GetInventoryItemLink then + local lok, link = pcall(GetInventoryItemLink, unit, 16) + readable = (lok and link ~= nil) or false + end + return E.NormalizeWeapon(false, mainName, readable), + E.NormalizeWeaponName(mainName) + end + + local function bumpUnknown(unknown) + local n = table.getn(unknown) + for i = 1, n do + local key = unknown[i] + DC.unknownSeen[key] = (DC.unknownSeen[key] or 0) + 1 + end + end + + -- readResists(unit, guid, isSelf, hit, race) -> resists, resistsRead, + -- source (E.AssembleResists' three returns). WoW-only gather for the + -- RESIST tab: three DIRECT read paths, each ATTEMPTED only once the + -- earlier one has already proven unusable (skips the extra client + -- calls on the common case -- token alone almost always answers for a + -- same-zone unit) -- plus the gear-reconstruction fallback (see + -- E.AssembleResists' header), which is why the caller now hands in the + -- `hit`/`race` results of this SAME scan's hit read (scanOne runs the + -- hit read before this call precisely so hit.resistGear/ + -- resistGearRead are available here; no extra client call of its own). + -- School range 2..6 (fire/nature/frost/shadow/arcane); school 0 + -- (physical) and 1 (holy, always 0) are never queried. + local function readResists(unit, guid, isSelf, hit, race) + local tokenRows = {} + if UnitResistance then + for school = 2, 6 do + local ok, base, total = pcall(UnitResistance, unit, school) + table.insert(tokenRows, { school = school, ok = ok, + total = total }) + end + end + local guidRows = {} + local fieldRows = {} + -- A path counts as usable only if it produced EVIDENCE. For the + -- own character any number is evidence (a real 0 resistance is a + -- measurement). For a FOREIGN unit it is not: UnitResistance + -- returns ok=true with total=0 for every school of a player the + -- client cannot resolve, which is indistinguishable from a + -- genuinely unresisted character. Treating that as "usable" + -- claimed the token path and left the GUID and descriptor paths + -- below unreachable -- so every foreign player read as UNKNOWN + -- even though GetUnitField(guid, "resistances") had the numbers. + local tokenUsable = false + for i = 1, table.getn(tokenRows) do + local r = tokenRows[i] + if r.ok and type(r.total) == "number" + and (isSelf or r.total > 0) then + tokenUsable = true + end + end + if not tokenUsable and guid and UnitResistance then + -- SuperWoW accepts a GUID directly as the first argument on + -- several UnitXXX calls (source-confirmed for mob armor; + -- unverified for a player's elemental schools -- pcall-guarded + -- and gated by the same honesty rule as every other path) + for school = 2, 6 do + local ok, base, total = pcall(UnitResistance, guid, school) + table.insert(guidRows, { school = school, ok = ok, + total = total }) + end + end + local guidUsable = false + for i = 1, table.getn(guidRows) do + local r = guidRows[i] + if r.ok and type(r.total) == "number" + and (isSelf or r.total > 0) then + guidUsable = true + end + end + if not tokenUsable and not guidUsable and guid and GetUnitField then + local ok, t = pcall(GetUnitField, guid, "resistances") + if ok and type(t) == "table" then + -- 7-entry table, index convention verified defensively: + -- tried as 1-based for school 0..6 (t[school+1]); a + -- non-number entry is simply skipped (AssembleResists + -- also drops non-positive field values -- see its header) + for school = 2, 6 do + table.insert(fieldRows, { school = school, + val = t[school + 1] }) + end + end + end + local gearFallback = nil + if hit then + gearFallback = { resistGear = hit.resistGear, + resistGearRead = hit.resistGearRead, race = race } + end + return E.AssembleResists(isSelf, tokenRows, guidRows, fieldRows, + gearFallback) + end + + -- scan one roster entry into a store player entry. + -- Online source (measured in-game): the roster + -- online flag (GetRaidRosterInfo return 8) is SERVER TRUTH and WINS + -- over UnitIsConnected, which can read nil mid-zoning while the + -- player is actually online (false-offline bug). ONLY entries + -- without a roster flag (entry.online == nil, party path) fall back + -- to the reach result. Distance stays from reach (diagnostic only). + local function scanOne(entry) + local reachOnline, distance = DC_Reach.ReadUnit(entry.unit) + local online = entry.online + if online == nil then + online = reachOnline -- no roster flag: reach fallback + end + + local auraRes = nil + local gearRes = nil + local weaponMain = nil + local weaponMainName = nil + local resRes = nil + local hitRes = nil + + if online then + local rawA, rawB, rawDA, rawDB, idNames = + DC_Aura.ReadUnit(entry.unit, entry.guid) + local auras, aurasRead, disagreements, unknown = + DC_Aura.Assemble(rawA, rawB, idNames) + -- debuffs: assembled alongside, readability + -- rides on aurasRead; debuff names NEVER feed DC.unknownSeen + auraRes = { read = aurasRead, auras = auras, + debuffs = DC_Aura.AssembleDebuffs(rawDA, rawDB) } + bumpUnknown(unknown) + + -- diagnostic path coverage (plan: disagreements between path A + -- and path B are counted into DC.probeStats) + local ps = DC.probeStats + local hadA = table.getn(rawA) > 0 + local hadB = table.getn(rawB) > 0 + if hadA then + ps.unitsPathA = ps.unitsPathA + 1 + ps.lastScan.unitsA = ps.lastScan.unitsA + 1 + end + if hadB then + ps.unitsPathB = ps.unitsPathB + 1 + ps.lastScan.unitsB = ps.lastScan.unitsB + 1 + end + if hadA and hadB then + ps.unitsBoth = ps.unitsBoth + 1 + ps.lastScan.both = ps.lastScan.both + 1 + end + ps.idDisagreements = ps.idDisagreements + disagreements + ps.lastScan.disagreements = ps.lastScan.disagreements + disagreements + + local rawLinks = DC_Gear.ReadUnit(entry.unit) + local gear, gearRead = DC_Gear.Assemble(rawLinks, DC_Gear.InvSlots(), + DC_Gear.ItemInfo) + gearRes = { read = gearRead, gear = gear } + + weaponMain, weaponMainName = readWeapon(entry.unit) + + local isSelf = false + if UnitIsUnit then + local ok, v = pcall(UnitIsUnit, entry.unit, "player") + isSelf = (ok and v) and true or false + end + + -- hit: the item links from the gear read above are handed over + -- so the tooltip pass does not fetch them a second time. + -- Declared OUTSIDE the if-block (unlike before this scope held + -- only hitRes) because readResists below now needs `hit`/ + -- `race` too, for the gear-reconstruction fallback -- see + -- E.AssembleResists' header for why the hit read must run + -- BEFORE the resist read. + local hit, race = nil, nil + if DC_Hit and DC_Hit.ReadUnit then + hit, race = DC_Hit.ReadUnit(entry.unit, rawLinks) + if isSelf and hit and DC_Hit.ReadSelfSkill then + -- the own character is the ONLY one whose weapon skill + -- can be MEASURED (rank + modifier); for everyone else + -- it is unreadable in principle and stays an estimate + if hit.skillName then + local exact = DC_Hit.ReadSelfSkill(hit.skillName) + if exact then + hit.skill = exact + hit.skillExact = true + end + end + local rangedSkillName = DC.WEAPON_SKILL_OF_SUBTYPE + and DC.WEAPON_SKILL_OF_SUBTYPE[hit.rangedSub] + if rangedSkillName then + local rexact = DC_Hit.ReadSelfSkill(rangedSkillName) + if rexact then + hit.rangedSkill = rexact + hit.rangedSkillExact = true + end + end + end + -- estimate whatever was not measured: base skill + racial + -- specialization + the user's per-player weapon-skill-book + -- flag. Snapshot semantics -- a flag toggled after the scan + -- reaches the display layer, not this table. + if DC_Hit.ApplyEstimate then + local db = DopingControlDB + local books = nil + if db and db.skillBooks then + books = db.skillBooks[entry.name] + end + DC_Hit.ApplyEstimate(hit, race, books) + end + -- TALENT hit (data/talenthit.lua). TWO sources, ONE + -- mathematics: + -- * own character -- GetTalentInfo, which has no unit + -- parameter and never will (the server transmits no + -- foreign talents at all: no SMSG_INSPECT_TALENT in + -- this fork, no talent field in InitVisibleBits); + -- * every other player -- this server's own inspect + -- protocol (scan/talents.lua): we ask by name, their + -- client answers with its whole tree. + -- Both hand the SAME name->rank table to the same + -- DC_Hit.TalentHit, so a foreign hit number cannot drift + -- from an own one. talentsRead stays false only while no + -- plausible reply has arrived yet -- that is a "not yet", + -- not "impossible". The dual-wield gate uses the weapon + -- subtypes the item pass just resolved. + if hit and DC_Hit.TalentHit then + local ranks = nil + if isSelf then + if DC_Hit.ReadSelfTalents then + ranks = DC_Hit.ReadSelfTalents() + end + elseif DC_Talents and DC_Talents.RanksFor then + ranks = DC_Talents.RanksFor(entry.name) + end + if ranks then + local dw = DC_Hit.DualWield(hit.mainSub, hit.offSub) + local t = DC_Hit.TalentHit(entry.class, ranks, dw) + hit.talentMelee = t.melee + hit.talentRanged = t.ranged + hit.talentSpell = t.spell + hit.talentOffhand = t.offhand + hit.talentSchools = t.schools + hit.talentsRead = true + end + end + hitRes = { hit = hit, race = race } + end + + local resists, resistsRead, source = + readResists(entry.unit, entry.guid, isSelf, hit, race) + resRes = { read = resistsRead, resists = resists, source = source } + end + + local p = E.BuildPlayer(entry, online, distance, auraRes, gearRes, + weaponMain, weaponMainName, resRes, hitRes) + -- last-known cache: successful channels refresh + -- DC.lastKnown, failed channels get the cached references attached + -- as p.last* -- display-only for the model (IRON RULE) + local now = 0 + if GetTime then + now = GetTime() + end + E.UpdateLastKnown(DC.lastKnown, p, now) + table.insert(results, p) + end + + -- one reused batch frame (allocating a throwaway frame per + -- batch would leak; we use ONE named frame). + -- CAUTION: hidden frames receive no OnUpdate on 1.12, so the frame is + -- Show()n for the duration of a scan (it has no regions -- draws + -- nothing) and Hide()n again in finish(). + local scanFrame = CreateFrame("Frame", "DopingControlScanFrame") + scanFrame:Hide() + + local function beginQueue() + queue = E.BuildRoster() + results = {} + DC.probeStats.lastScan = + { unitsA = 0, unitsB = 0, both = 0, disagreements = 0 } + end + + local function finish() + running = false + scanFrame:SetScript("OnUpdate", nil) + scanFrame:Hide() + -- cap-safety sweep on the last-known cache: runs on + -- every finished pass, roster members are never evicted + local rosterSet = {} + for i = 1, table.getn(results) do + rosterSet[results[i].name] = true + end + E.SweepLastKnown(DC.lastKnown, rosterSet, E.CACHE_CAP) + local db = DopingControlDB + if db and db.testMode then + -- Test mode was switched ON while this scan was in flight: the + -- simulator fully replaces the data source ("real + -- data never beats the simulation") -- drop the result. + results = {} + return + end + local now = 0 + if GetTime then + now = GetTime() + end + DC.probeStats.scans = DC.probeStats.scans + 1 + -- Line up talent requests for everyone this scan actually saw + -- (scan/talents.lua paces and de-duplicates them; a player whose + -- talents we already hold is skipped there, not here). Only + -- players with a KNOWN CLASS are worth asking: DC_Hit.TalentHit + -- keys its table by class, so a reply we cannot attribute would be + -- traffic for nothing. Own character excluded -- GetTalentInfo is + -- authoritative and free for that one. + if DC_Talents and DC_Talents.Request then + local me = UnitName and UnitName("player") or nil + local names = {} + for i = 1, table.getn(results) do + local p = results[i] + if p.name and p.name ~= me and p.class and p.class ~= "" then + table.insert(names, p.name) + end + end + DC_Talents.Request(names) + end + DC.store = E.NewStore(results, now) + DC.storeInvalid = nil + results = {} + if type(DC.OnStoreUpdated) == "function" then + DC.OnStoreUpdated() + end + end + + local function tick() + if restartPending then + -- roster changed mid-scan: indices volatile => full restart + restartPending = false + beginQueue() + end + local count = 0 + while table.getn(queue) > 0 and count < E.BATCH do + local entry = table.remove(queue, 1) + scanOne(entry) + count = count + 1 + end + if table.getn(queue) == 0 and not restartPending then + finish() + end + end + + -- Start(force) -> bool (scan started/restarted). + -- Already running: no-op unless force, which restarts with a fresh + -- roster snapshot. Test mode is NOT checked here -- an explicit Start + -- is user intent; only the READY_CHECK auto-trigger honors testMode. + function E.Start(force) + if running then + if not force then + return false + end + restartPending = true + return true + end + running = true + restartPending = false + beginQueue() + scanFrame:Show() -- hidden frames get no OnUpdate on 1.12 + scanFrame:SetScript("OnUpdate", tick) + tick() -- first batch immediately (solo/party scans finish at once) + return true + end + + function E.IsRunning() + return running + end + + -- event wiring + local eventFrame = CreateFrame("Frame", "DopingControlScanEventFrame") + eventFrame:RegisterEvent("RAID_ROSTER_UPDATE") + eventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") + -- READY_CHECK: unverified as a native 1.12 event -- register defensively + pcall(eventFrame.RegisterEvent, eventFrame, "READY_CHECK") + eventFrame:SetScript("OnEvent", function() + if event == "READY_CHECK" then + local db = DopingControlDB + if db and db.readyCheckScan and not db.testMode then + E.Start() + end + return + end + -- RAID_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED + if running then + restartPending = true + elseif DC.store and DC.store.source == "scan" then + DC.storeInvalid = true -- finished store no longer matches roster + end + end) + +end diff --git a/scan/gear.lua b/scan/gear.lua new file mode 100644 index 0000000..f5273be --- /dev/null +++ b/scan/gear.lua @@ -0,0 +1,222 @@ +-- DopingControl scan/gear.lua +-- Equipment + enchant reading for one unit: GetInventoryItemLink for the 16 +-- equipment-tab slots (DC.SLOTS_EQUIPMENT), enchant id parsed from the item +-- link ("item:ID:ENCHANT:..."), item details via GetItemInfo with the +-- MEASURED Turtle 1.18.1 return order +-- : 1 name, 2 link, 3 QUALITY, 4 reqLevel, 5 itemType, +-- 6 ITEMSUBTYPE, 7 maxStack, 8 equipSlot, 9 ICONPATH, 10 vendorPrice. +-- Reading position 4 as itemLevel or 10 as texture breaks SILENTLY with +-- plausible-looking values -- do not "fix" this back to the vanilla order. +-- +-- Readability rule (binding): +-- ANY slot returned a link => gearRead = true; slots with nil => "EMPTY" +-- ALL 13 slots returned nil => gearRead = false (failed read, NOT naked) +-- A link that came back but does not parse leaves its slot nil while +-- gearRead stays true -- core/model.lua shows that cell as UNKNOWN +-- ("no data"), never as MISSING (resolution: a parse anomaly must not be +-- claimed as "no item"). +-- +-- Pure Lua 5.0, dofile-loadable offline: ParseLink / ParseLinkName / +-- ParseWeaponEnchantLine / Assemble are pure (offline-tested); +-- ReadUnit, ItemInfo and ReadSelfWeaponEnchantName +-- touch the WoW API and run in-game only, pcall-wrapped. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Gear = DC_Gear or {} +local G = DC_Gear + +-- Fallback inventory-slot list (= invSlot column of DC.SLOTS_EQUIPMENT, +-- data/enchants.lua) so this file works standalone in tests. +local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 } + +-- InvSlots() -> array of the 16 inventory slot numbers, in column order. +-- Prefers DC.SLOTS_EQUIPMENT (single source of truth once data/ is loaded). +function G.InvSlots() + if DC.SLOTS_EQUIPMENT then + local out = {} + local n = table.getn(DC.SLOTS_EQUIPMENT) + for i = 1, n do + table.insert(out, DC.SLOTS_EQUIPMENT[i].invSlot) + end + return out + end + return FALLBACK_INV_SLOTS +end + +-- ================================================================== +-- Pure part +-- ================================================================== + +-- ParseLink(link) -> itemId, enchantId (numbers) | nil, nil on malformed. +-- Enchant id is the 2nd field of the link payload (standard 1.12 link +-- pattern, widened to also capture the item id). +function G.ParseLink(link) + if type(link) ~= "string" then + return nil, nil + end + local _, _, idStr, enchStr = string.find(link, "item:(%d+):(%d+)") + if not idStr then + return nil, nil + end + return tonumber(idStr), tonumber(enchStr) +end + +-- ParseLinkName(link) -> "[bracket] name" | nil (fallback when GetItemInfo +-- has no cached data yet; standard 1.12 pattern). +function G.ParseLinkName(link) + if type(link) ~= "string" then + return nil + end + local _, _, name = string.find(link, "%[(.+)%]") + return name +end + +-- ParseWeaponEnchantLine(text) -> name|nil (pure). +-- The main-hand tooltip's temporary-enchant line reads " (N min)" on +-- the English client (e.g. "Dense Sharpening Stone (28 min)") -- strip the +-- "(N min)" part, keep the name. Any other line (item name, "Speed 2.70", +-- "+9 Damage", a bare "(28 min)") and any non-string input -> nil. +function G.ParseWeaponEnchantLine(text) + if type(text) ~= "string" then + return nil + end + local _, _, name = string.find(text, "^(.-)%s*%((%d+) min%)%s*$") + if name and name ~= "" then + return name + end + return nil +end + +-- Assemble(rawLinks, slots, itemInfo) -> gear, gearRead +-- rawLinks : { [invSlot] = link string | nil } +-- slots : array of invSlot numbers (default G.InvSlots()) +-- itemInfo : nil, or function(itemId) -> name, quality, subType, texture +-- (already de-shifted; in-game wrapper is G.ItemInfo, tests +-- pass a stub) +-- Returns the store shape: +-- gearRead = false => gear = nil +-- gearRead = true => gear[invSlot] = { id, enchant, quality, subType, +-- texture, name } | "EMPTY" (no link) | nil +-- (link present but unparseable -> UNKNOWN cell) +-- The subType field feeds DC.ENCH_EXEMPT in core/model.lua (wand/holdable +-- exemptions); the model explicitly expects it. +function G.Assemble(rawLinks, slots, itemInfo) + rawLinks = rawLinks or {} + slots = slots or G.InvSlots() + local n = table.getn(slots) + + local gear = {} + local any = false + for i = 1, n do + local inv = slots[i] + local link = rawLinks[inv] + if link ~= nil then + any = true + local id, ench = G.ParseLink(link) + if id then + local entry = { id = id, enchant = ench } + if itemInfo then + local name, quality, subType, texture = itemInfo(id) + entry.name = name + entry.quality = quality + entry.subType = subType + entry.texture = texture + end + if not entry.name then + entry.name = G.ParseLinkName(link) + end + gear[inv] = entry + end + -- unparseable link: slot stays nil (UNKNOWN), see header + end + end + + if not any then + return nil, false + end + for i = 1, n do + local inv = slots[i] + if rawLinks[inv] == nil then + gear[inv] = "EMPTY" + end + end + return gear, true +end + +-- ================================================================== +-- WoW part (runtime only; every foreign-unit call pcall-wrapped) +-- ================================================================== + +-- ItemInfo(itemId) -> name, quality, subType, texture | nil (uncached/err). +-- De-shifts the Turtle 1.18.1 GetItemInfo order (see header). +function G.ItemInfo(itemId) + if not GetItemInfo then + return nil + end + local ok, name, link, quality, reqLevel, itemType, subType, + maxStack, equipSlot, iconPath = pcall(GetItemInfo, itemId) + if not ok or not name then + return nil + end + return name, quality, subType, iconPath +end + +-- ReadSelfWeaponEnchantName() -> name|nil (WoW runtime only). +-- Self path for store.weaponMainName: GetWeaponEnchantInfo() carries +-- no NAME for the own unit, but the equipped main hand's tooltip lists the +-- temp enchant as its own " (N min)" line (English client). Scans +-- the shared hidden tooltip (scan/aura.lua accessor -- never a second +-- frame under the same global name) over SetInventoryItem("player", 16) +-- and returns the first line that parses; nil-safe when no such line. +function G.ReadSelfWeaponEnchantName() + local tip = DC_Aura and DC_Aura.GetScanTip and DC_Aura.GetScanTip() + if not tip then + return nil + end + local ok = pcall(function() + tip:ClearLines() + tip:SetInventoryItem("player", 16) + end) + if not ok then + return nil + end + local n = 30 + if tip.NumLines then + local ok2, v = pcall(tip.NumLines, tip) + if ok2 and type(v) == "number" then + n = v + end + end + for i = 1, n do + local obj = getglobal("DopingControlScanTipTextLeft" .. i) + if obj then + local name = G.ParseWeaponEnchantLine(obj:GetText()) + if name then + return name + end + end + end + return nil +end + +-- ReadUnit(unit) -> rawLinks table for G.Assemble. +-- Vanilla serves GetInventoryItemLink only for the own/inspected unit; +-- SuperWoW serves it for all friendly units (unverified beyond sight, +-- probe questions 4/5) -- either way nil slots are handled by the +-- EMPTY-vs-gearRead rule above, so no capability branching here. +function G.ReadUnit(unit) + local raw = {} + if GetInventoryItemLink then + local slots = G.InvSlots() + local n = table.getn(slots) + for i = 1, n do + local ok, link = pcall(GetInventoryItemLink, unit, slots[i]) + if ok and link then + raw[slots[i]] = link + end + end + end + return raw +end diff --git a/scan/hit.lua b/scan/hit.lua new file mode 100644 index 0000000..3b1af34 --- /dev/null +++ b/scan/hit.lua @@ -0,0 +1,1052 @@ +-- DopingControl scan/hit.lua +-- Hit reading for one unit: the +hit percentages an equipped set of items +-- grants, plus the weapon information the hit CAPS depend on. +-- +-- There is no hit API on this client family (see data/hit.lua) -- every +-- number here is summed from item TOOLTIP text. The read path is +-- GameTooltip:SetInventoryItem(unit, slot) on the SHARED hidden tooltip +-- (scan/aura.lua's DC_Aura.GetScanTip -- never a second frame under the +-- same global name), NOT SetHyperlink: SetInventoryItem shows the item as +-- it is actually worn (temporary weapon enchants included) and sidesteps +-- item-link edge cases entirely. +-- +-- Performance (this is the expensive part of a scan -- one tooltip build +-- per equipped item per player): +-- * slots without an item link are skipped without touching the tooltip; +-- * nothing beyond the equipment slots is ever scanned; +-- * the parsed result of an item is CACHED by item id + enchant id, so +-- the second player wearing the same epic costs one table lookup. The +-- cache lives in memory only (never saved to disk), and a cap-safety +-- wipe keeps it bounded -- it is a pure accelerator, so a wipe only +-- costs one re-scan; +-- * the caller can hand in the item links scan/gear.lua already fetched +-- for this unit, which removes a second GetInventoryItemLink pass. +-- +-- WHAT IS COUNTED, and how: +-- * PER ITEM: lines that start with the equip trigger ("Equip: Improves +-- your chance to hit by 1%"). +-- * PER SET: set bonuses, counted ONCE for the whole set and only when +-- the piece threshold is met (H.CollectSet / H.SumSets). The set block +-- is printed on the tooltip of EVERY piece, which is why a per-item +-- sum would multiply it -- but the block also names the set, the worn +-- piece count and each bonus's threshold, so grouping by set is all +-- that is needed. The worn count comes from OUR OWN tally of the +-- player's pieces, never from a cached tooltip's "(x/y)": that number +-- belongs to whichever unit the tooltip was drawn for. +-- +-- Store shape produced BY THIS MODULE'S ReadUnit (players[i].hit, before +-- scan/engine.lua's BuildPlayer augments it with hit-granting auras -- +-- see AuraHit below and BuildPlayer's own header comment): +-- { melee = , -- generic physical hit % from GEAR (melee AND +-- -- ranged); BuildPlayer folds DC.HIT_AURAS on top +-- -- and turns this into the melee-column TOTAL +-- spell = , -- general spell hit % from GEAR; BuildPlayer +-- -- folds auras on top the same way +-- schools = { [school] = }, -- school-only spell hit extras +-- sources = { { item = , kind = , value = }, ... }, +-- mainSub = , -- main hand +-- offSub = , -- off hand +-- rangedSub = , -- ranged slot +-- skillName = , +-- skill = , -- main-hand weapon skill: MEASURED for +-- -- the own character, otherwise the +-- -- estimate H.ApplyEstimate filled in +-- skillExact = , -- true only when `skill` was MEASURED +-- book = , -- a skill book was ASSUMED (never on a +-- -- measured skill) +-- rangedSkill = , -- same for the ranged weapon +-- rangedSkillExact = , +-- hitRead = } +-- Readability rule (mirrors scan/gear.lua): ANY slot returned a link => +-- hitRead = true; ALL slots nil => hitRead = false (a failed read, not "a +-- naked player"). Unlike the aura/gear channels the table rides into the +-- store EVEN WHEN hitRead is false, because the flag lives inside it -- +-- every consumer must test hit.hitRead before believing the zeros. +-- +-- Weapon skill: MEASURED for the own character (H.ReadSelfSkill -> +-- hit.skill + hit.skillExact), ESTIMATED for everyone else -- foreign +-- weapon skill is unreadable in principle (no inspect-skill channel +-- exists). The estimate is deliberately NOT baked into the store: it +-- depends on the user's per-player weapon-skill-book flags, and baking it +-- in would freeze those flags until the next scan. Consumers resolve it at +-- display time: +-- local skill = hit.skillExact and hit.skill +-- or DC_Hit.SkillFor(hit.mainSub, player.race, +-- db.skillBooks and db.skillBooks[player.name]) +-- (player.race is the race token the scan stored alongside the hit table.) +-- +-- TALENTS are the third source (gear, auras, talents). GetTalentInfo has +-- no unit parameter, so ranks come from two places that must produce the +-- SAME shape: +-- * H.TalentHit is PURE (class token + learned ranks -> the four +-- buckets and the school extras, see data/talenthit.lua) and is +-- offline-tested like the rest of the pure half; +-- * H.ReadSelfTalents (WoW glue) reads the ranks for the own character; +-- * for every other player scan/talents.lua asks over the server's own +-- inspect protocol and hands the same name->rank table to H.TalentHit. +-- Until a reply arrives that player keeps hit.talentsRead = false and +-- zeroed talent fields, and every consumer must say so -- their number +-- EXCLUDES talents, which for a hybrid or a mage is easily 3-10%. +-- The talent fields the engine adds on top of this module's store shape: +-- talentMelee / talentRanged / talentSpell / talentOffhand (numbers), +-- talentSchools ({ [lowercase school] = number }), talentsRead (bool). +-- +-- Pure Lua 5.0, dofile-loadable offline: ParseHitLine / SumGear / AddSum / +-- IsHitSourceLine / SkillFor / MeleeCap / RangedCap / DualWield / +-- TalentHit / SchoolHit are pure (offline tested); ReadUnit, +-- ReadSelfSkill and ReadSelfTalents touch the WoW API and run in-game +-- only, pcall-wrapped. +-- +-- RESIST-TAB REUSE (added 2026-08-02, see scan/resist.lua's header for the +-- full rationale): the server never transmits UNIT_FIELD_RESISTANCES for a +-- foreign player at all, so scan/engine.lua's three direct read paths can +-- NEVER answer for anyone but the own character -- reconstruction from +-- equipped-item tooltips is the only path that can. Rather than run a +-- second per-item tooltip scan for that, itemHit() below hands the SAME +-- tooltip lines it already fetched for +hit to DC_Resist.SumLines (pure, +-- scan/resist.lua) and caches the result alongside the hit sum; H.ReadUnit +-- accumulates it into hit.resistGear/hit.resistGearRead the same way it +-- accumulates hit.melee/hit.spell. Zero extra SetInventoryItem calls. +-- hit.resistGearRead is the STRICT honesty flag scan/engine.lua's +-- gear-reconstruction fallback requires: true only when at least one +-- equipped item's tooltip was ACTUALLY read (H.SumItemResists), never +-- merely "a link existed" (hit.hitRead's looser bar) -- a player whose +-- tooltips are wholly unreadable must keep showing "?" on RESIST, not a +-- reconstructed 0. DC_Resist is existence-guarded throughout, so this +-- module works unchanged without it (resistGearRead simply stays false). + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Hit = DC_Hit or {} +local H = DC_Hit + +-- Equipment slots that can carry +hit, in inventory-slot order. This is +-- deliberately its OWN list and not DC_Gear.InvSlots(): the enchant tab +-- has no waist column, but a belt most certainly can carry hit. Shirt (4) +-- and tabard (19) never carry stats and are left out. +H.SLOTS = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 } + +H.SLOT_MAIN = 16 +H.SLOT_OFF = 17 +H.SLOT_RANGED = 18 + +-- Hard cap for the per-item parse cache (in memory only, see header). +H.CACHE_CAP = 300 + +-- Hard cap for the per-player breakdown list (tooltip detail only -- no +-- realistic set of gear reaches it; the cap exists so a broken tooltip +-- cannot grow the store without bound). +H.SOURCE_CAP = 16 + +-- ================================================================== +-- Pure part +-- ================================================================== + +-- round to one decimal (0.2 is not representable in binary floating point, +-- so 8 - 3*0.2 lands at 7.399999...; every cap leaves this function) +local function round1(v) + return math.floor(v * 10 + 0.5) / 10 +end + +-- ParseHitLine(text) -> kind, value | nil +-- kind : "melee" | "spell" | "both" | (see DC.HIT_PATTERNS) +-- value : the percentage as a number +-- Returns nil (a single value) when no pattern matches -- an unknown +-- phrasing is ignored, never guessed at. +function H.ParseHitLine(text) + if type(text) ~= "string" then + return nil + end + local pats = DC.HIT_PATTERNS + if type(pats) ~= "table" then + return nil + end + local n = table.getn(pats) + for i = 1, n do + local e = pats[i] + local _, _, cap = string.find(text, e.pattern) + if cap then + local v = tonumber(cap) + if v then + return e.kind, v + end + end + end + return nil +end + +-- IsHitSourceLine(text) -> bool +-- The gate that separates PER-ITEM hit from SET-BONUS hit: only a line +-- carrying the equip trigger is a per-item hit source. A set-bonus line +-- ("(2) Set: Improves your chance to hit by 2%") is printed on EVERY piece +-- of the set, so adding it per item would multiply it. +-- +-- That is a reason to count set bonuses ONCE PER SET, not a reason to drop +-- them -- see H.CollectSet / H.SumSets below. The Devilsaur two-piece +-- bonus is +2% hit; leaving it out understates hit by more than most +-- enchants add. +function H.IsHitSourceLine(text) + if type(text) ~= "string" then + return false + end + if string.find(text, "^Equip:") then + return true + end + return false +end + +-- AddSum(dst, add) -> dst -- fold one hit sum into another (in place). +-- Only the three hit fields are touched, so it can be applied directly to +-- a store hit table that carries weapon/readability fields as well. +function H.AddSum(dst, add) + if type(dst) ~= "table" or type(add) ~= "table" then + return dst + end + dst.melee = (dst.melee or 0) + (add.melee or 0) + dst.spell = (dst.spell or 0) + (add.spell or 0) + if add.schools then + dst.schools = dst.schools or {} + for school, v in pairs(add.schools) do + dst.schools[school] = (dst.schools[school] or 0) + v + end + end + return dst +end + +-- SumItemResists(items) -> resistGear, resistGearRead +-- items : array of the itemHit() cache entries collected while walking +-- H.SLOTS for ONE unit -- one entry per slot whose tooltip was +-- SUCCESSFULLY read (a slot with no item, or whose tooltip read +-- failed, contributes no entry at all -- see H.ReadUnit). +-- resistGear : { [UnitResistance school index] = total } summed +-- over every entry's .resist table (itself possibly +-- {} for a plain item -- see DC_Resist.SumLines). A +-- school with no contribution is simply absent, not +-- zero -- callers default with `or 0`, same as every +-- other resist table in this addon. +-- resistGearRead : true iff AT LEAST ONE entry carried a .resist table +-- (i.e. its tooltip was actually read, whether or not +-- that item happened to grant any resistance). +-- FALSE means every equipped item's tooltip failed -- +-- the exact scan/hit.lua-documented failure mode +-- (SetOwner missing on the scan tooltip, or the unit +-- simply out of tooltip range) -- and the caller MUST +-- show "?", never a reconstructed 0. This is the +-- honesty gate scan/engine.lua's AssembleResists +-- gear-fallback checks before trusting resistGear at +-- all. +-- `.resist` being nil on every entry (DC_Resist not loaded, see the file +-- header) degrades the same way: nothing is ever marked read, so the +-- fallback simply never activates -- no module, no reconstruction, never a +-- guess. +function H.SumItemResists(items) + local sum = {} + local read = false + items = items or {} + local n = table.getn(items) + for i = 1, n do + local it = items[i] + if it and it.resist then + read = true + for school, v in pairs(it.resist) do + sum[school] = (sum[school] or 0) + v + end + end + end + return sum, read +end + +-- SumGear(lines) -> sum, sources +-- lines : array of raw tooltip line strings (already gated by +-- IsHitSourceLine when they come from an item scan) +-- sum : { melee = , spell = , schools = { ... } } +-- sources : array of { kind = , value = } in line order -- the +-- breakdown a tooltip shows; the caller attaches the item name. +-- Folding rules (see DC.HIT_PATTERNS): "both" counts into melee AND +-- spell; a school-specific line counts ONLY into schools[], never +-- into the general spell number -- it buys hit against one school only. +-- Lines that parse to nothing are ignored. +function H.SumGear(lines) + local sum = { melee = 0, spell = 0, schools = {} } + local sources = {} + lines = lines or {} + local n = table.getn(lines) + for i = 1, n do + local kind, v = H.ParseHitLine(lines[i]) + if kind then + if kind == "melee" then + sum.melee = sum.melee + v + elseif kind == "spell" then + sum.spell = sum.spell + v + elseif kind == "both" then + sum.melee = sum.melee + v + sum.spell = sum.spell + v + else + sum.schools[kind] = (sum.schools[kind] or 0) + v + end + table.insert(sources, { kind = kind, value = v }) + end + end + return sum, sources +end + +-- ------------------------------------------------------------------ +-- SET BONUSES +-- +-- Shape of the set block in an item tooltip: +-- +-- L10 gold "Devilsaur Armor (0/2)" <- ITEM_SET_NAME +-- L11 grey " Devilsaur Leggings" <- piece list +-- L14 grey "(2) Set: Improves your chance to hit by 2%." <- ITEM_SET_BONUS_GRAY +-- +-- So the client states both halves itself: how many pieces are worn, and +-- what each bonus costs. The 1.12 GlobalStrings confirm the two forms -- +-- ITEM_SET_BONUS_GRAY = "(%d) Set: %s" for a bonus that is NOT active and +-- ITEM_SET_BONUS = "Set: %s" for one that IS. +-- +-- Counting rule: group by SET NAME, count each bonus at most ONCE, and +-- take it only when its piece threshold is met. The worn count is taken +-- from OUR OWN tally of the player's equipped pieces, never from the +-- "(x/y)" of a cached tooltip -- that number belongs to whichever unit was +-- scanned when the item entered the cache and would otherwise leak between +-- players. +-- ------------------------------------------------------------------ + +-- ParseSetHeader(text) -> name, worn, total | nil +-- The gold " (x/y)" line that opens the set block. +function H.ParseSetHeader(text) + if type(text) ~= "string" then + return nil + end + local _, _, name, worn, total = + string.find(text, "^(.+) %((%d+)/(%d+)%)%s*$") + if not name then + return nil + end + return name, tonumber(worn), tonumber(total) +end + +-- ParseSetBonus(text) -> threshold|nil, body | nil +-- "(2) Set: " -> 2, (inactive form, ITEM_SET_BONUS_GRAY) +-- "Set: " -> nil, (ACTIVE form, ITEM_SET_BONUS) +-- Anything else -> nil +-- A nil threshold with a body is NOT "no threshold" -- it means the client +-- already decided the bonus is active for the unit it drew the tooltip +-- for, which is why such an entry must not be cached across players. +function H.ParseSetBonus(text) + if type(text) ~= "string" then + return nil + end + local _, _, n, body = string.find(text, "^%((%d+)%) Set: (.+)$") + if n then + return tonumber(n), body + end + local _, _, body2 = string.find(text, "^Set: (.+)$") + if body2 then + return nil, body2 + end + return nil +end + +-- CollectSet(lines) -> { name, worn, total, bonuses = { {n=, text=} }, +-- contextual = } | nil +-- Reads the set block out of ONE item's tooltip lines. `contextual` marks +-- a block that carries at least one active-form line, i.e. one whose +-- meaning depends on the unit the tooltip was drawn for -- the caller must +-- not cache those. +function H.CollectSet(lines) + if type(lines) ~= "table" then + return nil + end + local set = nil + for i = 1, table.getn(lines) do + local text = lines[i] + if not set then + local name, worn, total = H.ParseSetHeader(text) + if name then + set = { name = name, worn = worn, total = total, + bonuses = {}, contextual = false } + end + else + local n, body = H.ParseSetBonus(text) + if body then + if not n then + set.contextual = true + end + table.insert(set.bonuses, { n = n, text = body }) + end + end + end + return set +end + +-- SumSets(sets) -> sum, sources +-- sets : { [setName] = { pieces = , +-- bonuses = { {n=, text=} } } } +-- Each bonus contributes AT MOST ONCE, and only when the player wears +-- enough pieces. A bonus with no threshold (active form) is taken as met: +-- the client only prints it that way once it applies. +function H.SumSets(sets) + local sum = { melee = 0, spell = 0, schools = {} } + local sources = {} + if type(sets) ~= "table" then + return sum, sources + end + -- deterministic order: sets are keyed by name, and two sets granting + -- hit must not swap places in the tooltip between two scans + local names = {} + for name in pairs(sets) do + table.insert(names, name) + end + table.sort(names) + for i = 1, table.getn(names) do + local name = names[i] + local s = sets[name] + local pieces = (s and s.pieces) or 0 + local bonuses = (s and s.bonuses) or {} + local seen = {} + for b = 1, table.getn(bonuses) do + local bonus = bonuses[b] + local met = (bonus.n == nil) or (pieces >= bonus.n) + -- the same bonus text can arrive from several pieces of the + -- set; key on it so it lands in the sum exactly once + if met and not seen[bonus.text] then + seen[bonus.text] = true + local kind, v = H.ParseHitLine(bonus.text) + if kind then + if kind == "melee" then + sum.melee = sum.melee + v + elseif kind == "spell" then + sum.spell = sum.spell + v + elseif kind == "both" then + sum.melee = sum.melee + v + sum.spell = sum.spell + v + else + sum.schools[kind] = (sum.schools[kind] or 0) + v + end + table.insert(sources, { kind = kind, value = v, + set = name, pieces = bonus.n or pieces }) + end + end + end + end + return sum, sources +end + +-- SkillFor(subType, race, books) -> skill, skillName | nil +-- The ESTIMATE for a foreign player's weapon skill: +-- base (level-60 cap) + racial specialization + weapon skill book. +-- `books` is one player's opt-in flag table (db.skillBooks[playerName]), +-- keyed by weapon SKILL name; nil is fine. Returns nil when the subtype is +-- not a weapon at all (shield, totem, nothing equipped) -- the caller then +-- has no skill claim to make. +-- The own character never needs this: its skill is measured +-- (H.ReadSelfSkill) and a measured value already includes any book. +function H.SkillFor(subType, race, books) + local map = DC.WEAPON_SKILL_OF_SUBTYPE + local skillName = map and map[subType] + if not skillName then + return nil + end + local skill = DC.BASE_WEAPON_SKILL or 300 + local byRace = DC.RACE_WEAPON_SKILL + local racial = race and byRace and byRace[race] + if racial and racial[skillName] then + skill = skill + racial[skillName] + end + if books and books[skillName] then + skill = skill + (DC.SKILL_BOOK_BONUS or 0) + end + return skill, skillName +end + +-- ApplyEstimate(hit, race, books) -> hit (in place) +-- Fills the weapon-skill fields a display layer needs for a player whose +-- skill could NOT be measured -- which is everyone except the own +-- character (foreign weapon skill is unreadable in principle): +-- hit.skill main-hand weapon skill estimate (base + racial + book) +-- hit.book true when a weapon skill book was ASSUMED for the +-- main-hand skill (false whenever the skill is measured +-- -- a measured value already contains the book) +-- hit.rangedSkill same estimate for the ranged weapon's own skill line +-- A MEASURED value (skillExact / rangedSkillExact) is never overwritten. +-- `books` is one player's flag table, db.skillBooks[playerName]; nil-safe. +-- +-- Note for consumers: this is a SNAPSHOT taken when the unit was scanned. +-- Toggling a book flag afterwards does not travel back into the store, so +-- a display layer that wants the flag to take effect immediately should +-- recompute with H.SkillFor(hit.mainSub, player.race, books) whenever +-- hit.skillExact is not set. +function H.ApplyEstimate(hit, race, books) + if type(hit) ~= "table" then + return hit + end + if not hit.skillExact then + local skill = H.SkillFor(hit.mainSub, race, books) + if skill then + hit.skill = skill + end + hit.book = (hit.skillName and books and books[hit.skillName] and true) + or false + else + hit.book = false + end + if not hit.rangedSkillExact then + local rskill = H.SkillFor(hit.rangedSub, race, books) + if rskill then + hit.rangedSkill = rskill + end + end + return hit +end + +-- AuraHit(auraNames) -> { melee = , ranged = , spell = } +-- The hit granted by AURAS (DC.HIT_AURAS, data/hit.lua) rather than gear. +-- auraNames : player.auras.names ({[name]=true,...}, nil-safe) -- the SAME +-- aura scan the class-buff tab already reads; no second scan needed. +-- Sums every DC.HIT_AURAS entry whose name is present in auraNames. A +-- hit-granting aura may bump any subset of the three columns (e.g. a +-- spell-only buff) -- absent fields on an entry default to 0, never nil, +-- so the caller can always add the result straight onto a gear sum. +function H.AuraHit(auraNames) + local sum = { melee = 0, ranged = 0, spell = 0 } + local auras = DC.HIT_AURAS + if type(auraNames) ~= "table" or type(auras) ~= "table" then + return sum + end + for name, entry in pairs(auras) do + if auraNames[name] then + sum.melee = sum.melee + (entry.melee or 0) + sum.ranged = sum.ranged + (entry.ranged or 0) + sum.spell = sum.spell + (entry.spell or 0) + end + end + return sum +end + +-- rankValue(arr, rank) -- the per-rank value of one talent bucket. +-- A rank ABOVE the listed count means the table is stale (a tree was +-- rebalanced and the talent gained a rank): it clamps to the highest +-- KNOWN value, which under-reports rather than extrapolating a number +-- nobody verified. Rank 0 / no such bucket contribute nothing. +local function rankValue(arr, rank) + if type(arr) ~= "table" or not rank or rank < 1 then + return 0 + end + local n = table.getn(arr) + if n < 1 then + return 0 + end + if rank > n then + rank = n + end + return arr[rank] or 0 +end + +-- DualWield(mainSub, offSub) -> bool +-- Both hands hold something that maps to a WEAPON skill. A shield, totem, +-- idol, libram or held-in-off-hand item is not dual wielding -- that is +-- exactly what the gaps in DC.WEAPON_SKILL_OF_SUBTYPE encode. +function H.DualWield(mainSub, offSub) + local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} + return (map[mainSub] ~= nil) and (map[offSub] ~= nil) +end + +-- TalentHit(classToken, learnedRanks, dualWield) +-- -> { melee = , ranged = , spell = , offhand = , schools = { ... } } +-- classToken : UnitClass file token ("MAGE", "WARRIOR", ...) +-- learnedRanks : { [talent name] = rank } -- what H.ReadSelfTalents +-- returns; matching is BY NAME (see data/talenthit.lua) +-- dualWield : true while two WEAPONS are equipped (H.DualWield) -- +-- gates the hunter's dual-wield-only extra +-- Every field is a number and `schools` is always a table, so the result +-- can be added onto a hit table without nil checks. An unknown class, a +-- nil rank table or a class with no hit talents all yield clean zeros -- +-- which is also what a player whose talents have not arrived yet gets. +-- `offhand` is returned SEPARATELY and never folded into `melee`: it buys +-- hit for off-hand swings only. +function H.TalentHit(classToken, learnedRanks, dualWield) + local sum = { melee = 0, ranged = 0, spell = 0, offhand = 0, schools = {} } + local byClass = DC.TALENT_HIT + if type(byClass) ~= "table" or type(learnedRanks) ~= "table" then + return sum + end + local list = classToken and byClass[classToken] + if type(list) ~= "table" then + return sum + end + local n = table.getn(list) + for i = 1, n do + local t = list[i] + local rank = nil + if type(t) == "table" and t.name then + rank = tonumber(learnedRanks[t.name]) + end + if rank and rank > 0 then + sum.melee = sum.melee + rankValue(t.melee, rank) + sum.ranged = sum.ranged + rankValue(t.ranged, rank) + sum.spell = sum.spell + rankValue(t.spell, rank) + sum.offhand = sum.offhand + rankValue(t.offhand, rank) + if dualWield then + sum.melee = sum.melee + rankValue(t.dwExtra, rank) + end + if type(t.schools) == "table" then + for school, arr in pairs(t.schools) do + sum.schools[school] = (sum.schools[school] or 0) + + rankValue(arr, rank) + end + end + end + end + return sum +end + +-- SchoolHit(hit, school) -> total, generic, specific +-- What ONE school column of the HIT tab is worth for this player: +-- generic : spell hit that helps EVERY school (hit.spell -- gear, auras +-- and generic talents, already summed by the engine) +-- specific : what only THIS school gets -- the gear affix parsed from +-- tooltips plus the school-specific talents +-- total : the number the column shows +-- `school` may be given in either spelling: the lowercase column id +-- ("fire", DC.SLOTS_HIT) or the capitalized tooltip word ("Fire", +-- DC.HIT_SCHOOLS). The two live side by side because they come from two +-- different sources; DC.HIT_SCHOOL_KEY bridges them (data/hit.lua). +-- Note the input: `hit` is a STORE hit table (after scan/engine.lua's +-- BuildPlayer folded auras and talents in). Handing in the raw table +-- scan/hit.lua's ReadUnit returns yields the gear-only picture, which is +-- correct but is not what a cell should display. +function H.SchoolHit(hit, school) + if type(hit) ~= "table" then + return 0, 0, 0 + end + local generic = hit.spell or 0 + if type(school) ~= "string" then + return generic, generic, 0 + end + local lower = string.lower(school) + local gearKey = (DC.HIT_SCHOOL_KEY and DC.HIT_SCHOOL_KEY[lower]) or school + local specific = 0 + if type(hit.schools) == "table" then + specific = specific + (hit.schools[gearKey] or 0) + end + if type(hit.talentSchools) == "table" then + specific = specific + (hit.talentSchools[lower] or 0) + end + return generic + specific, generic, specific +end + +-- capFor(base, skill) -- linear weapon-skill scaling: every point above +-- the base skill lowers the cap by DC.CAP_PER_SKILL, never below zero. +local function capFor(base, skill) + local baseSkill = DC.BASE_WEAPON_SKILL or 300 + local extra = (skill or baseSkill) - baseSkill + if extra < 0 then + extra = 0 + end + local v = (base or 0) - extra * (DC.CAP_PER_SKILL or 0) + if v < 0 then + v = 0 + end + return round1(v) +end + +-- MeleeCap(mainSub, offSub, skill, capTable) +-- -> { yellow = , white = , dualWield = , skillName = } +-- mainSub/offSub : GetItemInfo itemSubType of the two weapon slots +-- skill : effective weapon skill (measured or estimated); +-- nil falls back to the base skill +-- capTable : DC.HIT_CAPS by default +-- yellow = cap for special attacks. +-- white = cap for auto attacks: the dual-wield cap while two WEAPONS are +-- held, otherwise identical to yellow (a single weapon's white +-- swings carry no dual-wield penalty). +-- dualWield = both hands hold something that maps to a weapon skill -- a +-- shield/totem/held-in-off-hand off hand is not dual wielding. +-- skillName = weapon skill of the MAIN hand, nil when it is unknown or +-- empty (bare fists are the caller's business, see ReadUnit). +function H.MeleeCap(mainSub, offSub, skill, capTable) + capTable = capTable or DC.HIT_CAPS or {} + local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} + local skillName = map[mainSub] + local dualWield = H.DualWield(mainSub, offSub) + local yellow = capFor(capTable.meleeYellow, skill) + local white = yellow + if dualWield then + white = capFor(capTable.meleeWhiteDW, skill) + end + return { yellow = yellow, white = white, dualWield = dualWield, + skillName = skillName } +end + +-- RangedCap(rangedSub, skill, capTable) -> { cap = , skillName = } | nil +-- nil means "this player has no ranged hit question": nothing in the +-- ranged slot, or a relic/wand rather than a ranged ATTACK weapon (see +-- DC.RANGED_ATTACK_SUBTYPE -- wand mechanics are disputed, so wands never +-- drive this column). +-- `skill` is the skill of the RANGED weapon (bows/guns/crossbows/thrown +-- each have their own skill line), not the main hand's. +function H.RangedCap(rangedSub, skill, capTable) + capTable = capTable or DC.HIT_CAPS or {} + local ranged = DC.RANGED_ATTACK_SUBTYPE or {} + if not rangedSub or not ranged[rangedSub] then + return nil + end + local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} + return { cap = capFor(capTable.ranged, skill), skillName = map[rangedSub] } +end + +-- ================================================================== +-- WoW part (runtime only; every call pcall-wrapped) +-- ================================================================== + +-- The shared hidden tooltip's global name -- the line FontStrings are read +-- back as getglobal("TextLeft"), so the name must match the frame +-- created in scan/aura.lua. +local TIP_NAME = "DopingControlScanTip" + +-- per-item parse cache, [itemId:enchantId] = { sum, sources, name } +local itemCache = {} +local itemCacheSize = 0 + +-- ClearCache() -- in-memory cache reset (cap safety, and a handy manual +-- reset when item tooltips changed under us). +function H.ClearCache() + itemCache = {} + itemCacheSize = 0 +end + +function H.CacheSize() + return itemCacheSize +end + +-- read all left-hand tooltip lines of one equipped item; nil on failure +local function scanItemLines(unit, slot) + local tip = DC_Aura and DC_Aura.GetScanTip and DC_Aura.GetScanTip() + if not tip then + return nil + end + local ok = pcall(function() + tip:ClearLines() + tip:SetInventoryItem(unit, slot) + end) + if not ok then + return nil + end + local n = 30 + if tip.NumLines then + local ok2, v = pcall(tip.NumLines, tip) + if ok2 and type(v) == "number" then + n = v + end + end + local lines = {} + for i = 1, n do + local obj = getglobal(TIP_NAME .. "TextLeft" .. i) + if obj then + local text = obj:GetText() + if text then + table.insert(lines, text) + end + end + end + return lines +end + +-- parse ONE equipped item -> { sum, sources, name, set } (cached by item +-- id + enchant id). Random suffixes are not part of the key on purpose: no +-- 1.12 suffix grants hit, and a temporary weapon enchant cannot either -- +-- the key stays cheap. +-- +-- `set` is the item's set block (H.CollectSet). It is item data and caches +-- fine -- EXCEPT when it contains an active-form bonus line, which the +-- client only prints for a unit that already wears enough pieces. Such an +-- entry describes the unit, not the item, so it is returned but never +-- stored: the next player re-reads the tooltip and gets their own truth. +local function itemHit(unit, slot, itemId, enchantId) + local key = tostring(itemId) .. ":" .. tostring(enchantId) + local hitEntry = itemCache[key] + if hitEntry then + return hitEntry + end + local lines = scanItemLines(unit, slot) + if not lines then + return nil -- unreadable tooltip: do NOT cache a zero + end + local keep = {} + -- line 1 is the item name, never a stat line + for i = 2, table.getn(lines) do + if H.IsHitSourceLine(lines[i]) then + table.insert(keep, lines[i]) + end + end + local sum, sources = H.SumGear(keep) + local set = H.CollectSet(lines) + -- RESIST tab reuse (see file header addendum): the FULL lines array + -- this pass already fetched for +hit is handed to DC_Resist's pure + -- parser for resistance stats -- one more in-memory table walk, no + -- extra SetInventoryItem call. Resistance lines are plain stat text + -- (no "Equip:" trigger, unlike +hit -- see data/resist.lua), so `lines` + -- is passed unfiltered rather than `keep`. Existence-guarded: without + -- scan/resist.lua loaded, `resist` stays nil and H.SumItemResists + -- treats this item as "not scanned for resist" (never a guessed zero). + local resist = nil + if DC_Resist and DC_Resist.SumLines then + resist = DC_Resist.SumLines(lines) + end + hitEntry = { sum = sum, sources = sources, name = lines[1], set = set, + resist = resist } + if set and set.contextual then + return hitEntry -- unit-specific: use it, never cache it + end + if itemCacheSize >= H.CACHE_CAP then + -- cap-safety wipe: the cache is a pure accelerator, a reset only + -- costs one re-scan (no correctness meaning, no eviction order) + H.ClearCache() + end + itemCache[key] = hitEntry + itemCacheSize = itemCacheSize + 1 + return hitEntry +end + +-- ReadSelfSkill(skillName) -> effective, rank, modifier | nil +-- The own character's MEASURED weapon skill: rank (4th return of +-- GetSkillLineInfo) plus its modifier (6th return), which is how a +-- temporary or permanent skill bonus shows up. +-- Gotcha: a COLLAPSED skill category is not enumerated by +-- GetNumSkillLines/GetSkillLineInfo. We deliberately do NOT expand +-- categories (that would change the player's skill window); an +-- unenumerable skill simply comes back nil and the caller falls back to +-- the estimate. +function H.ReadSelfSkill(skillName) + if not skillName or not GetNumSkillLines or not GetSkillLineInfo then + return nil + end + local okN, n = pcall(GetNumSkillLines) + if not okN or type(n) ~= "number" then + return nil + end + for i = 1, n do + local ok, name, isHeader, isExpanded, rank, numTemp, modifier = + pcall(GetSkillLineInfo, i) + if ok and not isHeader and name == skillName then + local base = tonumber(rank) or 0 + local mod = tonumber(modifier) or 0 + return base + mod, base, mod + end + end + return nil +end + +-- ReadSelfTalents() -> { [talent name] = rank } | nil +-- The OWN character's learned talent ranks, walked as +-- GetNumTalentTabs -> GetNumTalents(tab) -> GetTalentInfo(tab, i). +-- GetTalentInfo takes no unit, so this function is only ever called for +-- the own character; other players' ranks arrive over the protocol in +-- scan/talents.lua and are handed to the same DC_Hit.TalentHit. +-- +-- nil vs empty table is the load-bearing distinction: +-- nil the API is absent or enumerated nothing at all (talent data not +-- available yet) -> the caller must report talents as UNREAD +-- {} the enumeration worked and the character has spent no points -> +-- a MEASURED zero +-- Only ranks above 0 are stored, so the table stays small and a lookup +-- miss is indistinguishable from "not learned", which is what it means. +function H.ReadSelfTalents() + if not GetNumTalentTabs or not GetNumTalents or not GetTalentInfo then + return nil + end + local okT, tabs = pcall(GetNumTalentTabs) + if not okT or type(tabs) ~= "number" then + return nil + end + local ranks = {} + local enumerated = false + for tab = 1, tabs do + local okN, n = pcall(GetNumTalents, tab) + if okN and type(n) == "number" then + for i = 1, n do + -- 1.12: name, iconTexture, tier, column, rank, maxRank, ... + local ok, name, _, _, _, rank = pcall(GetTalentInfo, tab, i) + if ok and type(name) == "string" and name ~= "" then + enumerated = true + local r = tonumber(rank) or 0 + if r > 0 then + ranks[name] = r + end + end + end + end + end + if not enumerated then + return nil + end + return ranks +end + +-- ReadUnit(unit, rawLinks) -> hit, race +-- rawLinks : OPTIONAL { [invSlot] = link } that scan/gear.lua already +-- fetched for this unit. Slots it covers are taken from it +-- (no second GetInventoryItemLink pass); slots outside its +-- coverage -- the waist, which the enchant tab has no column +-- for -- are fetched here. +-- hit : the store shape documented in the file header +-- race : race token from UnitRace (2nd return, e.g. "NightElf"); +-- needed for the racial weapon-skill estimate of foreign +-- players and stored next to the hit table by the engine. +function H.ReadUnit(unit, rawLinks) + local hit = { + melee = 0, spell = 0, schools = {}, sources = {}, + mainSub = nil, offSub = nil, rangedSub = nil, + skillName = nil, hitRead = false, + -- RESIST tab reuse (see file header addendum): resistGear starts + -- empty and resistGearRead false so a caller that never reaches + -- the accumulation below (e.g. an early return elsewhere) still + -- sees a well-formed, honestly-unread pair rather than nil fields. + resistGear = {}, resistGearRead = false, + } + + local race = nil + if UnitRace then + -- 1.12 returns the display name first, the file token second + local ok, display, token = pcall(UnitRace, unit) + if ok then + race = token or display + end + end + + -- which slots the caller's link table already covers + local covered = {} + if rawLinks and DC_Gear and DC_Gear.InvSlots then + local gs = DC_Gear.InvSlots() + for i = 1, table.getn(gs) do + covered[gs[i]] = true + end + end + + local mainHasItem = false + -- set pieces this unit wears, keyed by set name -- filled in the slot + -- loop, folded into the sum ONCE after it (H.SumSets) + local setsWorn = {} + -- RESIST tab reuse: every itemHit() entry that came back non-nil this + -- pass (tooltip actually read, whether cached or freshly scanned) -- + -- handed to H.SumItemResists after the loop, see that function's + -- header for why "entry existed" is the correct bar here. + local resistItems = {} + local n = table.getn(H.SLOTS) + for i = 1, n do + local slot = H.SLOTS[i] + local link = nil + if rawLinks and covered[slot] then + link = rawLinks[slot] + elseif GetInventoryItemLink then + local ok, l = pcall(GetInventoryItemLink, unit, slot) + if ok then + link = l + end + end + if link then + hit.hitRead = true -- any link at all = the read worked + local itemId, enchantId = nil, nil + if DC_Gear and DC_Gear.ParseLink then + itemId, enchantId = DC_Gear.ParseLink(link) + end + if slot == H.SLOT_MAIN then + mainHasItem = true + end + if itemId then + local entry = itemHit(unit, slot, itemId, enchantId or 0) + if entry then + H.AddSum(hit, entry.sum) + table.insert(resistItems, entry) + local ns = table.getn(entry.sources) + for s = 1, ns do + if table.getn(hit.sources) < H.SOURCE_CAP then + table.insert(hit.sources, { + item = entry.name, + kind = entry.sources[s].kind, + value = entry.sources[s].value, + }) + end + end + -- set membership: tally the pieces THIS unit wears per + -- set name. The bonus list comes from whichever piece + -- is seen first -- every piece of a set prints the same + -- list, which is exactly why it must be counted once. + if entry.set and entry.set.name then + local sname = entry.set.name + if not setsWorn[sname] then + setsWorn[sname] = { pieces = 0, + bonuses = entry.set.bonuses } + end + setsWorn[sname].pieces = setsWorn[sname].pieces + 1 + end + end + -- weapon subtypes drive the caps; only the three weapon + -- slots need the extra item lookup + if slot == H.SLOT_MAIN or slot == H.SLOT_OFF + or slot == H.SLOT_RANGED then + local sub = nil + if DC_Gear and DC_Gear.ItemInfo then + local _, _, s = DC_Gear.ItemInfo(itemId) + sub = s + end + if slot == H.SLOT_MAIN then + hit.mainSub = sub + elseif slot == H.SLOT_OFF then + hit.offSub = sub + else + hit.rangedSub = sub + end + end + end + end + end + + -- SET BONUSES, folded in ONCE per set (H.SumSets) now that the whole + -- inventory has been walked and the piece count per set is known. + -- Doing it here rather than per item is the entire fix: the bonus line + -- is printed on every piece, so a per-item sum would multiply it by + -- the number of pieces worn. + local setSum, setSources = H.SumSets(setsWorn) + H.AddSum(hit, setSum) + -- kept SEPARATELY as well, so the tooltip can show the hit number as a + -- plain addition (items + set bonuses + auras + talents) instead of one + -- merged "from gear" figure. The totals above already contain it; these + -- three fields only say how much of it came from sets. + hit.setMelee = setSum.melee + hit.setSpell = setSum.spell + hit.setSchools = setSum.schools + + -- RESIST tab reuse (see file header addendum): fold every scanned + -- item's resistance contribution into ONE sum, and record whether ANY + -- item's tooltip was actually read (the honesty gate scan/engine.lua's + -- gear-reconstruction fallback checks -- see H.SumItemResists). + hit.resistGear, hit.resistGearRead = H.SumItemResists(resistItems) + for s = 1, table.getn(setSources) do + if table.getn(hit.sources) < H.SOURCE_CAP then + local src = setSources[s] + table.insert(hit.sources, { + item = src.set .. " (" .. src.pieces .. " pieces)", + kind = src.kind, + value = src.value, + set = src.set, + }) + end + end + + -- main-hand weapon skill name: bare fists run on Unarmed, but an item + -- whose subtype could not be resolved stays UNKNOWN (nil) -- claiming + -- "Unarmed" for a weapon we simply failed to identify would be a + -- wrong assumption, not a missing one. A FAILED read claims nothing + -- at all: "no main hand" is only meaningful once something was read. + local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} + if hit.mainSub then + hit.skillName = map[hit.mainSub] + elseif hit.hitRead and not mainHasItem then + hit.skillName = "Unarmed" + end + + return hit, race +end diff --git a/scan/reach.lua b/scan/reach.lua new file mode 100644 index 0000000..1d1a691 --- /dev/null +++ b/scan/reach.lua @@ -0,0 +1,79 @@ +-- DopingControl scan/reach.lua +-- Unit readability probe: online FALLBACK via UnitIsConnected, distance via +-- UnitXP_SP3 "distanceBetween". Distance is DIAGNOSTIC ONLY -- it is written +-- into the store but NEVER used as a filter (readability is +-- decided from the read RESULT, not from a range assumption). +-- +-- ONLINE IS A FALLBACK ONLY (measured +-- in-game): the raid roster online flag (GetRaidRosterInfo return 8) is +-- SERVER TRUTH and wins in scan/engine.lua scanOne -- UnitIsConnected can +-- read nil mid-zoning while the player is actually online (false-offline +-- bug). The reach online result is only consulted for roster entries +-- WITHOUT a roster flag (the party path). +-- +-- Pure Lua 5.0, dofile-loadable offline: the WoW calls live in ReadUnit, +-- which is only invoked in-game; Normalize is pure and offline-tested. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Reach = DC_Reach or {} +local R = DC_Reach + +-- ------------------------------------------------------------------ +-- Normalize(haveOnline, onlineRaw, distOk, distVal) -> online, distance +-- haveOnline : bool -- UnitIsConnected existed AND its pcall succeeded. +-- UnitIsConnected returns 1/nil, where nil is read +-- as offline -- BUT it can also read nil mid-zoning +-- while the player is online (measured in-game), +-- which is why the engine only uses this value as a +-- fallback when there is no roster online flag. +-- onlineRaw : raw UnitIsConnected return (1/nil/false) +-- distOk : pcall success of UnitXP("distanceBetween", ...) +-- distVal : its first return +-- Returns: +-- online : true|false|nil -- nil = could not determine; the caller +-- (engine scanOne) consults this at all +-- only for entries without a roster flag +-- distance : number|nil -- nil = not measurable (no UnitXP_SP3, or +-- the call failed / returned a non-number) +-- ------------------------------------------------------------------ +function R.Normalize(haveOnline, onlineRaw, distOk, distVal) + local online = nil + if haveOnline then + if onlineRaw then + online = true + else + online = false + end + end + local distance = nil + if distOk and type(distVal) == "number" then + distance = distVal + end + return online, distance +end + +-- ------------------------------------------------------------------ +-- ReadUnit(unit) -> online, distance (WoW runtime only) +-- Every foreign-unit call is pcall-wrapped (hard rule). +-- Missing UnitXP_SP3 only drops the diagnostic distance +-- -- the online read runs regardless. +-- ------------------------------------------------------------------ +function R.ReadUnit(unit) + local haveOnline = false + local onlineRaw = nil + if UnitIsConnected then + local ok, v = pcall(UnitIsConnected, unit) + if ok then + haveOnline = true + onlineRaw = v + end + end + local distOk = false + local distVal = nil + if UnitXP then + distOk, distVal = pcall(UnitXP, "distanceBetween", "player", unit) + end + return R.Normalize(haveOnline, onlineRaw, distOk, distVal) +end diff --git a/scan/resist.lua b/scan/resist.lua new file mode 100644 index 0000000..4fdd0f0 --- /dev/null +++ b/scan/resist.lua @@ -0,0 +1,114 @@ +-- DopingControl scan/resist.lua +-- Pure resistance-reconstruction math: turn item TOOLTIP lines into a +-- per-school sum, and look up a race's innate bonus. Mirrors scan/hit.lua's +-- pure half (ParseHitLine/SumGear) one level down -- the constants live in +-- data/resist.lua, this file only turns them into behavior. +-- +-- NO WoW-glue function lives here on purpose: unlike scan/hit.lua/scan/ +-- gear.lua/scan/aura.lua, this module never calls SetInventoryItem itself. +-- The RESIST tab's numbers are reconstructed from tooltip lines +-- scan/hit.lua's item scan ALREADY fetched (see that file's itemHit()) -- +-- one more table walk over data already in hand, not a second tooltip +-- operation per item. That is the entire point of hanging resistance +-- reconstruction off the HIT tab's existing per-item cache instead of +-- building an independent RESIST scan pass: 26 players x 19 slots would be +-- ~500 extra SetInventoryItem calls per scan, and scan/hit.lua already pays +-- that cost once per unique item. +-- +-- Pure Lua 5.0, no WoW API, dofile-loadable offline: everything here is +-- offline-tested (test_resist.lua). + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Resist = DC_Resist or {} +local R = DC_Resist + +-- ParseResistLine(text) -> kind, value | nil +-- kind : a DC.RESIST_SCHOOLS name ("Fire", "Nature", ...) or "all" +-- value : the flat resistance amount as a number +-- Returns nil (a single value) when no pattern matches -- an unrecognized +-- phrasing is ignored, never guessed at (same contract as +-- DC_Hit.ParseHitLine). +function R.ParseResistLine(text) + if type(text) ~= "string" then + return nil + end + local pats = DC.RESIST_PATTERNS + if type(pats) ~= "table" then + return nil + end + local n = table.getn(pats) + for i = 1, n do + local e = pats[i] + local _, _, cap = string.find(text, e.pattern) + if cap then + local v = tonumber(cap) + if v then + return e.kind, v + end + end + end + return nil +end + +-- SumLines(lines) -> sum +-- lines : the FULL tooltip line array of ONE item, element 1 = item name +-- (same convention as scan/hit.lua's itemHit -- line 1 is never +-- a stat line, so the scan starts at 2; a caller that already +-- stripped the name may safely pass an array starting at the +-- real first stat line too, since no genuine item name matches +-- "^%+%d+ Resistance" or "^Equip: %+%d+ All +-- Resistances" in the first place). +-- sum : { [UnitResistance school index] = total } -- ONLY schools +-- that actually appeared; a plain item with no resistance stat +-- returns {} (empty, not nil) -- an empty sum is a real "this +-- item carries no resistance", exactly like SumGear's zero. +-- The "All Resistances" trap this table exists to close: a naive parser +-- keyed only on the five single-school patterns would silently skip +-- "Equip: +8 All Resistances." and undercount every one of the five +-- columns by that amount. Handled here by fanning ONE matched line out +-- over every entry of DC.RESIST_SCHOOLS. +function R.SumLines(lines) + local sum = {} + lines = lines or {} + local n = table.getn(lines) + for i = 2, n do + local kind, v = R.ParseResistLine(lines[i]) + if kind then + if kind == "all" then + local schools = DC.RESIST_SCHOOLS or {} + for s = 1, table.getn(schools) do + local school = schools[s].school + sum[school] = (sum[school] or 0) + v + end + else + local num = DC.RESIST_SCHOOL_NUM and DC.RESIST_SCHOOL_NUM[kind] + if num then + sum[num] = (sum[num] or 0) + v + end + end + end + end + return sum +end + +-- RaceBonus(race) -> { [UnitResistance school index] = bonus } +-- ALWAYS returns a table (empty for "no bonus", an unrecognized race +-- token/display name, or nil input) so callers can fold it onto a gear sum +-- without a nil check. The returned table is a FRESH COPY, never the +-- shared DC.RACE_RESIST entry itself -- DC.RACE_RESIST aliases two +-- spellings of the same race to ONE table object, so handing that +-- reference out would let a caller's in-place addition (E.AssembleResists +-- folds this straight onto the gear sum) corrupt the shared constant for +-- every future lookup of that race. +function R.RaceBonus(race) + local out = {} + local tbl = race and DC.RACE_RESIST and DC.RACE_RESIST[race] + if tbl then + for school, v in pairs(tbl) do + out[school] = v + end + end + return out +end diff --git a/scan/talents.lua b/scan/talents.lua new file mode 100644 index 0000000..783f0b8 --- /dev/null +++ b/scan/talents.lua @@ -0,0 +1,375 @@ +-- DopingControl scan/talents.lua +-- Reading OTHER players' talents. +-- +-- Why this file exists: talents raise hit (Precision, Elemental Precision, +-- Shadow Focus, Suppression, ...), so a hit number that omits them is +-- systematically LOW. GetTalentInfo has no unit parameter and the server +-- never transmits foreign talents (no SMSG_INSPECT_TALENT in this fork, +-- no talent field in InitVisibleBits or SMSG_PARTY_MEMBER_STATS). +-- +-- This server nonetheless ships its OWN inspect protocol, and it is a +-- plain addon-message conversation any addon may hold. +-- +-- ask SendAddonMessage("TW_CHAT_MSG_WHISPER", "INSTalentShow", +-- "GUILD") +-- reply the TARGET's client runs GetTalentInfo on ITSELF and sends one +-- message per line: +-- INSTalentTabInfo;;;; +-- INSTalentInfo;;;;;; +-- ;;; +-- ;; +-- INSTalentEND; +-- +-- Cost, as observed: ONE outgoing message per player; the reply is a few +-- dozen messages, ending in INSTalentEND. +-- +-- Three properties of the reply path decide the design here: +-- +-- * The answering client checks NOTHING -- no CanInspect, no group, +-- guild, range or visibility test. Anyone whose NAME we know answers. +-- So the limit has to be ours: we only ask for players the scan +-- actually shows (ASK_GAP apart, each name at most REASK_AFTER), and +-- the whole sender can be switched off. +-- * We cannot ask for single talents. What gets sent is decided by the +-- target's client (Turtle's own code in patch-9.mpq), not by us -- it +-- dumps the whole tree. Our outgoing traffic is one message per +-- player; everything else is inbound and we drop the lines we do not +-- need. +-- * 1.12's wire format is PREFIXMESSAGE, and the server rewrites +-- the "" tag out of the prefix on the way -- which leaves the +-- TAB at the HEAD OF THE PAYLOAD (measured: first bytes 9 73 78 83). +-- An anchored match on "INS..." therefore fails on every single line. +-- T.Normalize is not cosmetic; without it the whole reply is dropped. +-- +-- The pure half (Normalize/Parse/Feed/Plausible/ShouldAsk) is offline +-- tested under real Lua 5.0; only the frame, the event and SendAddonMessage +-- are in-game. + +DopingControl = DopingControl or {} +local DC = DopingControl + +DC_Talents = DC_Talents or {} +local T = DC_Talents + +-- addon-message prefix of the protocol (ours to send, theirs to answer on) +T.PREFIX = "TW_CHAT_MSG_WHISPER" +-- the channel argument is INERT once a "" tag is in the prefix (the +-- tag is the whisper substitute) -- kept exactly as Turtle's own UI sends +-- it, because a value the server does not expect is a needless risk +T.CHANNEL = "GUILD" +T.ASK_MSG = "INSTalentShow" + +-- seconds between two outgoing requests (any target). Nothing documents a +-- rate limit for THIS protocol, so the number is deliberately timid: one +-- request per this many seconds is far below what Turtle's own UI produces +-- when a player clicks through an inspect window. +T.ASK_GAP = 5 +-- do not ask the same player again for this long. Talents change on +-- respec, which is rare and never mid-raid. +T.REASK_AFTER = 1800 + +-- ================================================================== +-- PURE SECTION (offline-testable) +-- ================================================================== + +-- Strip the leading wire separator. See the header: the payload arrives as +-- INSTalent... because the server rewrote the name tag out of the +-- prefix. Leading spaces/CR/LF are stripped too -- cheap, and it keeps the +-- parser from depending on which whitespace the transport happens to leave. +function T.Normalize(msg) + if type(msg) ~= "string" then + return "" + end + return (string.gsub(msg, "^[ \t\r\n]+", "")) +end + +-- Split on ";" -- Lua 5.0 has no string.split and no gmatch. Empty fields +-- are preserved (the protocol uses fixed positions, so a dropped empty +-- field would shift every later one). +local function split(s) + local out, from = {}, 1 + while true do + local i = string.find(s, ";", from, true) + if not i then + table.insert(out, string.sub(s, from)) + break + end + table.insert(out, string.sub(s, from, i - 1)) + from = i + 1 + end + return out +end + +-- Classify + decode one payload. +-- Returns "tab", { tree, treeName, spent, numTalents } +-- or "talent", { tree, index, name, tier, column, rank, maxRank } +-- or "end", nil +-- or nil (not ours -- every other addon's traffic lands here too) +-- +-- A talent line with a MISSING or non-numeric rank is rejected rather than +-- read as 0: a silently zeroed rank looks exactly like an untrained talent +-- and would understate hit without any sign that something went wrong. +function T.Parse(msg) + msg = T.Normalize(msg) + if msg == "" then + return nil + end + if string.find(msg, "INSTalentEND", 1, true) == 1 then + return "end", nil + end + if string.find(msg, "INSTalentTabInfo;", 1, true) == 1 then + local f = split(msg) + local tree = tonumber(f[2]) + local spent = tonumber(f[4]) + if not tree or not spent then + return nil + end + return "tab", { tree = tree, treeName = f[3], spent = spent, + numTalents = tonumber(f[5]) } + end + if string.find(msg, "INSTalentInfo;", 1, true) == 1 then + local f = split(msg) + local tree = tonumber(f[2]) + local name = f[4] + local rank = tonumber(f[7]) + if not tree or type(name) ~= "string" or name == "" or not rank then + return nil + end + return "talent", { tree = tree, index = tonumber(f[3]), name = name, + tier = tonumber(f[5]), column = tonumber(f[6]), + rank = rank, maxRank = tonumber(f[8]) } + end + return nil +end + +-- A per-sender accumulator. Shape: +-- { ranks = { [talentName] = rank }, -- rank > 0 only, like the own read +-- spent = , +-- sumRank = , +-- trees = , +-- complete = } +function T.NewAcc() + return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false } +end + +-- Fold one parsed line into the accumulator. Returns the accumulator so +-- callers can chain; `complete` flips exactly once, on the END marker. +function T.Feed(acc, kind, data) + if not acc or not kind then + return acc + end + if kind == "end" then + acc.complete = true + return acc + end + if kind == "tab" and data then + acc.trees = acc.trees + 1 + acc.spent = acc.spent + (data.spent or 0) + return acc + end + if kind == "talent" and data and data.rank and data.rank > 0 then + -- keyed BY NAME, because that is what DC.TALENT_HIT matches on + -- (data/talenthit.lua) and what ReadSelfTalents produces -- the two + -- paths must hand DC_Hit.TalentHit the same shape or they would + -- drift apart silently + if not acc.ranks[data.name] then + acc.sumRank = acc.sumRank + data.rank + acc.ranks[data.name] = data.rank + end + end + return acc +end + +-- Consistency gate before a reply is believed. +-- +-- The client that answered told us two independent things: how many points +-- it spent per tree, and every individual rank. They must agree. They are +-- computed from the same source on the far side, so agreement does not +-- prove the data is FRESH -- but disagreement proves we mis-parsed or lost +-- messages, and that is exactly the failure this protects against (a +-- half-received tree would understate hit while looking perfectly normal). +-- +-- All three trees must have reported; a talentless character legitimately +-- sends three empty tab lines and zero talent lines, which passes. +function T.Plausible(acc) + if not acc or not acc.complete then + return false + end + if acc.trees < 3 then + return false + end + return acc.sumRank == acc.spent +end + +-- Throttle decision, pure so the whole policy is testable without a client. +-- asked = { [name] =