DopingControl 0.7.0

This commit is contained in:
2026-10-15 12:29:40 +02:00
commit e6561d3bce
48 changed files with 19254 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
# 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.7.0
### New Addons tab: who in the raid runs BigWigs, Threat Meter, EPGP, SuperWoW, Rinse
A seventh tab checks the raid's addons without the other raiders needing
DopingControl. Each column is detected only through what that addon sends or
answers: BigWigs' version query, the TW Threatmeter "who" query, shootyepgp's
version announcement and its SuperWoW reply, and UpsiRinse's version query.
* **Version in every cell.** Green with a check mark = the newest version in
the raid (your own row counts); green with an exclamation mark = present but
older (the tooltip names the newest); red = no reply after the query
window. Versions compare numerically per segment, and only within the same
BigWigs fork.
* **Honest silence.** Red only after a query window passed; before any query a
cell is grey "?". Not-in-guild (EPGP/SuperWoW) and non-dispeller classes
(Rinse) are "n/a", not red. The red tooltip says what it really means: not
installed, an old version, or a fork that does not answer.
* **Check addons** button; opening the tab runs one automatic check, at most
every 30 seconds, never in combat and never on joining a raid. A passive
listener records versions that addons announce on their own, and forgets
raiders who left.
* Your own row is read from your own addon list instead of waiting for replies.
* `/dc test` shows a deterministic mix of states on this tab.
* This adds a new file (`scan/addons.lua`), so the game must be restarted
once, not just reloaded.
### Equipment tab names the enchant, not just "enchanted"
Hovering an Equipment cell used to say only "Enchanted" or "Not
enchanted". It now shows which permanent enchant the slot carries
("Enchant: Crusader", "Enchant: Agility +15", ...). The names come from a
table generated from the game client's own enchant data, so they match
what the client shows, including server-custom enchants. An enchant that
is not in the table shows as "enchant #<id>": it is still known to be
enchanted, only the name is unknown, and it is never reported as missing.
### The addon does the pre-raid test-mode check itself
`/dc test` is persisted, so it survives a reload and a relog. A session
that ended with the simulated raid on screen started the next one showing
it again — and from then on the matrix graded a roster that does not
exist. On screen the only marker was the source banner, so the failure
mode was never an error, just a plausible wrong answer. That is the worst
kind for a buff checker, and "remember to check before the raid" is not a
routine anyone keeps.
Now the addon says it, at the two moments it matters:
* **Once per login or reload** — printed on world entry, not earlier;
chat output before that point is silently dropped.
* **When a group forms** — on the 0 → grouped edge only, so a raid
filling up prints it once, not once per member.
Both lines name the mode that is actually on and the switch that leaves
it (`/dc test` or `/dc demo`) — naming the wrong one would send you to a
toggle that changes nothing.
### "readable" means one thing now — the footer and the tooltips caught up
v0.6.1 gave the coverage pill its channel nouns (`15/26 hit read`), but the
footer strip on the Hit and Resistances tabs still said **AVERAGE
(READABLE)**, the chat report still said "average, readable players", and
the empty-slot tooltips said "unit readable" — all naming the gear/hit/
resist read while the word elsewhere (the `Not readable` list, the
`N unreadable` chips, the `N/N readable` group chip) means "this player
could be read at all". Same word, two populations, on one screen.
Decided and pinned in a code comment (`core/model.lua`, `CoverageLabel`):
**"readable" = the player could be read this scan (online, auras
answered)** — and nothing else. Every channel-specific population is
named by its channel: `gear read`, `hit read`, `resist read`.
* Footer on Hit / Resistances: **AVERAGE (HIT READ)** / **AVERAGE (RESIST
READ)** — derived from the same function as the pill, so the two can
no longer drift apart.
* Chat report section headers: `Hit (average, hit-read players):` /
`Resistances (average, resist-read players):`.
* Empty-slot tooltips: `gear read, no trinket equipped …`, `gear read, no
item equipped …`; the generic "slot empty" line takes the tab's noun
(`readable` on the aura tabs, `gear read` on Equipment).
* Coverage-pill tooltip on gear/hit/resist tabs now leads with the pill's
own number and noun (`15 of 26 hit read`), then the row-readability
line (`26 of 26 raid members readable (online, auras answered)`) —
before, the pill said `15/26` and its tooltip `26 of 26`.
* Two more spots missed the first pass: the gear-unreadable row's status
chip and its row-spanning explanation (`ui/matrix.lua` `UnreadRowText`,
`core/model.lua` `row.unreadNote`) said `items not readable` — right
next to a group header that counts that same row as readable (only its
EQUIPMENT channel came back empty). Both now say `gear not read`.
## v0.6.4
### Equipment expectation top-up now self-heals every refresh, not just once at ADDON_LOADED
Field report after v0.6.3 shipped (WAIST seeded `true` for every role):
after deploying and reloading, the Equipment matrix still showed 16
columns (no WAI) and the owner's own row read "(read failed)" / sum "--".
Offline reproduction against the real captured dump
(`dc_raid_1788070493.txt`, the scan taken right after the reload) and the
real live `db.expectations` shape
(`WTF\...\SavedVariables\DopingControl.lua`, all 20 role/class lines —
`NECK`/`R1`/`R2 = true`, `WAIST` absent everywhere) through the actual
`DC_Gear.Assemble` -> `DC_Model.build` -> `DC_Matrix` pipeline found no
Lua error anywhere — no nil dereference, nothing caught by a `pcall`.
Both symptoms are real, and neither is what it first looked like:
* The "(read failed)"/"--" row is a correct, unrelated read: the dump's
own `P`/`H`/`X` lines already carry `aurasRead=0`/
`unreadableReason=read failed` before any render happens —
`scan/aura.lua`'s "zero USABLE auras this scan => aurasRead=false" rule
firing on a genuine zero-buff moment. Nothing to do with WAIST or
equipment.
* The missing WAIST column traces to `core/model.lua`'s `anyClassExpects`:
it only falls back to the `DC.DEFAULT_EXPECT` seed for a (role, class)
combo with NO saved line at all — an *existing* class line (any line
materialized before the v0.6.3 seed change) is trusted as complete, key
by key. `DC.EnsureExpectations`/`topUpLine` (`core/config.lua`) are
themselves correct — proven by running them against the exact real
saved shape above — but they only ran once, at `ADDON_LOADED`. A
`db.expectations` line materialized before v0.6.3 shipped therefore
stayed short the new key until a SECOND full reload happened to run
`EnsureExpectations` again against the current seed.
Fix: `ui/matrix.lua`'s `MX.ExpectTable` — the one resolver both the header
and `MX.Refresh` read the expectation table from — now defensively re-runs
`DC.EnsureExpectations(d)` every time it resolves, i.e. on every
scan/window redraw, not only once at boot. `topUpLine` only ever fills
`nil` keys, so this is idempotent and side-effect-free on an
already-current table; it self-heals a stale class line the moment the
window redraws, without requiring a second reload.
New regression test `tools/luatests/test_expectations_topup.lua`: embeds
the real captured `db.expectations` shape (all 20 role/class lines,
verbatim) and a real gear/hit dump line, reproduces the exact
16-column/no-WAI/`(read failed)` symptom through the unmodified
`DC_Model.build`, then proves `MX.ExpectTable` self-heals it to 17 columns
with WAIST at column 6.
## v0.6.3
### Waist is expected by default for every role
Owner decision, reversing the "confirmed, not a bug" call below: every role
wears an enchantable belt, so WAIST now belongs with the other worn
equipment slots (HEAD, CHST, WRST, ...) instead of the opt-in neck/ring
treatment. `data/expectations.lua`'s seed now carries `WAIST = true` for
all five roles. Existing SavedVariables pick up the flag automatically —
`core/config.lua`'s `topUpLine` already adds any seed slot the saved line
has never seen (`nil`) as `true` on load, the same mechanism that
delivered the neck/ring columns to existing saves; no separate migration
was needed. An explicit `WAIST = false` set for a role/class still hides
the column, same opt-out behavior as any other slot.
### Confirmed: the waist column's off-by-default is intended, not a bug
A field report said the belt was worn but the Equipment tab still showed
16 columns, no WAI, after the v0.6.2 waist-slot fix was deployed and the
UI reloaded. Traced end to end (`core/model.lua` `tabSlots`/
`anyClassExpects`, `ui/matrix.lua`'s `vm.slots` column build, the live
SavedVariables `expectations` table): the gating is working exactly as
v0.6.2 shipped and tested it — the waist enchant column is opt-in per
role/class, the same treatment as neck/rings, and simply had not been
enabled for any role/class yet (unlike neck/rings, which already carry
`= true` in every class line of the reporting player's save from before
this default existed). No code was hiding anything; the column appears
the moment the checkbox is enabled in Options > Equipment.
Added a regression test (`tools/luatests/test_model.lua`, section 8d)
that exercises the real production path — `core/model.lua`'s `M.build`
through `tabSlots`/`anyClassExpects`, the same function `ui/matrix.lua`
renders from — instead of the static `DC.SLOTS_EQUIPMENT` array the
layout test uses. It pins both halves: WAIST absent from `vm.slots`
under the default seed, and present + classifying correctly (HAS/
MISSING/UNKNOWN) once a role/class expectation is switched on. This
closes the gap that let the "is it really gated correctly" question go
unanswered by the existing suite.
## v0.6.2
A fix release.
### The waist slot was never scanned
No equipment read — the raid dump, the Equipment tab, the Hit tab's
belt contribution — ever covered the waist (inventory slot 6). The slot
list that drives every `GetInventoryItemLink` read was borrowed from the
Equipment tab's enchant-tracking column list, which had no waist column;
that list never included the waist at all, so it was silently skipped in
every read that reused it, not only display.
The waist turns out to be enchantable on this server (a custom "Belt
Buckle" item line, the same kind of addition that already made the neck
and both rings enchantable beyond stock behaviour), so it now gets its
own Equipment-tab column — off by default, like the neck and ring
columns, until enabled per role/class in the options grid.
## 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.
+37
View File
@@ -0,0 +1,37 @@
## Interface: 11200
## Title: DopingControl
## Notes: Raid consumables, buffs, debuffs, resistances, hit and enchant checker
## Author: ShempError
## Version: 0.7.0
## 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
core\demo.lua
scan\talents.lua
scan\reach.lua
scan\aura.lua
scan\gear.lua
scan\resist.lua
scan\hit.lua
scan\engine.lua
scan\pull.lua
scan\addons.lua
core\probe.lua
ui\cellsheet.lua
ui\widgets.lua
ui\matrix.lua
ui\options.lua
ui\report.lua
features\minimap.lua
core\init.lua
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ShempError
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+349
View File
@@ -0,0 +1,349 @@
# 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.
<img src="screenshots/testmode.png" alt="The full readiness matrix on a simulated raid" width="100%">
*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.*
## What's new — v0.7.0 · 2026-10-15
- **A new Addons tab** shows which raid addons the other raiders run — BigWigs, Threat Meter,
EPGP, SuperWoW and Rinse — with the version in every cell and the newest one marked. The
other raiders do not need DopingControl; see [The Addons tab](#the-addons-tab).
- **The Equipment tooltip names the enchant.** Hovering a slot now says "Enchant: Crusader"
or "Enchant: Agility +15" instead of only "Enchanted", including server-custom enchants.
- **Seven tabs now**, and the footer, the filter and the test mode (`/dc test`) work on the
new tab as well.
## What's new — v0.6.4 · 2026-08-30
- **The belt is checked.** The waist slot was never scanned (v0.6.2), is expected equipment
for every role by default (v0.6.3), and a profile saved before the column existed picks it up
on the next refresh instead of only at login (v0.6.4).
**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
- **v0.7.0** — the **Addons tab** (BigWigs, Threat Meter, EPGP, SuperWoW, Rinse across the raid);
the Equipment tooltip names the enchant.
- **v0.6.4** — the equipment expectation top-up self-heals on every refresh, so a profile
saved before the waist column existed picks it up without a relogin.
- **v0.6.3** — the waist slot is expected equipment by default for every role.
- **v0.6.2** — fix: the waist slot was never scanned, so a missing belt could not be reported.
- **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.
## Screenshots
<table>
<tr>
<td align="center" width="33%">
<a href="screenshots/classbuffs.png"><img src="screenshots/classbuffs.png" width="260" alt="Class buffs tab"></a><br>
<sub><b>Class buffs</b> — six slots, the buffer's duty</sub>
</td>
<td align="center" width="33%">
<a href="screenshots/debuffs.png"><img src="screenshots/debuffs.png" width="260" alt="Debuffs tab"></a><br>
<sub><b>Debuffs</b> — columns only for what is present</sub>
</td>
<td align="center" width="33%">
<a href="screenshots/resistances.png"><img src="screenshots/resistances.png" width="260" alt="Resistances tab"></a><br>
<sub><b>Resistances</b> — totals + raid average</sub>
</td>
</tr>
<tr>
<td align="center" width="33%">
<a href="screenshots/hit.png"><img src="screenshots/hit.png" width="260" alt="Hit tab"></a><br>
<sub><b>Hit</b> — melee, ranged and one column per school</sub>
</td>
<td align="center" width="33%">
<a href="screenshots/hit-tooltip.png"><img src="screenshots/hit-tooltip.png" width="260" alt="Hit breakdown tooltip"></a><br>
<sub><b>Hit breakdown</b> — every source on its own line</sub>
</td>
<td align="center" width="33%">
<a href="screenshots/equipment.png"><img src="screenshots/equipment.png" width="260" alt="Equipment tab"></a><br>
<sub><b>Equipment</b> — item tiles, enchant corner markers</sub>
</td>
</tr>
<tr>
<td align="center" width="33%">
<a href="screenshots/options.png"><img src="screenshots/options.png" width="260" alt="Options panel"></a><br>
<sub><b>Options</b> — expectations per role and class</sub>
</td>
<td align="center" width="33%">
<a href="screenshots/skillbooks.png"><img src="screenshots/skillbooks.png" width="260" alt="Skill books options tab"></a><br>
<sub><b>Skill books</b> — one column per weapon skill</sub>
</td>
<td align="center" width="33%">
<a href="screenshots/hit-tooltip-school.png"><img src="screenshots/hit-tooltip-school.png" width="260" alt="School hit tooltip"></a><br>
<sub><b>School columns</b> — what only that school gets</sub>
</td>
</tr>
</table>
*(Click any thumbnail for full size.)*
## The seven 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 — and the tooltip names it
("Enchant: Crusader", "Enchant: Agility +15"), from a table generated from the game client's own
enchant data, so server-custom enchants are named too. An enchant that is not in the table shows
as "enchant #<id>": still known to be enchanted, never reported as missing.
**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.
### The Addons tab
<img src="screenshots/addons.png" alt="The Addons tab on a simulated raid: versions with a check, outdated versions with an exclamation mark, missing addons in red, unreadable players as a grey question mark, not-expected cells as a dot" width="100%">
*The Addons tab with the check finished (test mode, simulated raid): one row per raider, one
column per addon, the version in each cell. Every cell state is visible: a version with a check,
a version with `!`, a red missing cell, a grey `?` for an unreadable player and a dot where the
addon is not expected.*
The question this tab answers is **"who in my raid runs the addons I rely on?"** — BigWigs,
Threat Meter (TW Threatmeter), EPGP (shootyepgp), SuperWoW and Rinse. There are no
expectations to set and nothing to whisper; it is a display tab like Resistances and Hit.
**How it checks.** The other raiders do **not** need DopingControl. Each column is detected only
through what that addon itself sends or answers: BigWigs' own version query, the Threat Meter
"who" query, shootyepgp's version announcement (and its SuperWoW reply), and the UpsiRinse
version query. DopingControl sends the queries those addons already understand and reads the
answers.
- The **Check addons** button sends one burst of queries and collects replies for six seconds.
The status line next to it counts the window down.
- Opening the tab runs one check automatically, at most every 30 seconds, and only out of
combat. Joining a raid does not trigger one.
- Besides that, a passive listener records the versions addons announce on their own, and
forgets raiders who left the group.
- Your own row is read from your own addon list, not from replies.
**The cells.**
| Cell | Meaning |
|---|---|
| Green, version, check mark | The addon is there and this is the **newest version in the raid** (your own row counts). |
| Green, version, "!" | The addon is there but **older than the newest** version seen, or the version cannot be compared. The tooltip names the newest. |
| Red | **No reply** after the query window passed. |
| Grey "?" | **Not asked yet** (or the player is offline). Silence before any query is never shown as red. |
| Dot | **Not applicable** — see the limits below. |
Hovering a cell shows the version, the raw reply, which fork answered and how long ago it was
seen. SuperWoW has no version: its cell says `yes` (green) or `no` (red).
**What each column can and cannot tell you.** Red means *no reply*, and that is not always
"not installed" — it can also be an old version or a fork that does not answer. The tooltip on
a red cell says exactly that, and each column header carries its own limits:
- **BW (BigWigs)** — raid groups only, not party. Forks answer in their own format, and
revision numbers are compared only **within the same fork**.
- **THR (Threat Meter)** — only TW Threatmeter answers; other threat meters are not detected.
- **EPG (EPGP)** — members of **your own guild** only, and only if they run shootyepgp. The
version is known only if that player's announcement reached you; a
reply to the SuperWoW query alone proves the addon is installed but carries no version.
- **SW (SuperWoW)** — also guild members running shootyepgp only: SuperWoW itself cannot be seen
from outside, so shootyepgp answers for it. A raider outside your guild shows a dot, not red.
- **RIN (Rinse)** — only dispellers (Druid, Priest, Paladin, Shaman, Mage) are checked; everyone
else shows a dot. Only the **UpsiRinse** fork answers the query, so a raider with the original
Rinse shows red even though the addon is installed.
**Reading the totals.** Each class group's header counts the players, the gaps (red cells) and
how many answered. The footer **MISSING PER ADDON** counts the red cells per column, and the
**Gaps only** filter hides every raider with no red and no grey cell, so rows that still await an
answer stay visible. `/dc test` fills the tab with a deterministic mix of all states.
## 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 seven 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.
## Demo mode
`/dc demo` (or the button in the options panel) shows the same simulated raid, but with **all
six school protection columns at once** — fire, frost, nature, shadow, holy and arcane — even
though five of them are switched off by default. It is a viewing mode, nothing else: your saved
expectations are never modified, and the mode itself is not saved either, so a reload ends it.
## Install
**Option A — download (simplest):**
1. Grab **`DopingControl-vX.Y.Z.zip`** from
[**Releases**](https://octowow.st/git/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://octowow.st/git/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 demo` | toggles demo mode (the simulated raid with all six protection columns shown; your expectations stay untouched, the mode is not saved) |
| `/dc probe [range <name>]` | 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.
## Licence
MIT.
+252
View File
@@ -0,0 +1,252 @@
-- 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
-- auto-scan on a BigWigs pull timer (scan/pull.lua), ANY raid/party
-- member's, never opens the window -- see that file's header for the
-- wire format and the self-pull backstop.
pullTimerScan = true,
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 = {},
}
-- ------------------------------------------------------------------
-- DC.SimOwnsStore(db) -> bool
--
-- The ONE owner predicate for DC.store, and the question every scan gate
-- asks -- never db.testMode alone. Both simulated modes replace the store
-- with the simulated raid, and while either is on "real data never beats
-- the simulation":
-- * db.testMode -- /dc test, persisted (DEFAULTS above)
-- * DC.demoMode -- /dc demo, transient (core/demo.lua)
--
-- Why it exists: the gates named db.testMode only, so a finished scan
-- overwrote the sim store while demo mode was still armed. The demo
-- expectation layer then graded the REAL raid against all six school
-- protection columns (five of which ship with no seed entry at all): every
-- raider grew fabricated MISSING cells, the banner vanished with the "sim"
-- source that had been the only marker that this is a mode and not a
-- measurement, and each row's whisper button went live against a real
-- player.
--
-- Why HERE and not in core/demo.lua, where the mode lives: config.lua loads
-- second, ahead of every gate that calls this, so the gates need no
-- load-order guard -- and a demo module that fails to load can never take
-- test mode's protection down with it (DC.demoMode is then simply nil).
--
-- db is optional; the fallback is for callers that do not hold one.
-- Always returns a real boolean -- the gates invert it.
-- ------------------------------------------------------------------
function DC.SimOwnsStore(db)
db = db or DC.db or DopingControlDB
if db and db.testMode then
return true
end
if DC.demoMode then
return true
end
return false
end
-- 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
+277
View File
@@ -0,0 +1,277 @@
-- 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. ADDONS is the last
-- tab: it checks the raid's addons (scan/addons.lua), no expectations, no
-- whisper; a column with no reply after a query window is a red gap.
-- ------------------------------------------------------------------
DC.TABS = { "CONSUMABLES", "CLASSBUFFS", "DEBUFFS", "RESIST", "HIT", "EQUIPMENT",
"ADDONS" }
DC.TAB_LABEL = {
CONSUMABLES = "Consumables",
CLASSBUFFS = "Class buffs",
DEBUFFS = "Debuffs",
RESIST = "Resistances",
HIT = "Hit",
EQUIPMENT = "Equipment",
ADDONS = "Addons",
}
-- ------------------------------------------------------------------
-- 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 },
}
-- ------------------------------------------------------------------
-- ADDONS tab slot table (static). One column per addon the raid leader
-- cares about. Other raiders do not run DopingControl, so every column is
-- detected only through what that addon itself sends or answers; `how`
-- and `limits` feed the column header tooltip. `sw` marks the SuperWoW
-- column, which has no version (yes/no).
-- ------------------------------------------------------------------
DC.SLOTS_ADDONS = {
{ id = "BIGWIGS", label = "BW", sub = "boss mod", full = "BigWigs", kind = "addon",
how = "Asks the raid with BigWigs' own version query; every BigWigs"
.. " client answers with its revision.",
limits = "Raid groups only (not party). Forks answer in their own"
.. " format; revisions are only compared within the same fork." },
{ id = "TWT", label = "THR", sub = "threat", full = "Threat Meter", kind = "addon",
how = "Asks the group with the TW Threatmeter 'who' query; each"
.. " client answers with its version.",
limits = "Other threat meters are not detected." },
{ id = "EPGP", label = "EPG", sub = "epgp", full = "EPGP", kind = "addon",
how = "shootyepgp announces its version when it loads, and answers the"
.. " SuperWoW query below, which proves it is installed.",
limits = "Guild members only. The version is only known if that"
.. " player logged in while you were listening." },
{ id = "SUPERWOW", label = "SW", sub = "superwow", full = "SuperWoW", kind = "addon",
how = "Asked via shootyepgp: its answer says whether SuperWoW is"
.. " present (yes/no).",
limits = "Guild members running shootyepgp only. The SuperWoW DLL"
.. " itself cannot be detected." },
{ id = "RINSE", label = "RIN", sub = "dispel", full = "Rinse", kind = "addon",
how = "Asks the group with the UpsiRinse version query; it answers"
.. " with its version.",
limits = "Only dispellers (Druid, Priest, Paladin, Shaman, Mage) are"
.. " checked, and only the UpsiRinse fork answers - the original"
.. " Rinse does not." },
}
-- ------------------------------------------------------------------
-- 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
}
+154
View File
@@ -0,0 +1,154 @@
-- DopingControl core/demo.lua
-- Demo mode: the showcase view. It reuses test mode's simulated raid
-- (DC_Sim.buildStore -- no second roster exists) and lays its OWN
-- expectation table on top so ALL SIX school protection columns
-- (FR/FrR/NR/SR/HR/AR) are visible at once.
--
-- Two hard rules, both of them the reason this file exists at all:
--
-- 1. The demo layer NEVER writes into db.expectations. Live, five of the
-- six school columns ship with no seed entry (data/expectations.lua)
-- and are therefore hidden -- that is a deliberate default, and a
-- screenshot mode must not rewrite the user's saved matrix to get its
-- picture. BuildDemoExpectations deep-copies every line it starts
-- from, so no table is ever shared with the DB either.
-- 2. Demo state is TRANSIENT -- DC.demoMode / DC.demoExpectations live in
-- memory only and are gone after a reload. Deliberately unlike
-- db.testMode (which is persisted): the same treatment DC.simRoles
-- gets in core/roles.lua, and the BulwarkFrame /bf demo pattern.
--
-- Why the columns must be forced per (role, class) and not per role: the
-- model resolves a row through expect[role][class] and falls back to the
-- DC.DEFAULT_EXPECT ROLE line whenever that class line is missing
-- (core/model.lua, expectLine) -- a partially filled demo table would fall
-- back into the seed and silently lose the school columns again. The same
-- fallback drives the dynamic-column check (anyClassExpects), so an
-- incomplete layer would also drop the columns from the header.
--
-- FR is forced too, although the seed already ships it true for all five
-- roles: the user may have unchecked it, and "all six schools" is the
-- point of the mode.
--
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
DopingControl = DopingControl or {}
local DC = DopingControl
-- ------------------------------------------------------------------
-- Transient state (NEVER SavedVariables -- see rule 2 above).
-- ------------------------------------------------------------------
DC.demoMode = false
DC.demoExpectations = nil
-- DC.demoMode is the demo half of DC.SimOwnsStore (core/config.lua) -- the
-- predicate every scan gate asks. It deliberately lives in config.lua, not
-- here: config.lua loads second, ahead of every gate, so a broken demo
-- module can never take TEST mode's protection down with it.
-- The six school protection columns of the consumables slot table
-- (data/consumables.lua). FR is the only one with a seed entry.
DC.DEMO_SCHOOL_SLOTS = { "FR", "FrR", "NR", "SR", "HR", "AR" }
local function forceSchools(line)
local n = table.getn(DC.DEMO_SCHOOL_SLOTS)
for i = 1, n do
line[DC.DEMO_SCHOOL_SLOTS[i]] = true
end
end
-- ------------------------------------------------------------------
-- DC.BuildDemoExpectations(db) -> demo table (also cached in
-- DC.demoExpectations)
--
-- Shape is exactly the live one -- demo[role][class][slotId] -- built per
-- (role, performing class) over DC_Roles.ROLE_CLASSES, so it can be handed
-- to DC_Model.build in place of db.expectations without any special case
-- in the model.
--
-- Source per class line, in order: the user's saved line, else the
-- DC.DEFAULT_EXPECT seed line -- deep-copied either way, then the six
-- school slots forced on. A legacy old-shape role line (booleans directly
-- under [role], migrated away by DC.EnsureExpectations at ADDON_LOADED)
-- is not a table per class and therefore falls back to the seed; demo mode
-- showing seed defaults for one session is acceptable, writing into the DB
-- to avoid it is not.
-- ------------------------------------------------------------------
function DC.BuildDemoExpectations(db)
local src = db and db.expectations
local out = {}
local nRoles = table.getn(DC.ROLES)
for r = 1, nRoles do
local role = DC.ROLES[r]
local seedLine = (DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role]) or {}
local savedByClass = src and src[role]
local classes = (DC_Roles and DC_Roles.ROLE_CLASSES
and DC_Roles.ROLE_CLASSES[role]) or {}
local nClasses = table.getn(classes)
if nClasses > 0 then
local byClass = {}
for c = 1, nClasses do
local cls = classes[c]
local saved = savedByClass and savedByClass[cls]
local line
if type(saved) == "table" then
line = DC.DeepCopy(saved)
else
line = DC.DeepCopy(seedLine)
end
forceSchools(line)
byClass[cls] = line
end
out[role] = byClass
else
-- defensive: without DC_Roles there is no class domain to fill,
-- so emit the legacy role x slot line (the model falls back to
-- the seed for the cells, but the header tooltip and any
-- legacy reader still see the schools)
local line = DC.DeepCopy(seedLine)
forceSchools(line)
out[role] = line
end
end
DC.demoExpectations = out
return out
end
-- ------------------------------------------------------------------
-- DC.SetDemoMode(v) -- the switch behind /dc demo and the options button.
-- Mirrors DC.SetTestMode (core/init.lua) but touches no SavedVariables.
--
-- Store handling: entering demo mode swaps in the simulated raid. Leaving
-- it hands the store back to the REAL scan -- unless db.testMode is on,
-- which owns the simulated store on its own account and must not be kicked
-- out of it. DC.SetTestMode mirrors that in the other direction, so neither
-- mode can empty the other's showcase; both ask DC.SimOwnsStore
-- (core/config.lua), which is also what every scan gate now asks instead
-- of db.testMode.
-- ------------------------------------------------------------------
function DC.SetDemoMode(v)
local db = DC.db or DopingControlDB
if v then
DC.demoMode = true
DC.BuildDemoExpectations(db)
if DC_Sim and DC_Sim.buildStore then
-- buildStore() also refills the transient DC.simRoles -- do NOT
-- wipe them afterwards (see DC.SetTestMode)
DC.store = DC_Sim.buildStore()
end
else
DC.demoMode = false
DC.demoExpectations = nil
if not (db and db.testMode) then
DC.store = { players = {}, source = "scan" }
if DC_Scan and DC_Scan.Start then
DC_Scan.Start(true) -- leaving demo mode: immediate rescan
end
end
end
if DC_Matrix and DC_Matrix.Refresh then
DC_Matrix.Refresh()
end
if DC_Options and DC_Options.Refresh then
DC_Options.Refresh() -- relabels the options button when it is open
end
end
+365
View File
@@ -0,0 +1,365 @@
-- 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()
elseif DC.demoMode then
-- Demo mode owns the simulated store on its own account
-- (DC.SimOwnsStore) -- the mirror image of core/demo.lua's
-- "leaving demo mode must not kick test mode out of the sim
-- store". Without this the showcase would be emptied here and the
-- rescan below would be dropped by the scan gate anyway, leaving a
-- blank window. /dc demo is what ends demo mode.
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
-- ------------------------------------------------------------------
-- The pre-raid check, automated.
--
-- db.testMode is persisted (core/config.lua DEFAULTS), so it survives a
-- reload and a relog: a session that ends with the simulated raid on
-- screen starts the next one showing it again, and from then on the
-- matrix grades a roster that does not exist. On screen the only marker
-- is the source banner, and a "MISSING" wall reads the same either way --
-- so the failure mode is not a visible error, it is a plausible wrong
-- answer, which is the worst kind for a raid buff checker.
--
-- Remembering to check before every raid is not a routine anyone keeps.
-- The addon does it instead, at the two moments it matters: once per
-- login/reload, and again when a group forms.
--
-- DC.SimModeReminder(db, demoMode) -> string|nil
-- The line to print, or nil when neither simulated mode owns the
-- store. Pure -- the caller prints it, so it stays offline-testable.
-- Test mode is named first because it is the persisted one: the mode
-- that comes back on its own is the one this exists for. Demo mode is
-- transient (core/demo.lua) and cannot survive a reload, but it can
-- still be on when the raid forms, so it gets the same reminder with
-- its own switch named -- naming the wrong switch would send the user
-- to a toggle that changes nothing.
-- db/demoMode are optional; the fallback matches DC.SimOwnsStore.
-- ------------------------------------------------------------------
function DC.SimModeReminder(db, demoMode)
db = db or DC.db or DopingControlDB
if db and db.testMode then
return "test mode is ON - the matrix shows the simulated raid, not your group (/dc test to leave)"
end
if demoMode == nil then
demoMode = DC.demoMode
end
if demoMode then
return "demo mode is ON - the matrix shows the simulated raid, not your group (/dc demo to leave)"
end
return nil
end
-- ------------------------------------------------------------------
-- DC.NoteGroupSize(n) -> bool
--
-- Edge detector for "a group just formed": true only on the 0 -> >0
-- transition. The roster events fire once per member, so a 40-man raid
-- filling up would otherwise print the reminder 39 times. Pure apart
-- from its own last-seen counter, which starts at 0 (solo).
-- ------------------------------------------------------------------
local lastGroupSize = 0
function DC.NoteGroupSize(n)
n = n or 0
local joined = (lastGroupSize == 0 and n > 0)
lastGroupSize = n
return joined
end
-- Raid count first, party count only when it is 0: the two calls do not
-- agree on what "in a party" means once a raid exists, and the raid
-- answer is the unambiguous one. Returns 0 offline (no WoW API).
local function GroupSize()
local n = GetNumRaidMembers and GetNumRaidMembers() or 0
if n and n > 0 then
return n
end
n = GetNumPartyMembers and GetNumPartyMembers() or 0
return n or 0
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 | demo | probe [range <name>] | 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
-- Real data never beats the simulation while EITHER simulated mode
-- owns the store (DC.SimOwnsStore, core/config.lua). Test mode is
-- named first because it is the persisted one -- but the refusal
-- must name the mode that is actually on, or the user toggles the
-- wrong switch and the scan keeps refusing.
if db and db.testMode then
DC.Print("test mode is on - /dc test to switch back before scanning")
elseif DC.SimOwnsStore(db) then
DC.Print("demo mode is on - /dc demo 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")
elseif DC.demoMode then
-- SetTestMode kept the sim store for demo mode and started no
-- rescan; a "rescanning" message here would be false.
DC.Print("test mode OFF - demo mode still on, still showing the simulated raid (/dc demo to leave)")
else
DC.Print("test mode OFF - rescanning the real group")
end
elseif cmd == "demo" then
-- Showcase view: the simulated raid plus a TRANSIENT expectation
-- layer that shows all six school protection columns at once
-- (core/demo.lua). Nothing about it is saved -- unlike /dc test,
-- which persists db.testMode.
if not DC.SetDemoMode then
DC.Print("demo module not loaded")
else
DC.SetDemoMode(not DC.demoMode)
if DC.demoMode then
DC.Print("demo mode ON - simulated raid, all six protection columns shown")
else
DC.Print("demo mode OFF - back to the normal view")
end
end
elseif cmd == "probe" then
-- core/probe.lua owns the probe subcommand grammar:
-- DC_Probe.HandleSlash(rest) handles "" (battery),
-- "range <name>" (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
-- ...and say so. Not here though: chat output before
-- PLAYER_ENTERING_WORLD is silently dropped (verified), so
-- the reminder waits for that event.
ev:RegisterEvent("PLAYER_ENTERING_WORLD")
elseif event == "PLAYER_ENTERING_WORLD" then
-- Fires on every loading screen, not just login -- once is
-- the point, so this unregisters itself. The group-join
-- reminder below covers the rest of the session.
ev:UnregisterEvent("PLAYER_ENTERING_WORLD")
local warn = DC.SimModeReminder(DC.db)
if warn then
DC.Print(warn)
end
elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
local db = DC.db
-- A group just formed: the last moment before a raid where
-- a forgotten simulated mode is still cheap to notice.
if DC.NoteGroupSize(GroupSize()) then
local warn = DC.SimModeReminder(db)
if warn then
DC.Print(warn)
end
end
if db and not DC.SimOwnsStore(db) 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
+1822
View File
File diff suppressed because it is too large Load Diff
+534
View File
@@ -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 <name>" -> 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 <name>)
-- ------------------------------------------------------------------
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 <playername>")
return false
end
say("usage: /dc probe | /dc probe range <playername>")
return false
end
+247
View File
@@ -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
+1328
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
-- 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
-- ------------------------------------------------------------------
-- Name-only aura collisions with CONSUMABLE slots (decision 2026-08-15):
-- a name-only aura match (no spell ID at all for that scan) must NEVER
-- satisfy a CONSUMABLE slot when the matched display name is also a class
-- buff name -- the scan cannot tell the two apart, and defaulting to HAS
-- would hide a missing consumable behind someone else's class buff. Such
-- a slot classifies UNKNOWN instead (core/model.lua's aura name-fallback
-- branch consults this table; core/const.lua's Iron Rule: UNKNOWN is
-- never collapsed into HAS or MISSING).
--
-- Scope is deliberately ONE-SIDED: only the CONSUMABLE slot side of the
-- collision is affected. The class-buff slot (e.g. SPROT) keeps the
-- existing name-fallback behavior unchanged -- the ambiguity here means
-- "we can't prove the CONSUMABLE was used", not "we can't prove the class
-- buff is missing". Do not widen this into "name-only never counts"; that
-- would make every ID-less scan MISSING/UNKNOWN across the whole matrix.
--
-- Keyed by buff display name (a key shared by DC.CONSUMABLES and
-- DC.CLASSBUFFS). test_data.lua asserts every entry here is a key in BOTH
-- tables, so a stale or speculative entry cannot ship silently.
-- "Shadow Protection" -- DC.CONSUMABLES["Shadow Protection"] (SR slot,
-- Shadow Protection Potion, spell 7242, data/consumables.lua) vs.
-- DC.CLASSBUFFS["Shadow Protection"] above (SPROT slot, priest buff,
-- ids 976/10957/10958) -- identical display name, already flagged as a
-- collision at both definitions and wowhead-confirmed 2026-08-11 (see
-- the SR entry's comment in data/consumables.lua). This is the ONLY
-- name shared between DC.CONSUMABLES and DC.CLASSBUFFS (checked against
-- every key in both tables) -- no other entry is added without the same
-- level of evidence.
-- ------------------------------------------------------------------
DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION = {
["Shadow Protection"] = true,
}
+369
View File
@@ -0,0 +1,369 @@
-- DopingControl data/consumables.lua
-- Consumables tab: slot definitions + buff->entry table + spellID->slot map
-- + the pure item-identification ladder (DC.ResolveItem).
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
--
-- SLOT MODEL (v6, 22 slots -- the 17-slot base design plus the five school
-- protection-potion columns FrR/NR/SR/HR/AR added 2026-08-11):
-- One column = one thing that can be active independently of every other
-- column; items competing INSIDE a column are the "pick one" alternatives.
-- Source: community-sourced TurtleWoW 1.17.2 consumable-stacking rules,
-- trust external -- vanilla 1.12 slot rules, no TBC
-- Battle/Guardian categories. The old 11-slot model conflated four
-- independent slots into SP (GAE / school elixir / Dreamtonic / Dreamshard
-- are simultaneously active in the caster set) and two into HP (Fortitude
-- + stamina alcohol stack), and lacked BL/ZANZA entirely.
--
-- Deliberate compromises (documented, not hidden):
-- * ALC is ONE column although different alcohol types stack (Merlot +
-- Rumsey Black Label at once; same types do not): modelled as one
-- column showing the strongest active, detail in tooltip. The
-- slot-collision tests exempt exactly this column.
-- * Gift of Arthas and Juju Flurry get NO column (defense elixirs all
-- stack; Flurry lasts 20 s).
-- * UNVERIFIED gaps (community source does not cover them; marked
-- `unverified = true` on the slot/entries, upgrade via dev/raiddump
-- evidence next raid): (1) WPN main-hand/off-hand coexistence (oil on
-- MH + stone on OH), (2) protection-potion slot rules (all six school
-- columns FR/FrR/NR/SR/HR/AR -- cross-school stacking is NOT
-- evidenced, so each school gets its own column but every one stays
-- flagged unverified), (3) world-buff interactions (no world-buff
-- columns at all).
--
-- Entry fields (DC.CONSUMABLES[buffName]):
-- slot : slot id (column) the buff fills
-- spellID : aura spell id; 0 = "match by name only" (no known ID)
-- item : the consumable's display name (whisper/tooltip/cell)
-- icon : BARE icon texture name (no "Interface\Icons\" prefix, no
-- extension -- prefix is added at render time); nil when the
-- icon could not be sourced (the matrix fallback ladder
-- handles nil: slot-generic icon, then the green check)
-- discrim : optional Lua 5.0 string.find pattern matched against the
-- buff tooltip's EFFECT line (scan/aura.lua auras.effects) --
-- only where the buff NAME is generic
-- variants : optional ARRAY of { item, icon, discrim } for generic buff
-- names ("Well Fed") that many different items produce; the
-- identification ladder walks them in order, FIRST match wins
-- unverified : optional true -- see the honesty gaps above
--
-- Icon provenance (2026-08-11, never guessed): wowhead classic tooltip
-- API for the vanilla items, octowow.st/db + wowauctions for TurtleWoW
-- customs; icon = nil where no source confirmed one. Two lookalike
-- collisions are GENUINE, not typos: Dragonbreath Chili and Danonzo's
-- Delight share inv_drink_17 with Nightfin Soup; Gordok Green Grog
-- shares inv_drink_03 with Rumsey Rum; Cerebral Cortex Compound shares
-- inv_potion_32 with the Mongoose elixir.
--
-- 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.
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), unverified (optional,
-- see header),
-- icon (BARE slot-generic icon texture name -- same bare-name
-- rule as the entry icons above; the matrix HAS-cell fallback
-- ladder renders it when the ITEM behind a buff cannot be
-- resolved: resolved item icon -> this slot icon -> green check.
-- A representative member item's icon by design -- the cell must
-- read as "this column", not as a question mark).
-- ------------------------------------------------------------------
DC.SLOTS_CONSUMABLES = {
{ id = "FLASK", label = "FLK", sub = "Flask", full = "Flask", kind = "aura",
icon = "inv_potion_62",
items = "Flask of the Titans \194\183 Distilled Wisdom \194\183 Supreme Power" },
{ id = "FOOD", label = "FOD", sub = "Food", full = "Food buff", kind = "aura",
icon = "inv_misc_food_15",
items = "one food buff at a time - e.g. Smoked Desert Dumplings \194\183 Danonzo's Tel'Abim foods" },
{ id = "AP", label = "AP", sub = "Attack", full = "Attack power", kind = "aura",
icon = "inv_potion_92",
items = "Winterfall Firewater \194\183 Juju Might" },
{ id = "STR", label = "STR", sub = "Str.", full = "Strength", kind = "aura",
icon = "inv_potion_61",
items = "Juju Power \194\183 Elixir of Giants" },
{ id = "AGI", label = "AGI", sub = "Agi.", full = "Agility", kind = "aura",
icon = "inv_potion_32",
items = "Elixir of the Mongoose - free-stacker, own column" },
{ id = "BL", label = "BL", sub = "Blasted", full = "Blasted Lands buff", kind = "aura",
icon = "inv_stone_15",
items = "R.O.I.D.S. \194\183 Ground Scorpok Assay \194\183 Lung Juice Cocktail \194\183 Cortex Compound \194\183 Gizzard Gum" },
{ id = "ZANZA", label = "ZAN", sub = "Zanza", full = "Spirit of Zanza", kind = "aura",
icon = "inv_potion_30",
items = "Spirit of Zanza - free-stacker, one Zanza effect at a time" },
{ id = "GAE", label = "GAE", sub = "Arcane", full = "Greater Arcane Elixir", kind = "aura",
icon = "inv_potion_25",
items = "Greater Arcane Elixir \194\183 Arcane Elixir" },
{ id = "SCHOOL", label = "SCH", sub = "School", full = "School elixir", kind = "aura",
icon = "inv_potion_46",
items = "Shadow Power \194\183 Frost Power \194\183 Greater Firepower - one per school slot" },
{ id = "DREAMT", label = "DRT", sub = "Dreamt.", full = "Dreamtonic", kind = "aura",
icon = "inv_potion_10",
items = "Dreamtonic (TurtleWoW custom)" },
{ id = "SHARD", label = "SHD", sub = "Shard", full = "Dreamshard Elixir", kind = "aura",
icon = "inv_potion_12",
items = "Dreamshard Elixir (TurtleWoW custom)" },
{ id = "MP5", label = "MP5", sub = "Mana", full = "Mana regen", kind = "aura",
icon = "inv_potion_45",
items = "Mageblood Potion - Nightfin Soup is a FOOD buff, not this column" },
{ id = "ARM", label = "ARM", sub = "Armor", full = "Armor", kind = "aura",
icon = "inv_potion_66",
items = "Elixir of Superior Defense" },
{ id = "HPELX", label = "FRT", sub = "Fort.", full = "Elixir of Fortitude", kind = "aura",
icon = "inv_potion_43",
items = "Elixir of Fortitude - stacks with the alcohol column" },
{ id = "ALC", label = "ALC", sub = "Alcohol", full = "Alcohol buff", kind = "aura",
icon = "inv_drink_04",
items = "Medivh's Merlot \194\183 Rumsey Rum Black Label \194\183 Kreeg's Stout Beatdown - different types stack, same type does not; one column, strongest shown" },
{ id = "FR", label = "FR", sub = "fire", full = "Fire protection", kind = "aura", unverified = true,
icon = "inv_potion_24",
items = "Greater Fire Protection Potion - situational per boss; enable/disable via the expectation matrix" },
-- The remaining school protection columns follow the FR pattern (one
-- column per school, cross-school stacking unverified -- see header).
-- Unlike FR they carry NO seed entry in data/expectations.lua: the
-- dynamic-column rule keeps them hidden until enabled per role/class.
{ id = "FrR", label = "FrR", sub = "frost", full = "Frost protection", kind = "aura", unverified = true,
icon = "inv_potion_20",
items = "Greater Frost Protection Potion - situational per boss; enable/disable via the expectation matrix" },
{ id = "NR", label = "NR", sub = "nature", full = "Nature protection", kind = "aura", unverified = true,
icon = "inv_potion_22",
items = "Greater Nature Protection Potion - situational per boss; enable/disable via the expectation matrix" },
{ id = "SR", label = "SR", sub = "shadow", full = "Shadow protection", kind = "aura", unverified = true,
icon = "inv_potion_23",
items = "Greater Shadow Protection Potion - situational per boss; enable/disable via the expectation matrix" },
{ id = "HR", label = "HR", sub = "holy", full = "Holy protection", kind = "aura", unverified = true,
icon = "inv_potion_09",
items = "Holy Protection Potion - situational per boss; enable/disable via the expectation matrix" },
{ id = "AR", label = "AR", sub = "arcane", full = "Arcane protection", kind = "aura", unverified = true,
icon = "inv_potion_83",
items = "Greater Arcane Protection Potion - situational per boss; enable/disable via the expectation matrix" },
{ id = "WPN", label = "WPN", sub = "temp.", full = "Weapon (temporary)", kind = "weapon", unverified = true, -- NOT an aura slot; MH/OH split unverified
icon = "inv_stone_sharpeningstone_01",
items = "Elemental/Dense Sharpening Stone \194\183 Brilliant Wizard/Mana Oil - permanent enchant: Equipment tab" },
}
-- ------------------------------------------------------------------
-- Buff name -> entry 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", icon = "inv_potion_62" },
["Flask of Supreme Power"] = { slot = "FLASK", spellID = 17628, item = "Flask of Supreme Power", icon = "inv_potion_41" },
["Flask of Distilled Wisdom"] = { slot = "FLASK", spellID = 17627, item = "Flask of Distilled Wisdom", icon = "inv_potion_97" },
["Flask of Chromatic Resistance"] = { slot = "FLASK", spellID = 17629, item = "Flask of Chromatic Resistance", icon = "inv_potion_48" },
["Flask of Petrification"] = { slot = "FLASK", spellID = 17624, item = "Flask of Petrification", icon = "inv_potion_26" }, -- NOT inv_potion_98 (a often-repeated wrong guess) -- wowhead-confirmed
-- FOOD (buff names, not item names).
-- "Well Fed" is the generic food aura: the ITEM is identified through
-- the tooltip EFFECT line (scan/aura.lua auras.effects) against the
-- per-variant discrim patterns below -- first match wins, no match =
-- nil = the matrix falls back to the slot-generic icon ("food buff,
-- unknown which"). The variant list is the food set of the
-- reference stacking rules plus the classic raid staples; the discrim
-- patterns are best-effort data (recorded-tooltip verification via
-- dev/raiddump pending) -- a wrong one is a one-line fix.
-- ["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.
["Well Fed"] = { slot = "FOOD", spellID = 0, item = "food buff", icon = nil,
variants = {
-- ORDER MATTERS: first match wins, so specific effect lines
-- (crit, ranged AP, spell damage) sit ABOVE the generic stat
-- words they could otherwise be swallowed by.
-- Several Turtle customs SHARE their aura spell with a classic
-- staple (icon research 2026-08-11: Power Mushroom = 24800 =
-- Dumplings, Sour Mountain Berry = 18230 = Grilled Squid,
-- Juicy Striped Melon = 22731 = Runn Tum Tuber) -- those pairs
-- are indistinguishable by effect line; the listed-first item
-- is shown as the representative. Same slot, so harmless.
{ item = "Danonzo's Tel'Abim Delight", icon = "inv_drink_17", discrim = "[Ss]pell [Dd]amage" },
{ item = "Danonzo's Tel'Abim Medley", icon = "inv_misc_food_08", discrim = "[Hh]aste" },
{ item = "Danonzo's Tel'Abim Surprise", icon = "inv_misc_food_09", discrim = "[Rr]anged [Aa]ttack [Pp]ower" },
{ item = "Gurubashi Gumbo", icon = "inv_misc_food_64", discrim = "[Cc]rit" },
{ item = "Le Fishe Au Chocolat", icon = nil, discrim = "[Dd]odge" }, -- icon: questionmark placeholder even upstream (octowow + wowauctions), stays nil
{ item = "Empowering Herbal Salad", icon = "inv_misc_food_salad", discrim = "[Hh]ealing" },
{ item = "Nightfin Soup", icon = "inv_drink_17", discrim = "8 [Mm]ana" },
{ item = "Sagefish Delight", icon = "inv_misc_fish_21", discrim = "6 [Mm]ana" },
-- +20 Strength: Dumplings and Power Mushroom, same spell 24800
{ item = "Smoked Desert Dumplings", icon = "inv_misc_food_64", discrim = "[Ss]trength" },
{ item = "Power Mushroom", icon = "inv_mushroom_11", discrim = "[Ss]trength" }, -- NOT "+10 all stats" (dead pattern until 2026-08-11); tooltip-verified +20 STR
-- +10 Agility: Grilled Squid and Sour Mountain Berry, same spell 18230
{ item = "Grilled Squid", icon = "inv_misc_fish_13", discrim = "[Aa]gility" },
{ item = "Sour Mountain Berry", icon = "inv_misc_food_40", discrim = "[Aa]gility" },
-- +10 Intellect: classic Tuber and Turtle Melon, same spell 22731
{ item = "Runn Tum Tuber Surprise", icon = "inv_misc_food_63", discrim = "[Ii]ntellect" },
{ item = "Juicy Striped Melon", icon = "inv_misc_food_22", discrim = "[Ii]ntellect" },
-- generic stamina LAST: Gumbo's line also contains Stamina and
-- must be caught by its crit pattern above, never by this one
{ item = "Hardened Mushroom", icon = "inv_mushroom_11", discrim = "[Ss]tamina" }, -- NOT "+armor" (dead pattern until 2026-08-11); tooltip-verified +25 STA, spell 25660
} },
["Increased Stamina"] = { slot = "FOOD", spellID = 0, item = "stamina food", icon = nil },
["Dragonbreath Chili"] = { slot = "FOOD", spellID = 15852, item = "Dragonbreath Chili", icon = "inv_drink_17" }, -- yes, the chili really uses a drink icon (wowhead-confirmed via og:image)
-- AP
["Winterfall Firewater"] = { slot = "AP", spellID = 17038, item = "Winterfall Firewater", icon = "inv_potion_92" },
["Juju Might"] = { slot = "AP", spellID = 16329, item = "Juju Might", icon = "inv_misc_monsterscales_07" },
-- STR
["Juju Power"] = { slot = "STR", spellID = 16323, item = "Juju Power", icon = "inv_misc_monsterscales_11" },
["Elixir of Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants", icon = "inv_potion_61" },
["Elixir of the Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants", icon = "inv_potion_61" }, -- 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", icon = "inv_potion_40" },
-- AGI
["Elixir of the Mongoose"] = { slot = "AGI", spellID = 17538, item = "Elixir of the Mongoose", icon = "inv_potion_32" },
["Elixir of Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility", icon = "inv_potion_94" },
["Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility", icon = "inv_potion_94" }, -- 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", icon = "inv_potion_93" },
-- BL (Blasted Lands buff slot -- one at a time, pick one; the five
-- items were split across STR/nowhere in the old model).
-- Aura names + spell ids wowhead-confirmed 2026-08-11 ("Strike of the
-- Scorpok" additionally measured live in a 26-man raid). The item
-- names ride along as alias keys (spellID 0) because the Turtle
-- client may surface the permanent aura under the item name (reference
-- note); a key that never matches is harmless.
["Rage of Ages"] = { slot = "BL", spellID = 10667, item = "R.O.I.D.S.", icon = "inv_stone_15" },
["R.O.I.D.S."] = { slot = "BL", spellID = 0, item = "R.O.I.D.S.", icon = "inv_stone_15" },
["Strike of the Scorpok"] = { slot = "BL", spellID = 10669, item = "Ground Scorpok Assay", icon = "inv_misc_dust_02" }, -- old model filed it under STR with the aura name as item -- both wrong (it is +25 AGI, and the item is the Assay)
["Ground Scorpok Assay"] = { slot = "BL", spellID = 0, item = "Ground Scorpok Assay", icon = "inv_misc_dust_02" },
["Spirit of Boar"] = { slot = "BL", spellID = 10668, item = "Lung Juice Cocktail", icon = "inv_drink_12" },
["Lung Juice Cocktail"] = { slot = "BL", spellID = 0, item = "Lung Juice Cocktail", icon = "inv_drink_12" },
["Infallible Mind"] = { slot = "BL", spellID = 10692, item = "Cerebral Cortex Compound", icon = "inv_potion_32" }, -- same icon as the Mongoose elixir -- genuine, wowhead-confirmed
["Cerebral Cortex Compound"] = { slot = "BL", spellID = 0, item = "Cerebral Cortex Compound", icon = "inv_potion_32" },
["Spiritual Domination"] = { slot = "BL", spellID = 10693, item = "Gizzard Gum", icon = "inv_misc_food_30" },
["Gizzard Gum"] = { slot = "BL", spellID = 0, item = "Gizzard Gum", icon = "inv_misc_food_30" },
-- ZANZA (free-stacker; only one Zanza-potion effect at a time; buff
-- name = item name, wowhead-confirmed)
["Spirit of Zanza"] = { slot = "ZANZA", spellID = 24382, item = "Spirit of Zanza", icon = "inv_potion_30" },
-- GAE (split out of the old SP column)
["Greater Arcane Elixir"] = { slot = "GAE", spellID = 17539, item = "Greater Arcane Elixir", icon = "inv_potion_25" },
["Arcane Elixir"] = { slot = "GAE", spellID = 11390, item = "Arcane Elixir", icon = "inv_potion_30" },
-- SCHOOL (split out of the old SP column; one school elixir at a time)
["Elixir of Shadow Power"] = { slot = "SCHOOL", spellID = 11474, item = "Elixir of Shadow Power", icon = "inv_potion_46" },
["Elixir of Frost Power"] = { slot = "SCHOOL", spellID = 21920, item = "Elixir of Frost Power", icon = "inv_potion_03" },
["Greater Frost Power"] = { slot = "SCHOOL", spellID = 56544, item = "Elixir of Greater Frost Power", icon = "inv_potion_13" }, -- AURA name (TurtleWoW-added). NOT measured in-game: taken from a TurtleWoW spell database (56544 = "Greater Frost Power") 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 = "SCHOOL", spellID = 26276, item = "Elixir of Greater Firepower", icon = "inv_potion_60" },
["Elixir of Firepower"] = { slot = "SCHOOL", spellID = 7844, item = "Elixir of Firepower", icon = "inv_potion_33" },
-- DREAMT (TurtleWoW custom; own slot -- simultaneously active with
-- GAE, SCHOOL and SHARD in the caster set)
["Dreamtonic"] = { slot = "DREAMT", spellID = 45489, item = "Dreamtonic", icon = "inv_potion_10" }, -- use-spell ID; the aura's own ID is unverified
-- SHARD (TurtleWoW custom; own slot)
["Dreamshard Elixir"] = { slot = "SHARD", spellID = 45427, item = "Dreamshard Elixir", icon = "inv_potion_12" }, -- use-spell ID; the aura's own ID is unverified
-- MP5 (narrowed: Nightfin Soup is a FOOD buff and lives there now)
["Mageblood Potion"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion", icon = "inv_potion_45" },
["Mageblood"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion", icon = "inv_potion_45" }, -- TWoW buff-name variant (old Data.lua kept both)
-- ARM
["Elixir of Superior Defense"] = { slot = "ARM", spellID = 11348, item = "Elixir of Superior Defense", icon = "inv_potion_66" },
["Elixir of Greater Defense"] = { slot = "ARM", spellID = 11349, item = "Elixir of Greater Defense", icon = "inv_potion_65" },
-- HPELX (alcohol split out into ALC; Fortitude stacks with it)
["Elixir of Fortitude"] = { slot = "HPELX", spellID = 3593, item = "Elixir of Fortitude", icon = "inv_potion_43" },
["Health II"] = { slot = "HPELX", spellID = 3593, item = "Elixir of Fortitude", icon = "inv_potion_43" }, -- AURA name on this client (measured live: SpellInfo(3593) = "Health II")
-- ALC (alcohol buffs -- ONE column by design although different
-- types stack; see the header compromise note). Buff names and spell
-- ids wowhead/octowow-confirmed 2026-08-11; the "Blue Label" aura
-- name differs from the Blue item name, both keys are kept.
["Medivh's Merlot"] = { slot = "ALC", spellID = 57106, item = "Medivh's Merlot", icon = "inv_drink_waterskin_05" }, -- use-spell ID; the aura's own ID is unverified
["Medivh's Merlot Blue"] = { slot = "ALC", spellID = 0, item = "Medivh's Merlot Blue", icon = "inv_drink_waterskin_01" },
["Medivh's Merlot Blue Label"] = { slot = "ALC", spellID = 57107, item = "Medivh's Merlot Blue", icon = "inv_drink_waterskin_01" }, -- the AURA name (octowow: spell 57107 "Medivh's Merlot Blue Label")
["Rumsey Rum Black Label"] = { slot = "ALC", spellID = 25804, item = "Rumsey Rum Black Label", icon = "inv_drink_04" },
["Rumsey Rum"] = { slot = "ALC", spellID = 20875, item = "Rumsey Rum", icon = "inv_drink_03" },
["Rumsey Rum Light"] = { slot = "ALC", spellID = 25037, item = "Rumsey Rum Light", icon = "inv_drink_08" },
["Gordok Green Grog"] = { slot = "ALC", spellID = 22789, item = "Gordok Green Grog", icon = "inv_drink_03" }, -- same icon as Rumsey Rum -- genuine, wowhead-confirmed
["Kreeg's Stout Beatdown"] = { slot = "ALC", spellID = 22790, item = "Kreeg's Stout Beatdown", icon = "inv_drink_05" }, -- +25 Spirit/-5 Int -- the healer set's alcohol, not a stamina drink
-- FR (protection-potion slot rules are an UNVERIFIED gap, see header)
["Greater Fire Protection"] = { slot = "FR", spellID = 17543, item = "Greater Fire Protection Potion", icon = "inv_potion_24", unverified = true },
["Fire Protection"] = { slot = "FR", spellID = 7233, item = "Fire Protection Potion", icon = "inv_potion_16", unverified = true },
-- FrR/NR/SR/HR/AR (same UNVERIFIED protection-potion gap as FR).
-- Use-effect spell IDs + item icons wowhead-confirmed 2026-08-11.
-- Tier coverage is asymmetric by design: no Lesser Arcane potion and
-- no Greater Holy potion exist in vanilla 1.12. The aura on the
-- client likely drops the "Greater" (wowhead names both tiers
-- "<School> Protection"); the greater keys mirror the FR convention
-- and the ID match carries them regardless of the live aura name.
["Greater Frost Protection"] = { slot = "FrR", spellID = 17544, item = "Greater Frost Protection Potion", icon = "inv_potion_20", unverified = true },
["Frost Protection"] = { slot = "FrR", spellID = 7239, item = "Frost Protection Potion", icon = "inv_potion_13", unverified = true },
["Greater Nature Protection"] = { slot = "NR", spellID = 17546, item = "Greater Nature Protection Potion", icon = "inv_potion_22", unverified = true },
["Nature Protection"] = { slot = "NR", spellID = 7254, item = "Nature Protection Potion", icon = "inv_potion_06", unverified = true }, -- live aura name carries a trailing space (SpellInfo(7254)); scan/aura.lua's trimmed retry covers it
["Greater Shadow Protection"] = { slot = "SR", spellID = 17548, item = "Greater Shadow Protection Potion", icon = "inv_potion_23", unverified = true },
["Shadow Protection"] = { slot = "SR", spellID = 7242, item = "Shadow Protection Potion", icon = "inv_potion_44", unverified = true }, -- NAME collides with the priest buff (data/classbuffs.lua SPROT, ids 976/10957/10958): ID-before-name keeps them apart; a name-only scan cannot tell the two, which SR's default-off expectation mitigates
["Holy Protection"] = { slot = "HR", spellID = 7245, item = "Holy Protection Potion", icon = "inv_potion_09", unverified = true },
["Greater Arcane Protection"] = { slot = "AR", spellID = 17549, item = "Greater Arcane Protection Potion", icon = "inv_potion_83", unverified = true },
-- WPN: intentionally NO aura entries - temp enchant is not a buff
}
-- ------------------------------------------------------------------
-- DC.ResolveItem(buffName, effectText) -> item, icon
-- The PURE identification ladder (spec: "Identification ladder"):
-- 1. unique buff name -> item/icon straight from the entry
-- 2. generic buff name -> match effectText against the entry's
-- variant discrim patterns (plain Lua 5.0 string.find, first match
-- wins) -> that variant's item/icon
-- 3. no match / no effect text -> nil, nil (the caller falls back to
-- the slot-generic icon: "slot filled, item unknown")
-- An entry-level discrim (no variants) gates rule 1 the same way: the
-- item only resolves when the effect line matches. Offline-testable --
-- no WoW API, no upvalues beyond the data table.
-- ------------------------------------------------------------------
function DC.ResolveItem(buffName, effectText)
local def = DC.CONSUMABLES[buffName]
if not def then
return nil, nil
end
if def.variants then
if type(effectText) == "string" then
local n = table.getn(def.variants)
for i = 1, n do
local v = def.variants[i]
if v.discrim and string.find(effectText, v.discrim) then
return v.item, v.icon
end
end
end
return nil, nil
end
if def.discrim then
if type(effectText) == "string"
and string.find(effectText, def.discrim) then
return def.item, def.icon
end
return nil, nil
end
return def.item, def.icon
end
-- ------------------------------------------------------------------
-- 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
+388
View File
@@ -0,0 +1,388 @@
-- DopingControl data/enchants.lua
-- Equipment tab: 15 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, both rings AND the waist ARE enchantable on this
-- server (a server-custom feature beyond vanilla WoW, where none of them
-- take an enchant) -- NECK/R1/R2/WAIST are therefore kind="ench" like any
-- armor slot, not item tiles. The waist enchant is a "Belt Buckle" item
-- (Copper/Bronze/Thorium/Dreamsteel/Bloody Belt Buckle),
-- the same custom itemization line as the neck/ring gems. Their
-- expectation defaults to OFF (data/expectations.lua): unlike the ARMOR
-- enchant slots, a neck/ring/waist enchant is not assumed baseline raid
-- gear, so the columns only track it once a role/class opts in via the
-- options grid.
--
-- Bug history (2026-08-30): the waist (invSlot 6) was missing from this
-- table entirely -- not a deliberate exclusion, an oversight that predates
-- the Belt Buckle lookup above. Because scan/gear.lua's G.InvSlots()
-- derives the full GetInventoryItemLink read list from THIS table, the
-- omission silently dropped the waist from every equipment read: no raid
-- dump ever carried a ";6=" entry, and the HIT tab under-counted any
-- +hit/+resist on a belt (scan/hit.lua's H.SLOTS already listed 6 and
-- expected scan/gear.lua's rawLinks to cover it -- see that file's
-- ReadUnit comment). Adding WAIST here fixes both at the source.
-- 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 = "WAIST", label = "WAI", sub = "slot 6", full = "Waist", kind = "ench", invSlot = 6 }, -- enchantable on this server (custom Belt Buckle line, unlike vanilla)
{ 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
},
}
-- ------------------------------------------------------------------
-- Enchant ID -> display text (1.12 has no API for it). DC.ENCH_NAMES below
-- is generated from the client's own SpellItemEnchantment.dbc, restricted
-- to permanent item enchants (ENCHANT_ITEM spell effects).
-- DC.EnchantName(id) -> name | nil (unknown id, or no enchant: 0/nil).
-- DC.EnchantLabel(id) -> name | "enchant #<id>" | nil (only for no enchant):
-- an id missing from the table is still KNOWN to be enchanted, so it is
-- never reported as missing -- only its name is unknown.
-- ------------------------------------------------------------------
function DC.EnchantName(id)
if type(id) ~= "number" or id <= 0 or not DC.ENCH_NAMES then
return nil
end
return DC.ENCH_NAMES[id]
end
function DC.EnchantLabel(id)
if type(id) ~= "number" or id <= 0 then
return nil
end
return DC.EnchantName(id) or ("enchant #" .. id)
end
-- BEGIN GENERATED ENCH_NAMES (tools/gen-enchant-names.py; do not edit by hand)
DC.ENCH_NAMES = {
[1] = "Rockbiter 3",
[3] = "Flametongue 3",
[15] = "Reinforced Armor +8/+1 Stamina",
[16] = "Reinforced Armor +16/+2 Stamina",
[17] = "Reinforced Armor +24/+3 Stamina",
[18] = "Reinforced Armor +32/+4 Stamina",
[24] = "Mana +5",
[28] = "+4 All Resistances",
[30] = "Scope (+1 Damage)",
[32] = "Scope (+2 Damage)",
[33] = "Scope (+3 Damage)",
[34] = "Counterweight +3% Attack Speed",
[36] = "Enchant: Fiery Blaze",
[37] = "Weapon Chain - Immune Disarm",
[41] = "Health +5",
[43] = "Iron Spike (8-12)",
[44] = "Absorption (10)",
[63] = "Absorption (25)",
[65] = "+1 All Resistances",
[66] = "Stamina +1",
[71] = "+1 Stamina",
[72] = "+2 Stamina",
[73] = "+3 Stamina",
[76] = "+3 Agility",
[80] = "+2 Intellect",
[90] = "+4 Agility",
[92] = "+6 Agility",
[93] = "+7 Agility",
[94] = "+4 Intellect",
[96] = "+6 Intellect",
[98] = "+4 Spirit",
[104] = "+6 Stamina",
[107] = "+5 Strength",
[108] = "+6 Strength",
[208] = "Healing +7",
[225] = "Nature Damage +9",
[241] = "Weapon Damage +2",
[242] = "Health +15",
[243] = "Spirit +1",
[246] = "Mana +20",
[247] = "Agility +1",
[248] = "Strength +1",
[249] = "Beastslaying +2",
[250] = "Weapon Damage +1",
[254] = "Health +25",
[255] = "Spirit +3",
[256] = "+5 Fire Resistance",
[345] = "+40 Armor",
[354] = "+9 Intellect",
[357] = "+9 Strength",
[362] = "+10 Strength",
[370] = "+12 Spirit",
[371] = "+12 Stamina",
[426] = "Spell Damage +5",
[432] = "Spell Damage +12",
[440] = "Healing +12",
[449] = "Rift Tear",
[450] = "Scope (+2% Ranged Crit)",
[463] = "Mithril Spike (16-20)",
[464] = "Mithril Spurs",
[663] = "Scope (+5 Damage)",
[664] = "Scope (+7 Damage)",
[684] = "+15 Strength",
[723] = "Intellect +3",
[724] = "Stamina +3",
[744] = "Armor +20",
[763] = "Block +1%",
[783] = "Armor +10",
[803] = "Fiery Weapon",
[804] = "+10 Shadow Resistance",
[805] = "Weapon Damage +4",
[823] = "Strength +3",
[843] = "Mana +30",
[844] = "Mining +2",
[845] = "Herbalism +2",
[846] = "Fishing +2",
[847] = "All Stats +1",
[848] = "Armor +30",
[849] = "Agility +3",
[850] = "Health +35",
[851] = "Spirit +5",
[852] = "Stamina +5",
[853] = "Beastslaying +6",
[854] = "Elemental Slayer +6",
[856] = "Strength +5",
[857] = "Mana +50",
[863] = "Blocking +2%",
[865] = "Skinning +5",
[866] = "All Stats +2",
[883] = "+15 Agility",
[884] = "Armor +50",
[903] = "+3 All Resistances",
[904] = "Agility +5",
[905] = "Intellect +5",
[906] = "Mining +5",
[907] = "Spirit +7",
[908] = "Health +50",
[909] = "Herbalism +5",
[910] = "Increased Stealth",
[911] = "Minor Speed Increase",
[912] = "Demonslaying",
[913] = "Mana +65",
[923] = "Defense +3",
[924] = "Defense +1",
[925] = "Defense +2",
[926] = "+8 Frost Resistance",
[927] = "Strength +7",
[928] = "All Stats +3",
[929] = "Stamina +7",
[930] = "Minor Mount Speed Increase",
[931] = "Attack Speed +1%",
[943] = "Weapon Damage +3",
[963] = "Weapon Damage +7",
[1023] = "Feedback 1",
[1068] = "+15 Stamina",
[1125] = "+22 Intellect",
[1149] = "+20 Spirit",
[1250] = "+8 Arcane Resistance",
[1252] = "+10 Arcane Resistance",
[1257] = "+15 Arcane Resistance",
[1267] = "+25 Arcane Resistance",
[1341] = "+7 Fire Resistance",
[1342] = "+8 Fire Resistance",
[1344] = "+10 Fire Resistance",
[1388] = "+8 Nature Resistance",
[1434] = "+8 Shadow Resistance",
[1436] = "+10 Shadow Resistance",
[1441] = "+15 Shadow Resistance",
[1446] = "+20 Shadow Resistance",
[1483] = "Mana +150",
[1503] = "HP +100",
[1504] = "Armor +125",
[1505] = "+20 Fire Resistance",
[1506] = "Strength +8",
[1507] = "Stamina +8",
[1508] = "Agility +8",
[1509] = "Intellect +8",
[1510] = "Spirit +8",
[1523] = "MANA/FR +85/14",
[1524] = "HP/FR +75/14",
[1525] = "AC/FR +110/14",
[1526] = "STR/FR +10/14",
[1527] = "STA/FR +10/14",
[1528] = "AGI/FR +10/14",
[1529] = "INT/FR +10/14",
[1530] = "SPI/FR +10/14",
[1532] = "STR/STA/AC/FR +10/10/110/15",
[1543] = "INT/SPI/MANA/FR +10/10/100/15",
[1704] = "Thorium Spike (20-30)",
[1843] = "Reinforced Armor +40/+5 Stamina",
[1883] = "Intellect +7",
[1884] = "Spirit +9",
[1885] = "Strength +9",
[1886] = "Stamina +9",
[1887] = "Agility +7",
[1888] = "+5 All Resistances",
[1889] = "Armor +70",
[1890] = "Spirit +9",
[1891] = "All Stats +4",
[1892] = "Health +100",
[1893] = "Mana +100",
[1894] = "Icy Weapon",
[1896] = "Weapon Damage +9",
[1897] = "Weapon Damage +5",
[1898] = "Lifestealing",
[1899] = "Unholy Weapon",
[1900] = "Crusader",
[1903] = "Spirit +9",
[1904] = "Intellect +9",
[1944] = "+5 Defense",
[2045] = "+14 Ranged Attack Power",
[2082] = "+6 Arcane Spell Damage",
[2084] = "+9 Arcane Spell Damage",
[2144] = "+40 Shadow Spell Damage",
[2158] = "+6 Fire Spell Damage",
[2160] = "+9 Fire Spell Damage",
[2236] = "+9 Frost Spell Damage",
[2296] = "+40 Nature Spell Damage",
[2366] = "+2 mana every 5 sec.",
[2443] = "Frost Spell Damage +7",
[2463] = "+7 Fire Resistance",
[2483] = "+5 Fire Resistance",
[2484] = "+5 Frost Resistance",
[2485] = "+5 Arcane Resistance",
[2486] = "+5 Nature Resistance",
[2487] = "+5 Shadow Resistance",
[2488] = "+5 All Resistances",
[2503] = "Defense +5",
[2504] = "Spell Damage +30",
[2505] = "Healing Spells +55",
[2523] = "Scope (+3% Ranged Hit)",
[2544] = "Healing and Spell Damage +12",
[2545] = "Dodge +1%",
[2563] = "Strength +15",
[2564] = "Agility +15",
[2565] = "Mana Regen 4 per 5 sec.",
[2566] = "Healing Spells +24",
[2567] = "Spirit +20",
[2568] = "Intellect +22",
[2583] = "Defense +7/Stamina +10/Block Value +15",
[2584] = "Defense +7/Stamina +10/Healing Spells +24",
[2585] = "Attack Power +28/Dodge +1%",
[2586] = "Ranged Attack Power +24/Stamina +10/Hit +1%",
[2587] = "Healing and Spell Damage +13/Intellect +15",
[2588] = "Healing and Spell Damage +18/Spell Hit +1%",
[2589] = "Healing and Spell Damage +18/Stamina +10",
[2590] = "Mana Regen +4/Stamina +10/Healing Spells +24",
[2591] = "Intellect +10/Stamina +10/Healing Spells +24",
[2603] = "Eternium Line",
[2604] = "+33 Healing Spells",
[2605] = "+18 Spell Damage and Healing",
[2606] = "+30 Attack Power",
[2613] = "Threat +2%",
[2614] = "Shadow Damage +20",
[2615] = "Frost Damage +20",
[2616] = "Fire Damage +20",
[2617] = "Healing Spells +30",
[2619] = "+15 Fire Resistance",
[2620] = "+15 Nature Resistance",
[2621] = "Subtlety",
[2622] = "Dodge +1%",
[2646] = "Agility +25",
[2681] = "+10 Nature Resistance",
[2682] = "+10 Frost Resistance",
[2683] = "+10 Shadow Resistance",
[2715] = "Healing +31 and 5 mana per 5 sec.",
[2716] = "Stamina +16 and Armor +100",
[2717] = "Attack Power +26 and +1% Critical Strike",
[2721] = "Spell Damage +15 and +1% Spell Critical Strike",
[2722] = "Block Value +10",
[3001] = "Arcane Damage +20",
[3002] = "Nature Damage +20",
[3003] = "Holy Damage +20",
[3004] = "Wolfsheart",
[3005] = "+14 Stamina",
[3008] = "+6 Spell Penetration",
[3011] = "+1% Vampirism",
[3012] = "+2% Vampirism",
[3016] = "+1% Haste",
[3017] = "+2% Haste",
[3021] = "+3 Spell Power",
[3024] = "+5 Defense",
[3025] = "+9 Damage and Healing Spells",
[3026] = "Thorium Spurs",
[3038] = "Armor Penetration +40",
[3047] = "+25 Spell Penetration",
[3049] = "+2% Dodge",
[3051] = "+25 Armor Penetration",
[3052] = "+10 Intellect",
[3054] = "1% Resilience",
[3057] = "Healing +8",
}
-- END GENERATED ENCH_NAMES
+151
View File
@@ -0,0 +1,151 @@
-- 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: the four role sets of the reference stacking rules
-- (TurtleWoW 1.17.2 -- melee / caster / tank / healer, each "all
-- active simultaneously") ARE the defaults now. RANGED
-- has no reference set of its own and is derived from the melee
-- line (no STR -- ranged AP scales off AGI -- and no
-- FLASK). FR stays situational for all five roles
-- (enable/disable per boss via the expectation matrix),
-- WPN as before. The other school protection slots
-- (FrR/NR/SR/HR/AR, added 2026-08-11) carry NO seed entry
-- at all: default off for every role, so the dynamic-
-- column rule hides them until someone enables them per
-- role/class for a matching boss (same treatment as EMB
-- and T1/T2 below) -- unlike FR, which ships seeded true.
-- 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, WAIST included
-- (owner decision, 2026-08-30: every role wears a belt,
-- and it is enchantable on this server via the Belt
-- Buckle item line -- so it is worn equipment like any
-- other slot, not an optional extra like neck/rings);
-- per-item exemptions (wand/holdable) are handled at
-- classify time via DC.ENCH_EXEMPT, not here.
-- NECK/R1/R2 (also enchantable on this server, a custom
-- feature beyond vanilla) stay OFF by default for every
-- role: unlike WAIST, 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 / MELEE+FLASK: the OLD defaults deliberately excluded them;
-- the reference role sets include them (tank runs Winterfall
-- Firewater, melee runs Flask of the Titans), so both
-- flipped to true with the v6 slot model.
-- * RANGED line: derived, not covered by the reference sets (see above) -- BL is included
-- (Ground Scorpok Assay is +25 AGI), STR/FLASK are not.
-- * HEALER+HPELX: the reference healer set carries no Elixir of Fortitude, so
-- HPELX defaults off for HEALER while ALC (Kreeg's
-- Stout Beatdown + Medivh's Merlot Blue) is on.
-- * ZANZA: expected for every role (all four reference sets carry it).
-- * 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 (reference tank set: Mongoose, Firewater, Juju Power,
-- R.O.I.D.S., Zanza, Superior Defense, Fortitude, Titans, food,
-- Merlot + Rumsey, sharpening stone; Gift of Arthas has no column)
FLASK = true, FOOD = true, AP = true, STR = true, AGI = true,
BL = true, ZANZA = true, ARM = true, HPELX = true, ALC = 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,
WAIST = true,
},
MELEE = {
-- consumables (reference melee set: Mongoose, Firewater/Juju Might,
-- Juju Power/Giants, R.O.I.D.S./Scorpok, Zanza, Titans, food,
-- sharpening stone; Juju Flurry has no column)
FLASK = true, FOOD = true, AP = true, STR = true, AGI = true,
BL = true, ZANZA = 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,
WAIST = true,
},
RANGED = {
-- consumables (derived from the melee line -- no reference set; no STR,
-- no FLASK, see "Known debatable rows")
FOOD = true, AP = true, AGI = true, BL = true, ZANZA = 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,
WAIST = 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 (reference caster set: Dreamshard, GAE, school elixir,
-- Dreamtonic, Mageblood, wizard oil, Zanza, Supreme Power, food)
FLASK = true, FOOD = true, GAE = true, SCHOOL = true,
DREAMT = true, SHARD = true, MP5 = true, ZANZA = 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,
WAIST = true,
},
HEALER = {
-- consumables (reference healer set: Dreamshard, Mageblood, mana oil,
-- Zanza, Distilled Wisdom, food, Kreeg's + Merlot Blue)
FLASK = true, FOOD = true, MP5 = true, SHARD = true, ZANZA = true,
ALC = 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,
WAIST = true,
},
}
+319
View File
@@ -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 = <Lua 5.0 string.find pattern with ONE numeric capture>,
-- kind = "melee" | "spell" | "both" | <school name> }
--
-- 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> 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 },
}
+139
View File
@@ -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 = <Lua 5.0 string.find pattern with ONE numeric
-- capture>, kind = <school name> | "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 <School> 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,
}
+171
View File
@@ -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 <school> 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 = <talent name, EXACTLY as GetTalentInfo returns it>,
-- tree = <talent tab name; documentation only, never matched on>,
-- ranks = <max rank; the per-rank arrays must have this length>,
-- melee = { <per rank> }, -- generic physical hit (melee column)
-- ranged = { <per rank> }, -- generic ranged hit (ranged column)
-- spell = { <per rank> }, -- GENERIC spell hit: every school
-- offhand = { <per rank> }, -- OFF-HAND swings only, see below
-- dwExtra = { <per rank> }, -- extra melee hit while DUAL WIELDING
-- schools = { [<lowercase school id>] = { <per rank> } } }
-- 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 } } },
},
}
+118
View File
@@ -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
+908
View File
@@ -0,0 +1,908 @@
-- DopingControl scan/addons.lua
-- Addons tab backend: finds out which raid addons the OTHER raiders run.
-- They do not run DopingControl, so detection rides only on what their own
-- addons send or answer:
-- BigWigs prefix "BigWigs" (RAID): query "BWVQ BigWigs"; replies
-- "PEPO_BWVR <rev> <nick>", "BWVR <rev> <nick> <Fork>", the
-- old "MC:REV" style or a bare number. Any other BigWigs sync
-- proves presence without a version.
-- Threat prefix "TWT": query "TWT_WHO"; replies "TWT_ME:<ver>";
-- passive "TWTVersion:<ver>" at login. (Server threat packets
-- use the prefix "TWT " with a trailing space - a different
-- prefix, ignored here.)
-- EPGP shootyepgp, prefix "SEPGP_PREFIX" (GUILD): "VERSION;<ver>;<n>"
-- at the sender's login only; no query answer.
-- SuperWoW prefix "SWOW_Q" (GUILD) is answered by shootyepgp on prefix
-- "SWOW_R" with "1" (SuperWoW present) or "0". A reply also
-- proves shootyepgp itself is installed.
-- Rinse prefix "Rinse": query "REPORT_ADDON_VERSION", reply
-- "V_<ver>" (only the UpsiRinse fork answers).
-- Honest-unknown rule: silence is only "no reply" after a query window has
-- passed for a member who was in the group when the query went out;
-- before that the cell is unknown, and members who cannot answer (not in
-- the guild, a class that cannot dispel) are n/a.
-- The parsers, the evaluation and the query state machine are pure Lua 5.0
-- (dofile-loadable offline); the WoW wiring sits behind "if CreateFrame".
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Addons = DC_Addons or {}
local A = DC_Addons
A.WINDOW = 6 -- seconds a query waits for replies
A.AUTO_THROTTLE = 30 -- seconds between automatic queries
A.MAX_NAMES = 400 -- passive-store cap (guild chatter from non-raiders)
-- slot ids (match DC.SLOTS_ADDONS)
A.SLOT_IDS = { "BIGWIGS", "TWT", "EPGP", "SUPERWOW", "RINSE" }
A.DISPELLERS = {
DRUID = true, PRIEST = true, PALADIN = true, SHAMAN = true, MAGE = true,
}
-- prefix -> slot (hash lookup = the whole cost of an unrelated message)
local PREFIX_SLOT = {
["BigWigs"] = "BIGWIGS",
["TWT"] = "TWT",
["Rinse"] = "RINSE",
["SEPGP_PREFIX"] = "EPGP",
["SWOW_R"] = "SUPERWOW",
}
A.PREFIXES = PREFIX_SLOT
-- passive store: A.data[name][slot] = entry
A.data = {}
A.dataCount = 0
-- query state
A.query = { at = nil, sent = {}, roster = {} }
A.lastStatus = nil -- last StartQuery verdict text (shown next to the button)
-- ------------------------------------------------------------------
-- text + version helpers
-- ------------------------------------------------------------------
-- Reply text from other people goes into UI strings: drop colour escapes,
-- trailing "<...>" decorations, control characters, and cap the length.
function A.Clean(s)
if type(s) ~= "string" then
return nil
end
s = string.gsub(s, "|", "")
s = string.gsub(s, "[%c]", "")
s = string.gsub(s, "%s*<.*>%s*$", "")
s = string.gsub(s, "^%s+", "")
s = string.gsub(s, "%s+$", "")
if string.len(s) > 24 then
s = string.sub(s, 1, 24)
end
if s == "" then
return nil
end
return s
end
-- "r4521" / "v1.2.3" / "1.10.0" -> { 4521 } / { 1, 2, 3 } / { 1, 10, 0 };
-- anything else -> nil (shown raw, never compared).
function A.Segments(text)
if type(text) ~= "string" then
return nil
end
local s = string.gsub(text, "^[rRvV]", "")
if not string.find(s, "^%d+$") and not string.find(s, "^%d+%.[%d%.]*%d$") then
return nil
end
if string.find(s, "%.%.") then
return nil
end
local out = {}
for d in string.gfind(s, "(%d+)") do
table.insert(out, tonumber(d))
end
return out
end
-- numeric segment compare, missing segments count as 0: -1 / 0 / 1
function A.Compare(a, b)
local na, nb = table.getn(a), table.getn(b)
local n = na
if nb > n then
n = nb
end
for i = 1, n do
local x, y = a[i] or 0, b[i] or 0
if x < y then
return -1
elseif x > y then
return 1
end
end
return 0
end
local function entry(text, variant, raw, channel)
return {
text = text,
ver = A.Segments(text),
variant = variant,
raw = raw,
}
end
-- ------------------------------------------------------------------
-- reply parsers. Each returns an entry table or nil (not a reply);
-- a second return value "query" marks the query messages themselves.
-- ------------------------------------------------------------------
function A.ParseBigWigs(msg)
if type(msg) ~= "string" then
return nil
end
local _, _, sync, rest = string.find(msg, "^(%S+)%s*(.*)$")
if not sync then
return nil
end
if sync == "BWVQ" then
return nil, "query"
end
if not string.find(sync, "BWVR") then
-- some other BigWigs sync (boss engaged, ...): presence, no version
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
local rev, fork
if string.find(rest, ":") then
-- old style "MC:1234 BWL:1234": the highest number is the revision
for d in string.gfind(rest, "(%d+)") do
local n = tonumber(d)
if rev == nil or n > rev then
rev = n
end
end
fork = "Legacy"
elseif tonumber(rest) then
rev = tonumber(rest)
fork = "Legacy"
else
local f1, f2, f3
local _, _, a, b, c = string.find(rest, "^(%S*)%s*(%S*)%s*(%S*)")
f1, f2, f3 = a, b, c
rev = tonumber(f1)
if f3 and f3 ~= "" then
fork = A.Clean(f3)
elseif sync == "PEPO_BWVR" then
fork = "Pepo"
else
fork = "unknown"
end
end
if rev == nil or rev < 0 then
-- reply without a usable revision (zone unknown): BigWigs is there
local e = entry("yes", fork, msg)
e.presenceOnly = true
return e
end
return entry("r" .. rev, fork, msg)
end
function A.ParseTWT(msg)
if type(msg) ~= "string" then
return nil
end
if string.find(msg, "^TWT_WHO") then
return nil, "query"
end
local _, _, v = string.find(msg, "^TWT_ME:(.+)$")
if not v then
_, _, v = string.find(msg, "^TWTVersion:(.+)$")
end
v = A.Clean(v)
if not v then
return nil
end
return entry(v, nil, msg)
end
function A.ParseRinse(msg)
if type(msg) ~= "string" then
return nil
end
if msg == "REPORT_ADDON_VERSION" then
return nil, "query"
end
local _, _, v = string.find(msg, "^V_(.+)$")
v = A.Clean(v)
if not v then
return nil
end
return entry(v, nil, msg)
end
function A.ParseEPGP(msg)
if type(msg) ~= "string" then
return nil
end
local _, _, v = string.find(msg, "^VERSION;([^;]+)")
v = A.Clean(v)
if v then
return entry(v, nil, msg)
end
if string.find(msg, ";") then
-- any other shootyepgp traffic (EP awards, ...) proves it runs
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
return nil
end
-- "1" -> present, "0" -> absent; anything else is not an answer
function A.ParseSwow(msg)
if msg == "1" then
local e = entry("yes", nil, msg)
e.flag = true
return e
elseif msg == "0" then
local e = entry("no", nil, msg)
e.flag = false
return e
end
return nil
end
local PARSER = {
BIGWIGS = A.ParseBigWigs,
TWT = A.ParseTWT,
RINSE = A.ParseRinse,
EPGP = A.ParseEPGP,
SUPERWOW = A.ParseSwow,
}
-- ------------------------------------------------------------------
-- passive store
-- ------------------------------------------------------------------
local function put(name, slot, e, now)
local byName = A.data[name]
if not byName then
if A.dataCount >= A.MAX_NAMES then
A.data = {}
A.dataCount = 0
end
byName = {}
A.data[name] = byName
A.dataCount = A.dataCount + 1
end
local old = byName[slot]
-- a presence-only hint never replaces a real version
if e.presenceOnly and old and not old.presenceOnly then
return false
end
e.at = now
byName[slot] = e
return true
end
-- OnAddonMessage(prefix, msg, channel, sender, now) -> changed (bool)
-- Early return on an unrelated prefix: one table lookup, no allocation.
function A.OnAddonMessage(prefix, msg, channel, sender, now)
local slot = PREFIX_SLOT[prefix]
if not slot or type(sender) ~= "string" or sender == A.own then
return false
end
-- BigWigs itself only honours its syncs on RAID
if slot == "BIGWIGS" and channel ~= "RAID" then
return false
end
local e = PARSER[slot](msg)
if not e then
return false
end
e.channel = channel
local changed = put(sender, slot, e, now)
if slot == "SUPERWOW" then
-- the responder lives inside shootyepgp: an answer proves it
local p = entry("yes", nil, msg)
p.presenceOnly = true
p.channel = channel
if put(sender, "EPGP", p, now) then
changed = true
end
end
return changed
end
-- drop everybody not in the given name set (members who left)
function A.Prune(nameSet)
for name in pairs(A.data) do
if not nameSet[name] then
A.data[name] = nil
A.dataCount = A.dataCount - 1
end
end
end
function A.Reset()
A.data = {}
A.dataCount = 0
A.query = { at = nil, sent = {}, roster = {} }
A.lastStatus = nil
end
-- ------------------------------------------------------------------
-- query
-- env = { now, inCombat, raid, party, guild, send(prefix, msg, channel),
-- roster = array of names }
-- StartQuery(env, auto) -> started (bool), reason (string|nil)
-- ------------------------------------------------------------------
function A.StartQuery(env, auto)
local q = A.query
if env.inCombat then
A.lastStatus = "not in combat"
return false, "in combat"
end
if q.at and env.now - q.at < A.WINDOW then
return false, "query running"
end
if auto and q.at and env.now - q.at < A.AUTO_THROTTLE then
return false, "throttled"
end
if not env.raid and not env.party then
A.lastStatus = "not in a group"
return false, "no group"
end
local send = env.send
local chan = env.raid and "RAID" or "PARTY"
local sent = {}
if env.raid then
pcall(send, "BigWigs", "BWVQ BigWigs", "RAID")
sent.BIGWIGS = true
end
pcall(send, "TWT", "TWT_WHO", chan)
sent.TWT = true
pcall(send, "Rinse", "REPORT_ADDON_VERSION", chan)
sent.RINSE = true
if env.guild then
pcall(send, "SWOW_Q", "1", "GUILD")
sent.SUPERWOW = true
sent.EPGP = true
end
local roster = {}
local names = env.roster or {}
for i = 1, table.getn(names) do
roster[names[i]] = true
end
A.query = { at = env.now, sent = sent, roster = roster }
A.lastStatus = nil
return true
end
-- AutoCheck: the throttled variant used when the tab is opened
function A.AutoCheck(env)
return A.StartQuery(env, true)
end
function A.IsSilent(name, slot, now)
local q = A.query
if not q.at or not q.sent[slot] or not q.roster[name] then
return false
end
return now >= q.at + A.WINDOW
end
-- ------------------------------------------------------------------
-- own row: local knowledge instead of replies (several addons never
-- answer their own sender).
-- g = { loaded = function(name) -> bool, meta = function(addon, field),
-- bigwigsRev = number|nil, twtVer = string|nil, superwow = any }
-- returns slot -> entry | false (known not installed)
-- ------------------------------------------------------------------
function A.LocalEntries(g)
local out = {}
local function mk(text, variant)
local e = entry(A.Clean(text) or "yes", variant, "local")
e.islocal = true
return e
end
-- BigWigs
if g.loaded("BigWigs") then
local rev = g.bigwigsRev or tonumber(g.meta("BigWigs", "X-Revision"))
local fork = A.Clean(g.meta("BigWigs", "X-Fork"))
if rev and rev > 0 then
out.BIGWIGS = mk("r" .. rev, fork or "unknown")
else
out.BIGWIGS = mk("yes", fork or "unknown")
out.BIGWIGS.presenceOnly = true
end
else
out.BIGWIGS = false
end
-- threat meter
if g.twtVer then
out.TWT = mk(g.twtVer)
elseif g.loaded("TWThreat") then
out.TWT = mk(g.meta("TWThreat", "Version") or "yes")
else
out.TWT = false
end
-- shootyepgp
if g.loaded("shootyepgp") then
out.EPGP = mk(g.meta("shootyepgp", "Version") or "yes")
else
out.EPGP = false
end
-- SuperWoW: a global the DLL sets, no version needed
if g.superwow then
local e = mk("yes")
e.flag = true
out.SUPERWOW = e
else
local e = mk("no")
e.flag = false
out.SUPERWOW = e
end
-- Rinse (the fork first: it is the one that answers queries)
if g.loaded("UpsiRinse") then
out.RINSE = mk(g.meta("UpsiRinse", "Version") or "yes")
elseif g.loaded("Rinse") then
out.RINSE = mk(g.meta("Rinse", "Version") or "yes")
else
out.RINSE = false
end
return out
end
-- ------------------------------------------------------------------
-- evaluation
-- ------------------------------------------------------------------
local function variantKey(e)
return e.variant or ""
end
-- Group the present entries of one slot by fork/variant and find the
-- reference variant (most members, ties broken by name) plus the newest
-- parseable version inside each variant.
local function buildGroups(list)
local byVar, vars = {}, {}
for i = 1, table.getn(list) do
local e = list[i]
local k = variantKey(e)
local g = byVar[k]
if not g then
g = { key = k, count = 0, parseable = 0, uniform = true,
first = e.text, best = nil }
byVar[k] = g
table.insert(vars, g)
end
g.count = g.count + 1
if e.text ~= g.first then
g.uniform = false
end
if e.ver then
g.parseable = g.parseable + 1
if not g.best or A.Compare(e.ver, g.best.ver) > 0 then
g.best = e
end
end
end
local ref
for i = 1, table.getn(vars) do
local g = vars[i]
if not ref or g.count > ref.count
or (g.count == ref.count and g.key < ref.key) then
ref = g
end
end
return byVar, ref
end
-- Decide check / bang for one present entry. Returns mark, note.
local function markFor(e, byVar, ref)
if e.flag ~= nil then
return "check", nil -- SuperWoW has no version
end
local g = byVar[variantKey(e)]
if g ~= ref then
local who = (e.variant and e.variant ~= "") and e.variant or "other"
local refName = (ref.key ~= "") and ref.key or "main"
return "bang", "fork " .. who .. " (the raid mostly runs "
.. refName .. ", newest " .. (ref.best and ref.best.text or ref.first) .. ")"
end
if e.ver then
if g.best and A.Compare(e.ver, g.best.ver) < 0 then
return "bang", "newest in raid: " .. g.best.text
end
return "check", nil
end
-- unparseable text: fine only when nothing different is around
if g.parseable == 0 and g.uniform then
return "check", nil
end
return "bang", "version cannot be compared"
.. (g.best and (" (newest in raid: " .. g.best.text .. ")") or "")
end
local NA_CLASS = "this class cannot dispel, so Rinse is not expected"
local NA_GUILD = "not in your guild - shootyepgp only talks on guild chat"
local NA_NOGLD = "you are not in a guild - this check needs guild chat"
-- Annotate(players, ctx): sets p.addons[slot] and p.addonsAnswered on every
-- player table. ctx:
-- now clock
-- own own character name (nil in test mode)
-- ownEntries LocalEntries result (or nil)
-- myGuild bool, I am in a guild
-- inGuild(p) bool, this player is in my guild
-- lookup(p, slot) -> entry | nil
-- silent(p, slot) -> bool (queried, window over, no reply)
-- queryAt query time (nil = nothing asked yet)
function A.Annotate(players, ctx)
local n = table.getn(players)
local resolved = {} -- resolved[i][slot] = entry | false | "na" ... see below
local lists = {} -- present entries per slot (for the groups)
for s = 1, table.getn(A.SLOT_IDS) do
lists[A.SLOT_IDS[s]] = {}
end
local info = {} -- info[i][slot] = { kind=..., e=..., why=... }
for i = 1, n do
local p = players[i]
local row = {}
info[i] = row
local isOwn = ctx.own ~= nil and p.name == ctx.own and ctx.ownEntries
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
local r
if p.online == false then
r = { kind = "unknown", why = "offline" }
elseif slot == "RINSE" and not A.DISPELLERS[p.class or ""] then
r = { kind = "na", why = NA_CLASS }
elseif isOwn then
local e = ctx.ownEntries[slot]
if e then
r = { kind = "present", e = e }
if not e.presenceOnly then
table.insert(lists[slot], e)
end
else
r = { kind = "missing",
why = "not installed on your own character" }
end
elseif (slot == "EPGP" or slot == "SUPERWOW")
and not ctx.inGuild(p) then
r = { kind = "na", why = ctx.myGuild and NA_GUILD or NA_NOGLD }
else
local e = ctx.lookup(p, slot)
if e then
r = { kind = "present", e = e }
if not e.presenceOnly then
table.insert(lists[slot], e)
end
elseif ctx.silent(p, slot) then
r = { kind = "missing" }
else
local why
if ctx.queryAt then
why = "waiting for a reply, or this addon was not"
.. " queried in this kind of group"
else
why = "not asked yet - use Check addons"
end
r = { kind = "unknown", why = why }
end
end
row[slot] = r
end
end
-- newest per slot and variant
local groups, refs = {}, {}
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
if table.getn(lists[slot]) > 0 then
groups[slot], refs[slot] = buildGroups(lists[slot])
end
end
for i = 1, n do
local p = players[i]
local out = {}
local answered = false
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
local r = info[i][slot]
local res
if r.kind == "present" then
local e = r.e
if e.flag == false then
res = { status = "missing", text = e.text, raw = e.raw,
at = e.at, islocal = e.islocal,
why = e.islocal and "SuperWoW is not loaded"
or "shootyepgp reports: no SuperWoW" }
answered = true
else
local mark, note, newest
if e.presenceOnly then
-- present, version unknown: fine unless somebody
-- else reported one to compare against
if refs[slot] and refs[slot].best then
mark = "bang"
newest = refs[slot].best.text
note = "version unknown (newest in raid: "
.. newest .. ")"
else
mark = "check"
end
else
mark, note = markFor(e, groups[slot], refs[slot])
local g = groups[slot][variantKey(e)]
newest = g.best and g.best.text or nil
end
res = { status = (mark == "check") and "ok" or "old",
mark = mark, text = e.text, raw = e.raw,
at = e.at, variant = e.variant,
islocal = e.islocal, note = note,
presenceOnly = e.presenceOnly,
newest = newest }
answered = true
end
elseif r.kind == "missing" then
res = { status = "missing", why = r.why
or "no reply - addon missing, or an old version / fork"
.. " that does not answer" }
elseif r.kind == "na" then
res = { status = "na", why = r.why }
else
res = { status = "unknown", why = r.why }
end
out[slot] = res
end
p.addons = out
p.addonsAnswered = answered
end
end
-- ------------------------------------------------------------------
-- WoW wiring
-- ------------------------------------------------------------------
-- the real-game ctx: passive store + query state + guild lookup
function A.RealCtx(env)
local now = env.now
return {
now = now,
own = env.own,
ownEntries = env.ownEntries,
myGuild = env.myGuild,
inGuild = env.inGuild,
queryAt = A.query.at,
lookup = function(p, slot)
local byName = A.data[p.name]
return byName and byName[slot] or nil
end,
silent = function(p, slot)
return A.IsSilent(p.name, slot, now)
end,
}
end
-- the simulated raid: players carry p.addonSim = { guild = bool,
-- <slot> = entry (replied) | false (queried, silent) | nil (unknown) }
function A.SimCtx(now)
return {
now = now,
myGuild = true,
queryAt = now,
inGuild = function(p)
return p.addonSim ~= nil and p.addonSim.guild == true
end,
lookup = function(p, slot)
local v = p.addonSim and p.addonSim[slot]
if type(v) ~= "table" then
return nil
end
local e = entry(v.text, v.variant, "sim: " .. v.text)
e.flag = v.flag
e.presenceOnly = v.presenceOnly
e.at = now
return e
end,
silent = function(p, slot)
return p.addonSim ~= nil and p.addonSim[slot] == false
end,
}
end
-- Annotate a store's players for display (called before the model builds).
function A.AnnotateStore(store, env)
if not store or not store.players then
return
end
if store.source == "sim" then
A.Annotate(store.players, A.SimCtx(env and env.now or 0))
return
end
A.Annotate(store.players, A.RealCtx(env))
-- evict against the live roster (not the store: before the first scan
-- finishes the store is empty and would wipe everything)
if env and env.roster and table.getn(env.roster) > 0 then
local set = {}
for i = 1, table.getn(env.roster) do
set[env.roster[i]] = true
end
A.Prune(set)
end
end
-- tooltip/age helper (pure): seconds -> "5s ago" / "2m ago" / "1h ago"
function A.AgeText(sec)
if type(sec) ~= "number" or sec < 0 then
return "just now"
end
if sec < 60 then
return math.floor(sec) .. "s ago"
elseif sec < 3600 then
return math.floor(sec / 60) .. "m ago"
end
return math.floor(sec / 3600) .. "h ago"
end
if CreateFrame then
local function names()
local list = {}
local own = UnitName("player")
if own then
table.insert(list, own)
end
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
if nr > 0 then
list = {}
for i = 1, nr do
local nm = GetRaidRosterInfo(i)
if nm then
table.insert(list, nm)
end
end
else
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
for i = 1, np do
local nm = UnitName("party" .. i)
if nm then
table.insert(list, nm)
end
end
end
return list
end
local function guildName(unit)
if GetGuildInfo then
return (GetGuildInfo(unit))
end
return nil
end
-- name -> true for raid/party members in MY guild, rebuilt per call (the
-- display refresh is event driven, not per frame)
local function guildSet()
local set = {}
local mine = guildName("player")
if not mine then
return set, false
end
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
for i = 1, nr do
local unit = "raid" .. i
if guildName(unit) == mine then
set[UnitName(unit)] = true
end
end
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
for i = 1, np do
local unit = "party" .. i
if guildName(unit) == mine then
set[UnitName(unit)] = true
end
end
local own = UnitName("player")
if own then
set[own] = true
end
return set, true
end
function A.GameEnv()
local now = GetTime()
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
local gs, inGuild = guildSet()
local g = {
loaded = function(name)
return IsAddOnLoaded and IsAddOnLoaded(name) and true or false
end,
meta = function(addon, field)
return GetAddOnMetadata and GetAddOnMetadata(addon, field) or nil
end,
bigwigsRev = (type(BigWigs) == "table") and tonumber(BigWigs.revision) or nil,
twtVer = (type(TWT) == "table") and TWT.addonVer or nil,
superwow = SUPERWOW_VERSION,
}
local own = UnitName("player")
A.own = own
return {
now = now,
raid = nr > 0,
party = nr == 0 and np > 0,
guild = inGuild,
myGuild = inGuild,
inCombat = UnitAffectingCombat and UnitAffectingCombat("player") and true or false,
send = SendAddonMessage,
roster = names(),
own = own,
ownEntries = A.LocalEntries(g),
inGuild = function(p)
return gs[p.name] == true
end,
}
end
-- Check addons button / tab open. Returns started, reason.
function A.Check(auto)
local ok, env = pcall(A.GameEnv)
if not ok then
return false, "error"
end
local started, reason = A.StartQuery(env, auto)
if started then
A.finishAt = GetTime() + A.WINDOW + 0.3
A.Wake()
end
return started, reason
end
-- one frame: passive listener + a throttled updater that repaints the tab
-- when replies came in and again when the query window ends
local listener = CreateFrame("Frame", "DopingControlAddonsEventFrame", UIParent)
listener:RegisterEvent("CHAT_MSG_ADDON")
listener:SetScript("OnEvent", function()
if A.OnAddonMessage(arg1, arg2, arg3, arg4, GetTime()) then
A.dirty = true
A.Wake()
end
end)
local updater = CreateFrame("Frame", "DopingControlAddonsUpdateFrame", UIParent)
updater.dcT = 0
updater:Hide()
updater:SetScript("OnUpdate", function()
this.dcT = this.dcT + arg1
if this.dcT < 0.5 then
return
end
this.dcT = 0
local now = GetTime()
local repaint = false
if A.dirty then
A.dirty = false
repaint = true
end
if A.finishAt and now >= A.finishAt then
A.finishAt = nil
repaint = true
end
if repaint and DC_Matrix and DC_Matrix.RefreshAddons then
DC_Matrix.RefreshAddons()
end
if not A.dirty and not A.finishAt then
this:Hide()
end
end)
function A.Wake()
updater:Show()
end
end -- if CreateFrame
+519
View File
@@ -0,0 +1,519 @@
-- 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.
-- Buff EFFECT lines: auras.effects[name] = the tooltip's line 2 text
-- (same hidden-tooltip read as the name, TextLeft2) for every path-A buff
-- that yielded both a name and a non-empty line 2. This feeds the item
-- identification ladder for generic buff names ("Well Fed" -> which food,
-- via DC.ResolveItem's discrim patterns). effects is OPTIONAL/partial
-- exactly like textures -- every consumer nil-guards.
-- 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:<id>", 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:<id>" -- 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:<id>" 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, effect = string|nil }
-- (path A; texture is the 1st UnitBuff return,
-- effect the tooltip's line 2 -- both 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:<id>" exactly
-- as before (no behavior change without it).
-- Returns:
-- auras : { names = { [name]=true }, ids = { [id]=true },
-- textures = { [name]=iconPath },
-- effects = { [name]=effectLine } }
-- (store shape; textures AND effects 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:<id>")
-- 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 effects = {} -- [buffName] = tooltip effect line (line 2)
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
if type(e.effect) == "string" and e.effect ~= "" then
effects[e.name] = e.effect
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:<id>").
-- Without a resolved name, behavior is unchanged: "spell:<id>".
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,
effects = effects },
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("<name>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
-- Returns name (tooltip line 1) AND effect (tooltip line 2 -- the buff's
-- effect text, feeds the item identification ladder for generic names).
-- raises an error on bad units -> always called through pcall
local function tipBuffName(unit, buffIndex)
scanTip:ClearLines()
scanTip:SetUnitBuff(unit, buffIndex)
local name, effect = nil, nil
local textObj = getglobal("DopingControlScanTipTextLeft1")
if textObj then
name = textObj:GetText()
end
local effObj = getglobal("DopingControlScanTipTextLeft2")
if effObj then
effect = effObj:GetText()
end
return name, effect
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, effect = nil, nil
if tip then
local ok2, nm, eff = pcall(tipBuffName, unit, i)
if ok2 then
name = nm
effect = eff
end
end
local id = nil
if type(auraID) == "number" and auraID > 0 then
id = auraID
end
-- texture (UnitBuff return 1) and the tooltip effect line ride
-- along so Assemble can key them under the tooltip name
-- (auras.textures / auras.effects)
table.insert(rawA, { name = name, id = id, texture = texture,
effect = effect })
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:<id>").
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
+1142
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -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, 6, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
-- InvSlots() -> array of the 17 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 "<Name> (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 "<Name> (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
+1053
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
-- DopingControl scan/pull.lua
-- Silent snapshot on a pull timer: BigWigs' Pulltimer plugin broadcasts a
-- CHAT_MSG_ADDON every time ANYONE in the raid/party starts (or rebroadcasts)
-- a pull timer -- one more moment where "who has their flask up" matters,
-- and one the addon currently misses entirely (only READY_CHECK triggers an
-- automatic snapshot -- scan/engine.lua's header -- and some raids' ready
-- checks route through a DIFFERENT addon's own protocol instead: prefix
-- "TW_RF", payload "<Name> Ready", never raising the native READY_CHECK
-- event at all (seen in a live capture). A whole Molten
-- Core night went unsnapshotted this way on 24 Sep 2026.
--
-- Wire format (source-confirmed against the installed BigWigs addon):
-- BigWigsComm:BigWigs_SendSync sends
-- SendAddonMessage("BigWigs", "<syncName> <rest>", "RAID")
-- (Plugins/Comm.lua:57-67) and receives the identical shape back on
-- CHAT_MSG_ADDON with prefix == "BigWigs" (Plugins/Comm.lua:43-54:
-- local _, _, sync, rest = string.find(message, "(%S+)%s*(.*)$")
-- -- the exact split used below). The pull-timer sync names
-- (Plugins/Pulltimer.lua:27-32):
-- "PulltimerSync" -- a (possibly soft/relayed) timer start,
-- rest = duration in seconds
-- "PulltimerBroadcastSync" -- an explicit rebroadcast, same rest shape
-- "PulltimerStopSync" -- pull aborted -- no duration, never a start
-- "PulltimerReqSync" -- "is a timer running" -- no duration
-- DoPull only ever Syncs a POSITIVE duration (Plugins/Pulltimer.lua:203-
-- 223: an explicit 0 either stops an existing bar or resolves to 6 before
-- sending), and BigWigs_Pulltimer itself discards anything `tonumber`
-- rejects (Plugins/Pulltimer.lua:256-260) -- so "sync name matches AND
-- rest is a positive number" is both necessary and sufficient for "a pull
-- just started".
-- No pfUI pull timer exists on this client (checked: no "pull" or
-- "Pulltimer" hit anywhere under Interface/AddOns/pfUI) -- BigWigs is the
-- only pull timer installed, so it is the only wire this file speaks.
-- Live confirmation the "BigWigs"/RAID channel is real traffic on this
-- server, not merely installed-but-idle code: captured chat addon
-- messages carry the same "<sync> <rest>" shape (a ward-fired notice).
-- No PulltimerSync sample was captured, so the exact in-game duration
-- argument is confirmed from source, not from a captured packet.
--
-- Self-pull: does the SENDER's own client receive its own CHAT_MSG_ADDON on
-- this fork? Unverified in-game either way -- but BigWigs' own code does not
-- trust it: BigWigs_SendSync calls SendAddonMessage AND ALSO invokes its own
-- CHAT_MSG_ADDON handler directly, passing the sender's own name, right
-- after (Plugins/Comm.lua:64-65). That is strong circumstantial evidence the
-- authors did not want a raid leader's own /pull to depend on the echo. This
-- file does the analogous thing: it hooks the global SendAddonMessage
-- (guarded, pcall-wrapped, every return value passed through unchanged) and
-- feeds prefix=="BigWigs" traffic through the SAME parser the CHAT_MSG_ADDON
-- handler uses. If the echo DOES fire on this client, the debounce below
-- absorbs the harmless double trigger; if it does not, the hook is the only
-- path a self-started pull has.
--
-- The scan itself rides the SAME entry point as the ready-check path
-- (DC_Scan.Start, scan/engine.lua) and is silent BY CONSTRUCTION: E.Start
-- never shows any UI frame -- the ready-check path's own auto-OPEN is a
-- SEPARATE decision made by core/init.lua's READY_CHECK handler
-- (db.readyCheckOpen -> DC_Matrix.Show()), which this file never touches or
-- even requires loaded. The resulting store gets a `trigger = "pull"` tag
-- (E.NewStore, scan/engine.lua) purely so dev/raiddump.lua's S line and the
-- store itself say which scans came from a pull timer; store.source stays
-- "scan" either way -- every real gate keeps reading THAT field.
--
-- Pure Lua 5.0, dofile-loadable offline: P.ParsePull/P.ShouldTrigger are
-- pure; the frame, the event and the SendAddonMessage hook sit behind
-- `if CreateFrame then`, the same convention as every other scan/*.lua file.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Pull = DC_Pull or {}
local P = DC_Pull
-- at most one pull-triggered scan every this many seconds
P.DEBOUNCE = 15
-- ==================================================================
-- Pure part
-- ==================================================================
-- ParsePull(prefix, msg) -> duration (number > 0) | nil
-- Recognizes a pull-timer START message on the BigWigs wire (see header).
-- Everything else (wrong prefix, a stop/req sync, a non-numeric or
-- non-positive rest) returns nil -- including a message from some OTHER
-- addon that happens to share the "BigWigs" prefix by accident, which would
-- simply fail the sync-name check.
function P.ParsePull(prefix, msg)
if prefix ~= "BigWigs" or type(msg) ~= "string" then
return nil
end
local _, _, sync, rest = string.find(msg, "(%S+)%s*(.*)$")
if sync ~= "PulltimerSync" and sync ~= "PulltimerBroadcastSync" then
return nil
end
local dur = tonumber(rest)
if dur and dur > 0 then
return dur
end
return nil
end
-- ShouldTrigger(now, lastAt, opts) -> bool
-- opts = { enabled, inGroup, simOwnsStore, scanRunning }
-- Every gate named in the brief, in one pure, orthogonal predicate: the
-- setting, group membership, the sim-store lock and the debounce window.
-- `scanRunning` is a SEPARATE gate from the debounce window -- a long scan
-- (a large raid, batched 5 units/frame in scan/engine.lua) can still be
-- running well past 15s, and a second pull during it must not restart it:
-- unlike a manual/ready-check Start(true), a pull-triggered Start is never
-- `force`, so DC_Scan.Start would already no-op on its own -- this gate just
-- avoids bumping `lastAt` (and thus the debounce window) for a call that was
-- never going to do anything.
function P.ShouldTrigger(now, lastAt, opts)
opts = opts or {}
if not opts.enabled then
return false
end
if opts.simOwnsStore then
return false
end
if not opts.inGroup then
return false
end
if opts.scanRunning then
return false
end
if lastAt and (now - lastAt) < P.DEBOUNCE then
return false
end
return true
end
-- ==================================================================
-- WoW part
-- ==================================================================
if CreateFrame then
local lastAt = nil
-- Raid count first, party count only when it is 0 -- same convention as
-- core/init.lua's (private) GroupSize(): both read the same two API
-- calls and would drift identically if either ever changed shape.
local function inGroup()
local n = GetNumRaidMembers and GetNumRaidMembers() or 0
if n and n > 0 then
return true
end
n = GetNumPartyMembers and GetNumPartyMembers() or 0
return (n or 0) > 0
end
local function pullEnabled(db)
-- default ON, like every other auto-scan switch in this file family
-- (db.readyCheckScan, core/config.lua) -- NEVER `db.pullTimerScan`
-- alone (would misread a saved `false` as truthy-missing).
return not (db and db.pullTimerScan == false)
end
-- Shared by the CHAT_MSG_ADDON handler and the SendAddonMessage hook
-- below -- one decision path, fed from two entry points (see header).
local function maybeTrigger(prefix, msg)
local dur = P.ParsePull(prefix, msg)
if not dur then
return
end
local db = DopingControlDB
local now = GetTime and GetTime() or 0
local scanRunning = (DC_Scan and DC_Scan.IsRunning
and DC_Scan.IsRunning()) or false
local go = P.ShouldTrigger(now, lastAt, {
enabled = pullEnabled(db),
inGroup = inGroup(),
simOwnsStore = DC.SimOwnsStore(db),
scanRunning = scanRunning,
})
if not go then
return
end
lastAt = now
if DC_Scan and DC_Scan.Start then
DC_Scan.Start(nil, "pull")
end
end
local f = CreateFrame("Frame", "DopingControlPullFrame")
f:RegisterEvent("CHAT_MSG_ADDON")
f:SetScript("OnEvent", function()
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender -- the
-- channel/sender are not needed here, unlike scan/talents.lua: ANY
-- raid/party member's pull (including our own, see below) is a
-- trigger, so there is nothing left to filter on.
maybeTrigger(arg1, arg2)
end)
-- Self-pull backstop -- see the file header. Installed once; pcall-
-- wrapped so a malformed call from ANY addon can never take the real
-- SendAddonMessage down with it, and every return value is passed
-- through unchanged so no other addon (BigWigs included) can tell it
-- was wrapped.
if SendAddonMessage and not P.hooked then
P.hooked = true
local orig = SendAddonMessage
P.origSendAddonMessage = orig -- exposed for tests
SendAddonMessage = function(prefix, msg, chatType, target)
-- our own logic is guarded so it can never break the caller's
-- send; the original runs unguarded so its errors still reach
-- the calling addon exactly as before the hook
pcall(maybeTrigger, prefix, msg)
return orig(prefix, msg, chatType, target)
end
end
end
+79
View File
@@ -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
+114
View File
@@ -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+ <School> 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
+431
View File
@@ -0,0 +1,431 @@
-- 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<Name>", "INSTalentShow",
-- "GUILD")
-- reply the TARGET's client runs GetTalentInfo on ITSELF and sends one
-- message per line:
-- INSTalentTabInfo;<tree>;<treeName>;<pointsSpent>;<numTalents>
-- INSTalentInfo;<tree>;<index>;<name>;<tier>;<column>;
-- <currRank>;<maxRank>;<meetsPrereq>;
-- <ptier>;<pcolumn>;<isLearnable>
-- 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 PREFIX<TAB>MESSAGE, and the server rewrites
-- the "<Name>" 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 "<Name>" 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
-- how long an unfinished reply stays open. A whole reply lands within a
-- fraction of a second, so a line arriving this much later cannot belong to
-- it -- it is the start of a NEW reply and must not be folded into the old
-- accumulator (see T.AccStale).
T.ACC_TTL = 10
-- ==================================================================
-- PURE SECTION (offline-testable)
-- ==================================================================
-- Strip the leading wire separator. See the header: the payload arrives as
-- <TAB>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 = <sum of pointsSpent over the trees that reported>,
-- sumRank = <sum of every rank we stored>,
-- trees = <how many tab lines arrived>,
-- complete = <INSTalentEND seen>,
-- at = <when the first line arrived, nil offline> }
function T.NewAcc(now)
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false,
at = now }
end
-- Has this accumulator been waiting so long that the next line must belong to
-- a different reply? Only the END marker drops an accumulator, so a reply
-- whose tail was lost would otherwise stay open forever and swallow the next
-- one: tab lines add up twice while ranks are deduplicated by name, so
-- T.Plausible rejects a perfectly good retry. Without a clock (offline) or an
-- opening stamp nothing expires -- the old behaviour, unchanged.
function T.AccStale(acc, now)
if not acc or not acc.at or not now then
return false
end
return (now - acc.at) > T.ACC_TTL
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] = <time we last asked> }
-- lastAny = <time of the last request to ANYBODY>
function T.ShouldAsk(name, now, asked, lastAny)
if type(name) ~= "string" or name == "" then
return false
end
now = now or 0
if lastAny and (now - lastAny) < T.ASK_GAP then
return false
end
local last = asked and asked[name]
if last and (now - last) < T.REASK_AFTER then
return false
end
return true
end
-- ==================================================================
-- WOW SECTION -- frame, event, sending
-- ==================================================================
if CreateFrame then
local acc = {} -- [sender] = accumulator, dropped once complete
local ranks = {} -- [sender] = { [talent] = rank }, the result
local readAt = {} -- [sender] = when that result landed
local asked = {} -- [name] = when we last asked
local lastAny = nil -- when we last asked anybody
local queue = {} -- names waiting to be asked
local queued = {} -- [name] = true, so the queue cannot hold dupes
local function enabled()
local d = DopingControlDB
-- default ON: an addon that computes hit and silently leaves out a
-- readable component is the bug this file fixes
return not (d and d.readTalents == false)
end
-- ranks for a player, or nil if we have not got a plausible reply yet.
-- nil (not {}) is load-bearing, exactly as for the own character:
-- "not read" and "read, no talents" are different states and only the
-- second one may count as a measured zero.
function T.RanksFor(name)
return ranks[name]
end
function T.ReadAt(name)
return readAt[name]
end
-- diagnostics for /dc talents
function T.Stats()
local nRanks, nPending = 0, 0
for _ in pairs(ranks) do
nRanks = nRanks + 1
end
for _ in pairs(acc) do
nPending = nPending + 1
end
return { known = nRanks, pending = nPending,
queued = table.getn(queue), enabled = enabled() }
end
function T.Ask(name)
if not enabled() or type(name) ~= "string" or name == "" then
return false
end
if not SendAddonMessage then
return false
end
-- pcall: a malformed name must never take the scan down with it
local ok = pcall(SendAddonMessage, T.PREFIX .. "<" .. name .. ">",
T.ASK_MSG, T.CHANNEL)
if ok then
asked[name] = GetTime()
lastAny = asked[name]
end
return ok
end
-- set below, once the frame exists: arms the paced sender
local arm
-- Called by the scan with the players it can see. Nothing is sent here
-- -- names are only lined up; the timer below paces them out.
function T.Request(names)
if not enabled() or type(names) ~= "table" then
return
end
local now = GetTime()
for i = 1, table.getn(names) do
local n = names[i]
if type(n) == "string" and n ~= "" and not queued[n]
and T.ShouldAsk(n, now, asked, nil) then
queued[n] = true
table.insert(queue, n)
end
end
if arm and table.getn(queue) > 0 then
arm()
end
end
local f = CreateFrame("Frame", "DopingControlTalentFrame")
f:RegisterEvent("CHAT_MSG_ADDON")
f:SetScript("OnEvent", function()
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender
if arg1 ~= T.PREFIX then
return
end
local sender = arg4
if type(sender) ~= "string" or sender == "" then
return
end
local kind, data = T.Parse(arg2)
if not kind then
return
end
local now = GetTime()
local a = acc[sender]
if a and T.AccStale(a, now) then
-- the previous reply lost its END line; this one starts fresh
a = nil
end
if not a then
a = T.NewAcc(now)
acc[sender] = a
end
T.Feed(a, kind, data)
if a.complete then
acc[sender] = nil
if T.Plausible(a) then
ranks[sender] = a.ranks
readAt[sender] = GetTime()
-- a fresh talent set changes computed hit, so the view has
-- to be rebuilt -- without this the number only corrects
-- itself on the next scan
if DC_Matrix and DC_Matrix.Refresh then
DC_Matrix.Refresh()
end
end
end
end)
-- Paced sender: one request per T.ASK_GAP, and only ever from the
-- queue the scan filled. Deliberately a plain timer rather than a burst
-- after each scan -- 40 requests in one frame is what a rate limit
-- would punish, and nothing documents where that limit sits.
--
-- It is installed only while there is something to send and takes itself
-- off again the moment the queue runs dry (or the feature is switched
-- off): an OnUpdate runs 60+ times a second for the whole session, and a
-- player who never scans must not pay for a queue that is always empty.
-- Re-arming needs no extra wiring -- every scan calls T.Request.
local elapsed = 0
local function disarm()
f:SetScript("OnUpdate", nil)
end
local function drain()
elapsed = elapsed + (arg1 or 0)
if elapsed < 1 then
return
end
elapsed = 0
if not enabled() or table.getn(queue) == 0 then
disarm()
return
end
local now = GetTime()
if lastAny and (now - lastAny) < T.ASK_GAP then
return
end
local name = table.remove(queue, 1)
if name then
queued[name] = nil
if T.ShouldAsk(name, now, asked, lastAny) then
T.Ask(name)
end
end
if table.getn(queue) == 0 then
disarm()
end
end
arm = function()
if f:GetScript("OnUpdate") then
return
end
-- a long idle gap must not fire an immediate burst
elapsed = 0
f:SetScript("OnUpdate", drain)
end
-- forget everything (respec, or a deliberate re-read)
function T.Clear()
disarm()
acc = {}
ranks = {}
readAt = {}
asked = {}
queue = {}
queued = {}
lastAny = nil
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+118
View File
@@ -0,0 +1,118 @@
-- DopingControl ui/cellsheet.lua
--
-- The matrix draws one small box per cell: a solid background with a 1px
-- border whose color carries the state. Done with SetBackdrop that costs NINE
-- texture regions per cell (measured in the client: bare frame 0, backdrop
-- with bgFile only 1, backdrop with edgeFile 9). At 805 cells that is ~7200
-- regions of pure decoration.
--
-- Instead every box is baked into one sheet and a cell owns a single texture
-- region that picks its box with SetTexCoord. The border is part of the image,
-- so the result is pixel-identical -- the sheet is generated from the same
-- DC.COLORS palette by tools/gen-cellsheet.js.
--
-- Pure Lua on purpose: no WoW API at file scope, so the offline tests can
-- dofile it under lua50.exe.
DC_Cells = {}
DC_Cells.SHEET = {
path = "Interface\\AddOns\\DopingControl\\textures\\cells",
size = 256, -- power of two, mandatory: a 12x12 TGA fails silently
tileW = 64, -- holds the widest sprite (34) with room to spare
tileH = 32, -- holds CELL_H (23)
perRow = 4, -- 256 / 64
}
-- Cell height never varies; only the width does (trinket columns are 34).
DC_Cells.CELL_H = 23
DC_Cells.WIDTHS = { 30, 34 }
-- Every (background, border) pair a CELL can take, in sheet order. Borders
-- given as a number are DC.QUALITY indices, everything else is a DC.COLORS
-- key. Order is the wire format of the sheet: appending is safe, reordering
-- means regenerating the TGA.
DC_Cells.VARIANTS = {
{ name = "HAS", bg = "hatBg", border = "hatBorder" },
{ name = "MISSING", bg = "fehltBg", border = "fehltBorder" },
{ name = "UNKNOWN", bg = "unbekBg", border = "unbekBorder" },
{ name = "SKIP", bg = "skip", border = "skipBorder" },
{ name = "NEUTRAL", bg = "theadBg", border = "line" },
{ name = "ITEM_HAS", bg = "theadBg", border = "hatBorder" },
{ name = "ITEM_MISSING", bg = "theadBg", border = "fehltBorder" },
{ name = "Q_RARE", bg = "theadBg", border = "rareBorder" },
{ name = "Q_EPIC", bg = "theadBg", border = "epicBorder" },
{ name = "Q_0", bg = "theadBg", border = 0 },
{ name = "Q_1", bg = "theadBg", border = 1 },
{ name = "Q_2", bg = "theadBg", border = 2 },
{ name = "Q_5", bg = "theadBg", border = 5 },
{ name = "Q_6", bg = "theadBg", border = 6 },
}
DC_Cells.INDEX = {}
for i = 1, table.getn(DC_Cells.VARIANTS) do
DC_Cells.INDEX[DC_Cells.VARIANTS[i].name] = i
end
-- Tile slot of a variant/width pair: each variant owns two consecutive tiles,
-- one per width, in DC_Cells.WIDTHS order.
local function slotOf(name, w)
local vi = DC_Cells.INDEX[name]
if not vi then
return nil
end
for k = 1, table.getn(DC_Cells.WIDTHS) do
if DC_Cells.WIDTHS[k] == w then
return (vi - 1) * table.getn(DC_Cells.WIDTHS) + (k - 1)
end
end
return nil
end
DC_Cells.SlotOf = slotOf
-- SetTexCoord arguments for one box. Returns four numbers or nil -- nil so a
-- typo shows up as a missing box instead of silently sampling tile 0.
function DC_Cells.Coords(name, w)
local slot = slotOf(name, w)
if not slot then
return nil
end
local S = DC_Cells.SHEET
local col = math.mod(slot, S.perRow)
local row = math.floor(slot / S.perRow)
local x0 = col * S.tileW
local y0 = row * S.tileH
return x0 / S.size,
(x0 + w) / S.size,
y0 / S.size,
(y0 + DC_Cells.CELL_H) / S.size
end
-- State + slot kind + item quality -> variant name. Mirrors exactly what the
-- old SetBackdropG call sites in ui/matrix.lua chose -- which, for a nil
-- quality, differed BY KIND (see ui/matrix.lua's M.PaintItemTile, old lines
-- 1965-1990, confirmed at tools/luatests/test_cellsheet.lua):
-- * trinket: M.qualityColors(item.quality) ran unconditionally, and for
-- quality == nil that resolves to DC.QUALITY[1] (common/white) via the
-- `q or 1` fallback -- state-INDEPENDENT.
-- * ench: quality == nil skipped M.qualityColors and fell back to
-- C.hatBorder/C.fehltBorder/C.line BY STATE -- exactly
-- ITEM_HAS/ITEM_MISSING/NEUTRAL.
-- So trinket's nil-quality case must NOT share ench's state-based fallback.
function DC_Cells.VariantFor(state, kind, quality)
if kind == "ench" or kind == "trinket" then
if quality == 3 then return "Q_RARE" end
if quality == 4 then return "Q_EPIC" end
if quality ~= nil then return "Q_" .. quality end
if kind == "trinket" then
return "Q_1"
end
if state == "HAS" then return "ITEM_HAS" end
if state == "MISSING" then return "ITEM_MISSING" end
return "NEUTRAL"
end
if state == "HAS" then return "HAS" end
if state == "MISSING" then return "MISSING" end
if state == "UNKNOWN" then return "UNKNOWN" end
return "SKIP"
end
+4078
View File
File diff suppressed because it is too large Load Diff
+1919
View File
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
-- DopingControl ui/report.lua
-- Report preview overlay + raid-chat send queue.
--
-- Pure part (offline-testable):
-- DC_Report.SendDisabled(source) -> bool (true iff source == "sim")
-- DC_Report.QueueCreate(lines) -> queue (independent copy of lines)
-- DC_Report.QueueTick(q, now, src) -> line-to-send-now | nil
-- DC_Report.QueueLabel(q) -> "sending k/n" button label
-- DC_Report.BuildLines(store, db, tab) -> lines, vm (via DC_Model)
--
-- Queue semantics:
-- * First line goes out immediately on Send; every further line waits
-- >= SEND_INTERVAL (0.6 s) measured from the PREVIOUS ACTUAL SEND
-- (the client chat throttle is ~10 msgs/10 s --
-- 0.6 s spacing stays safely under it even with other addons chatting).
-- * ABORT: if store.source flips to "sim" mid-drain, the very next tick
-- aborts the queue without sending (synthetic names must
-- never reach real chat). An aborted queue never resumes.
-- * In sim mode the Send button never activates in the first place
-- (SendDisabled predicate), with tooltip "disabled in test mode".
--
-- WoW wiring (behind `if CreateFrame then`): overlay frame
-- DopingControlReportFrame, DIALOG strata, anchored over the matrix frame,
-- scrollable preview FontString showing DC_Model.reportLines output EXACTLY
-- as it will be sent, buttons "Send to raid chat" + "Close".
-- SendChatMessage(line, "RAID") only ever fires from the queue drainer here
-- (single-sender rule).
--
-- Deliberate resolutions:
-- * DC_Report.Open() is exposed with OPTIONAL (vm, tab) arguments: the
-- matrix "Report..." button may pass its already-built vm; with no
-- arguments the report builds its own vm from DC.store via
-- DC_Model/DC_Roles and the current tab (DC_Matrix.GetTab()/.tab if
-- the matrix exposes it, else DC.currentTab, else "CONSUMABLES").
-- * The matrix frame global name is not fixed; anchoring
-- tries DC_Matrix.frame, then DopingControlFrame /
-- DopingControlMatrixFrame, else UIParent.
-- * Closing the overlay (Close / ESC) while draining cancels the rest of
-- the queue -- hidden frames get no OnUpdate on 1.12, so a hidden
-- "paused" queue would silently resume minutes later; cancel is the
-- predictable behavior.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Report = DC_Report or {}
local R = DC_Report
-- ==================================================================
-- Pure queue core
-- ==================================================================
R.SEND_INTERVAL = 0.6 -- seconds between two sent lines
R.CHAT_TYPE = "RAID"
-- Send is dead exactly in test mode. nil source (no scan
-- yet) is NOT sim: sending a "coverage 0/0" report is pointless but safe.
function R.SendDisabled(source)
return source == "sim"
end
-- The chat API rejects the WHOLE message with "Invalid escape code in chat
-- message" when it meets a "|" that does not open a valid escape sequence
-- (|c |r |H |h |T |t |n ||). Our header line uses " | " as a plain ASCII
-- separator, so every report was refused. "||" is the escape for a literal
-- pipe and still renders as a single "|", which is why this runs at the
-- send point and NOT inside DC_Model.reportLines: the preview shows what
-- reportLines returns and must keep showing the text as it APPEARS in chat.
-- Report lines are pure ASCII by design (no item links, no colour codes),
-- so doubling every pipe cannot damage a real escape sequence here.
function R.ChatSafe(line)
if not line then
return line
end
return (string.gsub(line, "|", "||"))
end
-- Create a drain queue from an array of lines. Takes an independent copy
-- (the preview may be rebuilt while an old queue is still draining).
function R.QueueCreate(lines)
local q = {
lines = {},
n = 0,
sent = 0,
nextAt = 0, -- absolute time the next send is allowed
done = false,
aborted = false,
}
local n = table.getn(lines)
for i = 1, n do
table.insert(q.lines, lines[i])
end
q.n = n
if n == 0 then
q.done = true
end
return q
end
-- One drain step. now = current time (GetTime() in-game), source = current
-- store source ("scan"/"sim"/nil). Returns the line to send NOW, or nil.
-- Sets q.done when the last line went out, q.aborted on a sim flip.
function R.QueueTick(q, now, source)
if not q or q.done or q.aborted then
return nil
end
if source == "sim" then
q.aborted = true -- never resumes
return nil
end
if now < q.nextAt then
return nil
end
q.sent = q.sent + 1
local line = q.lines[q.sent]
q.nextAt = now + R.SEND_INTERVAL
if q.sent >= q.n then
q.done = true
end
return line
end
-- Button label while a queue exists.
function R.QueueLabel(q)
if not q then
return nil
end
if q.aborted then
return "aborted"
end
return string.format("sending %d/%d", q.sent, q.n)
end
-- ==================================================================
-- Line building (pure given a store; uses DC_Model + DC_Roles)
-- ==================================================================
-- "14:04"-style timestamp: WoW 1.12 exposes a global date(); plain
-- Lua 5.0 has os.date.
function R.TimeStr()
if type(date) == "function" then
return date("%H:%M")
end
if os and type(os.date) == "function" then
return os.date("%H:%M")
end
return "--:--"
end
-- Build the report lines for a store + db + tab. Returns lines, vm.
function R.BuildLines(store, dbt, tab)
store = store or DC.store or { players = {} }
tab = tab or "CONSUMABLES"
-- Same expectation resolver the matrix uses, or the report preview
-- would show different columns and different gap totals than the grid
-- it was opened from (demo mode). Safe for the SEND path too: the demo
-- layer only applies over the "sim" store, and R.SendDisabled blocks
-- sending on exactly that source. Guarded because ui/report.lua is
-- loadable without ui/matrix.lua (tools/luatests/test_report_queue.lua).
local expect
if DC_Matrix and DC_Matrix.ExpectTable then
expect = DC_Matrix.ExpectTable(dbt)
else
expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {}
end
local function roleOf(p)
return DC_Roles.resolve(dbt, store.source, p)
end
local vm = DC_Model.build(store, roleOf, expect, tab)
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
return DC_Model.reportLines(vm, label, R.TimeStr()), vm
end
-- ==================================================================
-- WoW wiring
-- ==================================================================
if CreateFrame then
local frame = nil -- DopingControlReportFrame (lazy)
local previewText = nil -- the preview FontString
local scrollChild = nil
local infoText = nil
local sendBtn = nil
local sendBlocker = nil -- invisible tooltip overlay while disabled
local lines = nil -- current preview lines (array)
local queue = nil -- active drain queue or nil
local function db()
return DopingControlDB
end
local function currentSource()
return DC.store and DC.store.source
end
local function currentTab()
if DC_Matrix then
if DC_Matrix.GetTab then
return DC_Matrix.GetTab()
end
if DC_Matrix.tab then
return DC_Matrix.tab
end
end
return DC.currentTab or "CONSUMABLES"
end
-- The matrix frame global name is not guaranteed; try the likely
-- handles, fall back safely.
local function anchorFrame()
if DC_Matrix and DC_Matrix.frame then
return DC_Matrix.frame
end
local m = getglobal("DopingControlFrame")
or getglobal("DopingControlMatrixFrame")
return m or UIParent
end
local function panelBackdrop()
if DC_Widgets and DC_Widgets.PANEL_BACKDROP then
return DC_Widgets.PANEL_BACKDROP
end
if DC.PANEL_BACKDROP then
return DC.PANEL_BACKDROP
end
return {
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
tile = true, tileSize = 128, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
end
-- Send button + blocker state machine: draining -> disabled "sending
-- k/n"; sim -> disabled with tooltip blocker; else enabled.
local function UpdateSendState()
if not sendBtn then
return
end
if queue and not queue.done and not queue.aborted then
sendBtn:Disable()
sendBtn:SetText(R.QueueLabel(queue))
sendBlocker:Hide()
elseif R.SendDisabled(currentSource()) then
sendBtn:Disable()
sendBtn:SetText("Send to raid chat")
sendBlocker:Show()
else
sendBtn:Enable()
sendBtn:SetText("Send to raid chat")
sendBlocker:Hide()
end
end
-- OnUpdate drainer (installed on the frame only while a queue exists;
-- no allocation on the idle path).
local function OnUpdateDrain()
if not queue then
this:SetScript("OnUpdate", nil)
return
end
local line = R.QueueTick(queue, GetTime(), currentSource())
if line then
SendChatMessage(R.ChatSafe(line), R.CHAT_TYPE)
UpdateSendState()
end
if queue.done or queue.aborted then
queue = nil
this:SetScript("OnUpdate", nil)
UpdateSendState()
end
end
local function OnSendClick()
if queue then
return
end
if not lines or table.getn(lines) == 0 then
return
end
if R.SendDisabled(currentSource()) then
return
end
queue = R.QueueCreate(lines)
frame:SetScript("OnUpdate", OnUpdateDrain)
UpdateSendState()
end
local function BuildReportFrame()
if frame then
return frame
end
local f = CreateFrame("Frame", "DopingControlReportFrame", UIParent)
f:SetWidth(460)
f:SetHeight(340)
f:SetFrameStrata("DIALOG")
f:SetPoint("CENTER", anchorFrame(), "CENTER", 0, 0)
f:SetBackdrop(panelBackdrop())
f:SetBackdropColor(1, 1, 1, 0.97)
f:SetMovable(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() this:StartMoving() end)
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
f:SetClampedToScreen(true)
-- title (gold)
local title = f:CreateFontString("DopingControlReportTitle",
"ARTWORK", "GameFontNormal")
title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -14)
local g = DC.COLORS and DC.COLORS.goldHi
if g then
title:SetTextColor(g.r, g.g, g.b)
else
title:SetTextColor(0.85, 0.66, 0.31)
end
title:SetText("Report preview")
-- info line (line count + pacing note)
infoText = f:CreateFontString("DopingControlReportInfo",
"ARTWORK", "GameFontNormalSmall")
infoText:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -30)
infoText:SetTextColor(0.6, 0.6, 0.6)
infoText:SetText("")
-- close X
local closeX = CreateFrame("Button", "DopingControlReportCloseX",
f, "UIPanelCloseButton")
closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
-- scrollable preview: shows reportLines output EXACTLY as sent
local scroll = CreateFrame("ScrollFrame", "DopingControlReportScroll",
f, "UIPanelScrollFrameTemplate")
scroll:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -48)
scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -36, 44)
scrollChild = CreateFrame("Frame", "DopingControlReportScrollChild",
scroll)
scrollChild:SetWidth(400)
scrollChild:SetHeight(1)
scroll:SetScrollChild(scrollChild)
previewText = scrollChild:CreateFontString("DopingControlReportText",
"ARTWORK", "GameFontHighlightSmall")
previewText:SetPoint("TOPLEFT", scrollChild, "TOPLEFT", 0, 0)
previewText:SetWidth(390)
previewText:SetJustifyH("LEFT")
previewText:SetJustifyV("TOP")
-- mono-ish: 1.12 ships no true monospace; the condensed number
-- font keeps the indented name lists readable
previewText:SetFont("Fonts\\ARIALN.TTF", 12)
-- buttons
sendBtn = CreateFrame("Button", "DopingControlReportSendButton",
f, "UIPanelButtonTemplate")
sendBtn:SetWidth(150)
sendBtn:SetHeight(22)
sendBtn:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 14)
sendBtn:SetText("Send to raid chat")
sendBtn:SetScript("OnClick", OnSendClick)
local closeBtn = CreateFrame("Button", "DopingControlReportCloseButton",
f, "UIPanelButtonTemplate")
closeBtn:SetWidth(80)
closeBtn:SetHeight(22)
closeBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -16, 14)
closeBtn:SetText("Close")
closeBtn:SetScript("OnClick", function() f:Hide() end)
if pfUI and pfUI.api then
if pfUI.api.SkinButton then
pfUI.api.SkinButton(sendBtn)
pfUI.api.SkinButton(closeBtn)
end
if pfUI.api.SkinCloseButton then
pfUI.api.SkinCloseButton(closeX)
end
end
-- invisible overlay for the disabled-in-test-mode tooltip
-- (disabled buttons receive no mouse events on 1.12)
sendBlocker = CreateFrame("Frame", "DopingControlReportSendBlocker", f)
sendBlocker:SetAllPoints(sendBtn)
sendBlocker:SetFrameLevel(sendBtn:GetFrameLevel() + 2)
sendBlocker:EnableMouse(true)
sendBlocker:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText("disabled in test mode", 1, 1, 1)
GameTooltip:AddLine("Simulated names never reach real chat.", 0.8, 0.8, 0.8)
GameTooltip:AddLine("The preview stays fully usable.", 0.8, 0.8, 0.8)
GameTooltip:Show()
end)
sendBlocker:SetScript("OnLeave", function() GameTooltip:Hide() end)
sendBlocker:Hide()
-- closing cancels a running drain (see header)
f:SetScript("OnHide", function()
if queue then
queue = nil
this:SetScript("OnUpdate", nil)
end
end)
-- ESC closes
table.insert(UISpecialFrames, "DopingControlReportFrame")
f:Hide()
frame = f
return f
end
-- Rebuild the preview content. vm/tab optional (see header).
function R.Refresh(vm, tab)
BuildReportFrame()
tab = tab or currentTab()
if vm then
local label = (DC.TAB_LABEL and DC.TAB_LABEL[tab]) or tab
lines = DC_Model.reportLines(vm, label, R.TimeStr())
else
lines = R.BuildLines(DC.store, db(), tab)
end
local n = table.getn(lines)
previewText:SetText(table.concat(lines, "\n"))
scrollChild:SetHeight(n * 14 + 24)
infoText:SetText(string.format(
"%d line%s - sent one per %.1f s", n, (n == 1) and "" or "s",
R.SEND_INTERVAL))
UpdateSendState()
end
-- Public entry point (matrix "Report..." button, /dc report).
function R.Open(vm, tab)
local f = BuildReportFrame()
R.Refresh(vm, tab)
f:Show()
end
function R.Toggle(vm, tab)
local f = BuildReportFrame()
if f:IsShown() then
f:Hide()
else
R.Open(vm, tab)
end
end
end
+463
View File
@@ -0,0 +1,463 @@
-- DopingControl ui/widgets.lua
-- Shared UI building blocks: panel backdrop, pooled-widget factories
-- (cell / pill / badge / button), tooltip helpers, pfUI-aware font helper,
-- change-guarded setters (only touch a region when the value changed).
--
-- Factory + pooling patterns follow common 1.12 addon practice
-- (pfUI-style WHITE8X8 backdrops, pooled regions, save-and-call hooks).
--
-- Pure Lua 5.0 dofile-loadable: top level only defines tables/functions;
-- CreateFrame & friends are only referenced INSIDE factory functions, which
-- are never called offline.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Widgets = DC_Widgets or {}
local W = DC_Widgets
-- ------------------------------------------------------------------
-- Texture / font constants
-- ------------------------------------------------------------------
W.SOLID_TEX = "Interface\\Buttons\\WHITE8X8" -- 1px-border backdrop base
W.FONT = "Fonts\\FRIZQT__.TTF" -- default client font
W.CHECK_TEX = "Interface\\Buttons\\UI-CheckBox-Check" -- HAS glyph (tinted green)
W.CROSS_TEX = "Interface\\Buttons\\UI-GroupLoot-Pass-Up" -- MISSING glyph (tinted red)
-- Main window backdrop (textures shipped in DopingControl\textures\).
W.PANEL_BACKDROP = {
bgFile = "Interface\\AddOns\\DopingControl\\textures\\panel_bg",
edgeFile = "Interface\\AddOns\\DopingControl\\textures\\panel_border",
tile = true, tileSize = 128, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
-- Solid 1px-border backdrop for cells/pills/badges/buttons (pfUI-style
-- WHITE8X8 recipe; bg + border colored via SetBackdropColor /
-- SetBackdropBorderColor).
W.SOLID_BACKDROP = {
bgFile = W.SOLID_TEX,
edgeFile = W.SOLID_TEX,
tile = false, edgeSize = 1,
insets = { left = 1, right = 1, top = 1, bottom = 1 },
}
W.WHITE = { r = 1, g = 1, b = 1 }
-- ------------------------------------------------------------------
-- Cell-box fallback palette: variant name -> (bg, border) color reference,
-- used ONLY when ui/cellsheet.lua's baked sheet is unavailable (see
-- W.HasCellSheet below). `border` is a DC.COLORS key, or a number that
-- indexes DC.QUALITY -- same convention as DC_Cells.VARIANTS in
-- ui/cellsheet.lua, which this table intentionally mirrors: colors are
-- pulled LIVE from DC.COLORS/DC.QUALITY (core/const.lua, already loaded --
-- ui/cellsheet.lua only bakes that same palette into a texture, it does
-- not own it), so nothing here duplicates a color VALUE. The pairing
-- itself (which key goes with which variant) can't be derived
-- mechanically -- it is the one piece of information that has to exist
-- twice given the failure mode this guards (ui/cellsheet.lua not loaded
-- at all). tools/luatests/test_widgets.lua cross-checks every entry
-- against the real DC_Cells.VARIANTS so the two cannot silently drift.
W.CELL_FALLBACK_VARIANTS = {
HAS = { bg = "hatBg", border = "hatBorder" },
MISSING = { bg = "fehltBg", border = "fehltBorder" },
UNKNOWN = { bg = "unbekBg", border = "unbekBorder" },
SKIP = { bg = "skip", border = "skipBorder" },
NEUTRAL = { bg = "theadBg", border = "line" },
ITEM_HAS = { bg = "theadBg", border = "hatBorder" },
ITEM_MISSING = { bg = "theadBg", border = "fehltBorder" },
Q_RARE = { bg = "theadBg", border = "rareBorder" },
Q_EPIC = { bg = "theadBg", border = "epicBorder" },
Q_0 = { bg = "theadBg", border = 0 },
Q_1 = { bg = "theadBg", border = 1 },
Q_2 = { bg = "theadBg", border = 2 },
Q_5 = { bg = "theadBg", border = 5 },
Q_6 = { bg = "theadBg", border = 6 },
}
-- ------------------------------------------------------------------
-- DC_Cells fallback shim -- covers a mid-session /reload where THIS file
-- (ui/widgets.lua, an EXISTING file whose new content /reload happily
-- re-executes) deploys, but ui/cellsheet.lua (a NEW file added in the same
-- change) does not: 1.12's /reload re-runs existing files but never loads
-- a file that was not already part of the running session (verified
-- in-game). Left alone, DC_Cells stays nil, and it is not only
-- W.MakeCell/W.SetCellBox below that touch it -- ui/matrix.lua calls
-- DC_Cells.VariantFor(...) directly at two paint sites (M.PaintItemTile
-- and the legacy trinket path, ui/matrix.lua ~2182/2381 -- out of scope
-- for this change) and would error on the very first painted item/trinket
-- cell regardless of anything done here.
--
-- The shim supplies ONLY the pure classification function -- state/kind/
-- quality -> variant NAME string -- no sheet, no texture, no coordinate
-- math, because none of that can work without ui/cellsheet.lua's baked
-- sheet file. It is a verbatim copy of DC_Cells.VariantFor's logic (see
-- ui/cellsheet.lua); test_widgets.lua cross-checks the two stay in sync.
-- Installs only when DC_Cells does not already exist, so the normal
-- (full-restart) load path -- where ui/cellsheet.lua runs before this file
-- per DopingControl.toc and sets the real DC_Cells -- is untouched.
if not DC_Cells then
DC_Cells = {
VariantFor = function(state, kind, quality)
if kind == "ench" or kind == "trinket" then
if quality == 3 then return "Q_RARE" end
if quality == 4 then return "Q_EPIC" end
if quality ~= nil then return "Q_" .. quality end
if kind == "trinket" then
return "Q_1"
end
if state == "HAS" then return "ITEM_HAS" end
if state == "MISSING" then return "ITEM_MISSING" end
return "NEUTRAL"
end
if state == "HAS" then return "HAS" end
if state == "MISSING" then return "MISSING" end
if state == "UNKNOWN" then return "UNKNOWN" end
return "SKIP"
end,
}
end
-- Whether ui/cellsheet.lua's baked sheet is actually available (as
-- opposed to the shim above, which never sets .SHEET). One seam shared by
-- W.MakeCell and W.SetCellBox, and directly testable offline without
-- CreateFrame (unlike W.MakeCell itself).
function W.HasCellSheet()
return DC_Cells ~= nil and DC_Cells.SHEET ~= nil
end
-- ------------------------------------------------------------------
-- Font helper (we keep OUR pixel
-- size -- the matrix layout depends on it -- and only take pfUI's font
-- FAMILY when pfUI is present; forcing pfUI's global font size would break
-- the 30x23 cell grid).
-- ------------------------------------------------------------------
function W.ApplyFont(fs, size)
size = size or 10
if pfUI and pfUI.font_default then
fs:SetFont(pfUI.font_default, size)
else
fs:SetFont(W.FONT, size)
end
end
-- ------------------------------------------------------------------
-- Change-guarded setters (convention: cache last-applied value on the
-- region as dcLast*; only call the real setter when the value changed.
-- Color guards compare by TABLE REFERENCE -- callers always pass the
-- shared DC.COLORS / DC.QUALITY tables, so this is exact and zero-alloc).
-- ------------------------------------------------------------------
function W.SetTextG(fs, text)
if fs.dcLastText ~= text then
fs.dcLastText = text
fs:SetText(text)
end
end
function W.SetTextColorG(fs, c)
if fs.dcLastColor ~= c then
fs.dcLastColor = c
fs:SetTextColor(c.r, c.g, c.b)
end
end
function W.SetBackdropG(f, bg, border)
if f.dcLastBg ~= bg then
f.dcLastBg = bg
f:SetBackdropColor(bg.r, bg.g, bg.b, bg.a or 1)
end
if f.dcLastBorder ~= border then
f.dcLastBorder = border
f:SetBackdropBorderColor(border.r, border.g, border.b, 1)
end
end
function W.SetTextureG(tex, path)
if tex.dcLastTex ~= path then
tex.dcLastTex = path
tex:SetTexture(path)
end
end
function W.SetVertexG(tex, c)
if tex.dcLastVertex ~= c then
tex.dcLastVertex = c
tex:SetVertexColor(c.r, c.g, c.b)
end
end
function W.SetAlphaG(region, a)
if region.dcLastAlpha ~= a then
region.dcLastAlpha = a
region:SetAlpha(a)
end
end
-- ------------------------------------------------------------------
-- FontString factory
-- ------------------------------------------------------------------
function W.MakeText(parent, size, color, layer)
local fs = parent:CreateFontString(nil, layer or "OVERLAY")
W.ApplyFont(fs, size)
if color then
W.SetTextColorG(fs, color)
end
return fs
end
-- ------------------------------------------------------------------
-- Cell factory: 30x23 state cell (the 30x23 size is part of the fixed
-- visual layout). The box (background + 1px border) is one texture region
-- sampled from the baked sheet in ui/cellsheet.lua -- SetBackdrop with an
-- edgeFile costs NINE texture regions per cell, this costs one. The icon
-- (check/cross glyph OR trinket item icon) and FontString (?, skip dot,
-- trinket monogram) are created on demand via W.CellIcon / W.CellText;
-- the matrix paint code shows exactly one of them per state.
-- Pool children stay anonymous (documented factory exception).
-- ------------------------------------------------------------------
function W.MakeCell(parent, name, w, h)
local c = CreateFrame("Frame", name, parent)
c:SetWidth(w or 30)
c:SetHeight(h or 23)
c:EnableMouse(true)
c.dcW = w or 30
if W.HasCellSheet() then
-- The state box (background + 1px border) comes from one baked sheet:
-- SetBackdrop with an edgeFile costs NINE texture regions per cell,
-- this costs one. See ui/cellsheet.lua.
c.boxTex = c:CreateTexture(nil, "BACKGROUND")
c.boxTex:SetAllPoints(c)
c.boxTex:SetTexture(DC_Cells.SHEET.path)
-- Without an initial texcoord the region would show the WHOLE sheet
-- stretched across the cell until the first paint. Start on NEUTRAL.
local l, r, t, b = DC_Cells.Coords("NEUTRAL", c.dcW)
if l then
c.boxTex:SetTexCoord(l, r, t, b)
c.dcBoxVariant = "NEUTRAL"
c.dcBoxW = c.dcW
end
else
-- Fallback: ui/cellsheet.lua did not load this session (see
-- W.HasCellSheet above) -- pre-refactor SetBackdrop path (no
-- boxTex region at all) so the matrix still draws instead of
-- erroring on a nil DC_Cells.SHEET. W.SetBackdropG and
-- W.SOLID_BACKDROP are untouched by the sprite refactor, so this
-- is exactly what W.MakeCell did before it.
c:SetBackdrop(W.SOLID_BACKDROP)
W.SetCellBox(c, "NEUTRAL")
end
return c
end
-- Icon and FontString on demand: a cell shows one or the other, and in 1.12
-- eagerly created regions cost even while hidden.
function W.CellIcon(cell)
if not cell.icon then
cell.icon = cell:CreateTexture(nil, "ARTWORK")
cell.icon:SetPoint("CENTER", cell, "CENTER", 0, 0)
cell.icon:SetWidth(16)
cell.icon:SetHeight(16)
cell.icon:Hide()
end
return cell.icon
end
function W.CellText(cell)
if not cell.text then
cell.text = cell:CreateFontString(nil, "OVERLAY")
W.ApplyFont(cell.text, 9)
cell.text:SetPoint("CENTER", cell, "CENTER", 0, 0)
end
return cell.text
end
-- Guards on variant AND width (cell.dcBoxW, the width the box was last
-- painted at) -- NOT variant alone. ui/matrix.lua resizes a pooled cell in
-- place when its column width changes (trinket columns are 34, normal
-- cells 30: see ui/matrix.lua's column-layout path around SetWidth(col.w))
-- and only ever writes cell.dcW; it has no notion of dcBoxVariant and isn't
-- expected to invalidate it. If this guard only checked variant, a cell
-- that kept its variant across such a resize would keep a stale texcoord
-- sized for the OLD width -- the baked border would sit off the new cell
-- edge. Tracking dcBoxW here keeps the guard self-contained: correctness
-- does not depend on any caller convention.
function W.SetCellBox(cell, variant)
if not W.HasCellSheet() then
-- Fallback: same pre-refactor SetBackdrop recipe, colors pulled
-- LIVE from DC.COLORS/DC.QUALITY via W.CELL_FALLBACK_VARIANTS
-- above. Never touches cell.boxTex -- a fallback cell (see
-- W.MakeCell) does not have one. W.SetBackdropG already
-- change-guards by table reference, so no separate
-- dcBoxVariant/dcBoxW bookkeeping is needed here.
local v = W.CELL_FALLBACK_VARIANTS[variant]
if not v then
return
end
local bg = DC.COLORS[v.bg]
local border = v.border
if type(border) == "number" then
border = DC.QUALITY[border]
else
border = DC.COLORS[border]
end
if not bg or not border then
return
end
W.SetBackdropG(cell, bg, border)
return
end
if cell.dcBoxVariant == variant and cell.dcBoxW == cell.dcW then
return
end
local l, r, t, b = DC_Cells.Coords(variant, cell.dcW)
if not l then
return
end
cell.dcBoxVariant = variant
cell.dcBoxW = cell.dcW
cell.boxTex:SetTexCoord(l, r, t, b)
end
-- ------------------------------------------------------------------
-- Pill factory (ready pill, coverage pill): bordered mini frame with a
-- centered FontString; width is adjusted by the caller to fit the text.
-- ------------------------------------------------------------------
function W.MakePill(parent, name)
local p = CreateFrame("Frame", name, parent)
p:SetWidth(70)
p:SetHeight(16)
p:SetBackdrop(W.SOLID_BACKDROP)
p:EnableMouse(true)
p.text = p:CreateFontString(nil, "OVERLAY")
W.ApplyFont(p.text, 9)
p.text:SetPoint("CENTER", p, "CENTER", 0, 0)
return p
end
-- ------------------------------------------------------------------
-- Role badge factory: clickable bordered button with centered label.
-- Suggested roles render with a "?" suffix + dimmer border (matrix paints
-- that -- 1.12 has no dashed borders, see the adaptation note there).
-- ------------------------------------------------------------------
function W.MakeBadge(parent, name)
local b = CreateFrame("Button", name, parent)
b:SetWidth(56)
b:SetHeight(16)
b:SetBackdrop(W.SOLID_BACKDROP)
b.text = b:CreateFontString(nil, "OVERLAY")
W.ApplyFont(b.text, 8)
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
b:RegisterForClicks("LeftButtonUp")
return b
end
-- ------------------------------------------------------------------
-- Dark gold-trim button (titlebar Scan/Report/Options/X).
-- ------------------------------------------------------------------
function W.MakeButton(parent, name, label, width, height)
local C = DC.COLORS
local b = CreateFrame("Button", name, parent)
b:SetWidth(width or 64)
b:SetHeight(height or 19)
b:SetBackdrop(W.SOLID_BACKDROP)
W.SetBackdropG(b, C.panel2, C.goldSoft)
b.text = b:CreateFontString(nil, "OVERLAY")
W.ApplyFont(b.text, 10)
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
W.SetTextColorG(b.text, C.goldHi)
b.text:SetText(label)
b:SetScript("OnEnter", function()
W.SetBackdropG(this, C.panel3, C.gold)
end)
b:SetScript("OnLeave", function()
W.SetBackdropG(this, C.panel2, C.goldSoft)
end)
return b
end
-- ------------------------------------------------------------------
-- Tooltip helpers. Convention: the builder only ADDS
-- lines; AttachTooltip calls SetOwner before and Show after -- AddLine
-- never calls Show itself.
-- AttachTooltip CHAINS any existing OnEnter/OnLeave (no HookScript on
-- 1.12 -- standard save-and-call pattern).
-- ------------------------------------------------------------------
function W.TipLine(text, c, wrap)
if not GameTooltip then
return
end
local r, g, b = 1, 1, 1
if c then
r, g, b = c.r, c.g, c.b
end
if wrap then
GameTooltip:AddLine(text, r, g, b, 1)
else
GameTooltip:AddLine(text, r, g, b)
end
end
-- Two-column tooltip line: label left, value flush RIGHT. This is what
-- makes a breakdown read like an addition on paper -- the values line up
-- in a column instead of trailing their labels at ragged positions. 1.12
-- has no monospace font and no tab stops; AddDoubleLine is the only way to
-- get a right-aligned column, and it exists in this client's FrameXML.
-- `cr` defaults to the label color when omitted.
function W.TipDouble(left, right, c, cr)
if not GameTooltip then
return
end
local r, g, b = 1, 1, 1
if c then
r, g, b = c.r, c.g, c.b
end
local r2, g2, b2 = r, g, b
if cr then
r2, g2, b2 = cr.r, cr.g, cr.b
end
GameTooltip:AddDoubleLine(left, right, r, g, b, r2, g2, b2)
end
-- W.dcTipOwner: our own record of which of OUR frames currently owns
-- GameTooltip. WoW 1.12's GameTooltip has no "GetOwner" method (confirmed
-- against FrameXML-1.12.1: zero real hits -- the only "GetOwner" match
-- anywhere in that tree is the unrelated global function
-- GetOwnerAuctionItems). Every caller in this addon that ever needs "who
-- currently owns the tooltip" goes through W.AttachTooltip to open one in
-- the first place, so this is the single place that needs to record it.
-- Set right after SetOwner
-- (matching order: SetOwner, then the builder runs, then Show -- a builder
-- that itself re-SetOwner()s the SAME frame, like M.CellTooltip's
-- ANCHOR_LEFT override, does not change who owns it). Cleared in OnLeave,
-- guarded on identity so a late/out-of-order OnLeave can never clobber a
-- newer owner.
W.dcTipOwner = nil
function W.AttachTooltip(frame, builder, anchor)
frame.dcTipBuilder = builder
frame.dcTipAnchor = anchor or "ANCHOR_LEFT"
frame.dcOldEnter = frame:GetScript("OnEnter")
frame.dcOldLeave = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
if this.dcOldEnter then
this.dcOldEnter()
end
if GameTooltip and this.dcTipBuilder then
GameTooltip:SetOwner(this, this.dcTipAnchor)
W.dcTipOwner = this
this.dcTipBuilder(this)
GameTooltip:Show()
end
end)
frame:SetScript("OnLeave", function()
if this.dcOldLeave then
this.dcOldLeave()
end
if W.dcTipOwner == this then
W.dcTipOwner = nil
end
if GameTooltip then
GameTooltip:Hide()
end
end)
end