From 695dd9dcd419355215aef941ad8c2b55367f01fd Mon Sep 17 00:00:00 2001 From: ShempError <689+shemperror@noreply.octowow.st> Date: Sun, 30 Aug 2026 15:37:17 +0200 Subject: [PATCH] DopingControl v0.6.4 --- CHANGELOG.md | 109 ++++++ DopingControl.toc | 8 +- README.md | 12 + core/config.lua | 38 ++ core/demo.lua | 154 +++++++++ core/init.lua | 39 ++- core/model.lua | 16 + core/sim.lua | 250 ++++++++++--- data/classbuffs.lua | 34 ++ data/consumables.lua | 397 ++++++++++++++++----- data/enchants.lua | 30 +- data/expectations.lua | 95 +++-- scan/aura.lua | 49 ++- scan/engine.lua | 28 +- scan/gear.lua | 4 +- scan/hit.lua | 13 +- scan/talents.lua | 68 +++- textures/cells.tga | Bin 0 -> 262162 bytes ui/cellsheet.lua | 118 +++++++ ui/matrix.lua | 789 ++++++++++++++++++++++++++++++++++++++---- ui/options.lua | 78 ++++- ui/report.lua | 31 +- ui/widgets.lua | 214 +++++++++++- 23 files changed, 2278 insertions(+), 296 deletions(-) create mode 100644 core/demo.lua create mode 100644 textures/cells.tga create mode 100644 ui/cellsheet.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 0591240..031d45a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,115 @@ 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. +## Unreleased + +## 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 diff --git a/DopingControl.toc b/DopingControl.toc index 380c04e..3a0f5e1 100644 --- a/DopingControl.toc +++ b/DopingControl.toc @@ -1,7 +1,8 @@ ## Interface: 11200 ## Title: DopingControl ## Notes: Raid consumables, buffs, debuffs, resistances, hit and enchant checker -## Version: 0.6.1 +## Author: ShempError +## Version: 0.6.4 ## SavedVariables: DopingControlDB core\const.lua @@ -16,6 +17,7 @@ data\resist.lua core\model.lua core\roles.lua core\sim.lua +core\demo.lua scan\talents.lua scan\reach.lua scan\aura.lua @@ -24,9 +26,13 @@ scan\resist.lua scan\hit.lua scan\engine.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 + +# DEV ONLY -- remove this line and dev\ when cutting a release orphan commit. +dev\raiddump.lua diff --git a/README.md b/README.md index 3c6095e..bf6f9a2 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,13 @@ same gaps, every time. The full UI (all six tabs, roles, pills, report preview) 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):** @@ -213,6 +220,7 @@ git clone https://github.com/ShempError/DopingControl.git DopingControl | `/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 ]` | 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 | @@ -239,6 +247,10 @@ either way. See [CHANGELOG.md](CHANGELOG.md). The short form: +- **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 diff --git a/core/config.lua b/core/config.lua index 70ba276..5cb9c1d 100644 --- a/core/config.lua +++ b/core/config.lua @@ -47,6 +47,44 @@ DC.DEFAULTS = { 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) diff --git a/core/demo.lua b/core/demo.lua new file mode 100644 index 0000000..1edcef5 --- /dev/null +++ b/core/demo.lua @@ -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 diff --git a/core/init.lua b/core/init.lua index 79dfd37..d8d5aba 100644 --- a/core/init.lua +++ b/core/init.lua @@ -59,6 +59,14 @@ function DC.SetTestMode(v) -- 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 @@ -108,7 +116,7 @@ end -- ------------------------------------------------------------------ -- Slash commands: /dc, /doping -- ------------------------------------------------------------------ -local HELP_TEXT = "commands: /dc (window) | scan | options | report | test | probe [range ] | unknown | talents [on|off|clear]" +local HELP_TEXT = "commands: /dc (window) | scan | options | report | test | demo | probe [range ] | unknown | talents [on|off|clear]" function DC.HandleSlash(msg) msg = msg or "" @@ -119,9 +127,15 @@ function DC.HandleSlash(msg) 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 - -- real data never beats the simulation while test mode is on 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 @@ -133,9 +147,28 @@ function DC.HandleSlash(msg) 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), @@ -222,7 +255,7 @@ if CreateFrame then elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then local db = DC.db - if db and not db.testMode and MatrixIsShown() then + if db and not DC.SimOwnsStore(db) and MatrixIsShown() then DC_Scan.Start() end diff --git a/core/model.lua b/core/model.lua index 40d4134..e60368e 100644 --- a/core/model.lua +++ b/core/model.lua @@ -850,6 +850,22 @@ function M.classify(player, slotdef, expected, books) for name in pairs(names) do if nameMapsToSlot(name, slotdef.id) then if not (haveIds and nameHasIds[name]) then + -- Consumable/class-buff name collision (decision + -- 2026-08-15, data/classbuffs.lua + -- DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION): a + -- name-only match (this branch only runs when no + -- spell ID resolved the buff) can never satisfy a + -- CONSUMABLE slot when the name is also a class buff + -- name -- UNKNOWN, not HAS. One-sided: the class-buff + -- slot side of the same name is unaffected (see the + -- constant's comment for why). + local cdef = DC.CONSUMABLES and DC.CONSUMABLES[name] + local collide = DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION + if cdef and cdef.slot == slotdef.id + and collide and collide[name] then + return S.UNKNOWN, + "name-only, ambiguous with class buff: " .. name + end local icon = nil if textures then icon = textures[name] diff --git a/core/sim.lua b/core/sim.lua index 085c8d0..2f27e2b 100644 --- a/core/sim.lua +++ b/core/sim.lua @@ -35,6 +35,27 @@ -- (built programmatically below, so id-matched and name-matched -- players are both covered), and every gear entry TABLE carries -- texture + name + quality -- test mode showcases the icon feature. +-- * SCHOOL PROTECTION carriers, one per school, ALL by spell ID: +-- Simtank SR (17548), Simsteel FrR (17544), Simsham NR (17546), +-- Simheala HR (7245), Simmage AR (17549) -- plus the seeded FR +-- carriers. Their columns stay hidden under the shipped defaults; demo +-- mode (core/demo.lua) switches all six on at once and these five are +-- what makes that view show HAS cells rather than an empty block. Held +-- by ID on purpose: "Shadow Protection" is ALSO the priest class buff +-- (data/consumables.lua:312), so a name-based carrier would fake an SR +-- fulfillment the moment the column is visible. +-- * v6 SLOT MODEL (17 active default columns of the 22-slot table -- +-- the five school protection slots FrR/NR/SR/HR/AR default off and +-- stay hidden) + identification-ladder showcase: the +-- four TANKs run the full KG tank set (green pill across all 17 +-- columns); every new column (BL/ZANZA/GAE/SCHOOL/DREAMT/SHARD/ALC) +-- has HAS, MISSING and NOTEXP cells somewhere in the roster. Generic +-- "Well Fed" carriers split three ways for the matrix ladder: +-- effect line resolving to an ICON-carrying item (Simwall Dumplings, +-- Simrogue Squid, Simmage Danonzo's Delight), effect line resolving +-- to an item WITHOUT a sourced icon (Simheala Herbal Salad -> +-- slot-generic icon, tooltip names the item), and no effects table +-- at all (Simsteel and others -> "item unknown" fallback). -- * DEBUFFS tab: exactly 3 afflicted players -- -- Simrogue + Simtree share "Resurrection Sickness" (afflicted=2 => -- first column, count sorting testable), Simfrost carries "Curse of @@ -191,13 +212,29 @@ end -- one shared list is safe to reuse across every role and class. -- cleanAuras() returns a FRESH table on every call (set() already builds -- a new table each time); the source list itself is read-only. +-- +-- SPROT is filled by "Prayer of Shadow Protection" and NEVER by the bare +-- "Shadow Protection": that bare name belongs to BOTH the priest buff +-- (data/classbuffs.lua SPROT) and the lesser SR potion aura +-- (data/consumables.lua:312, spell 7242). These players carry no aura IDs +-- at all (ids = {}), so the model's ID-before-name rule cannot fire +-- (core/model.lua: haveIds == false) and the bare name would satisfy the +-- SR POTION column as well. Live that stays invisible because SR ships +-- default-off -- but demo mode (core/demo.lua) switches SR on for +-- everyone, which is exactly where the false HAS would show up. The +-- Prayer variant maps to SPROT only (classbuffs.lua:51, ids {27683}) and +-- is the raid-wide form anyway, so the class-buff showcase is unchanged +-- while the SR column stays honest. -- ------------------------------------------------------------------ local CLEAN_AURA_NAMES = { "Flask of the Titans", "Well Fed", "Juju Might", "Juju Power", - "Elixir of the Mongoose", "Greater Arcane Elixir", "Mageblood Potion", - "Elixir of Superior Defense", "Elixir of Fortitude", + "Elixir of the Mongoose", "R.O.I.D.S.", "Spirit of Zanza", + "Greater Arcane Elixir", "Elixir of Shadow Power", "Dreamtonic", + "Dreamshard Elixir", "Mageblood Potion", + "Elixir of Superior Defense", "Elixir of Fortitude", "Medivh's Merlot", "Greater Fire Protection", "Mark of the Wild", "Power Word: Fortitude", - "Shadow Protection", "Arcane Intellect", "Soulstone Resurrection", + "Prayer of Shadow Protection", "Arcane Intellect", + "Soulstone Resurrection", } local function cleanAuras() @@ -225,31 +262,59 @@ local ICONS = { ["Juju Power"] = "Interface\\Icons\\INV_Misc_MonsterScales_11", ["Elixir of Giants"] = "Interface\\Icons\\INV_Potion_61", ["Elixir of the Giants"] = "Interface\\Icons\\INV_Potion_61", - ["Strike of the Scorpok"] = "Interface\\Icons\\INV_Misc_MonsterScales_09", - ["Rage of Ages"] = "Interface\\Icons\\INV_Scroll_03", -- AGI ["Elixir of the Mongoose"] = "Interface\\Icons\\INV_Potion_32", ["Elixir of Greater Agility"] = "Interface\\Icons\\INV_Potion_94", ["Greater Agility"] = "Interface\\Icons\\INV_Potion_94", - -- SP + -- BL (aura names + item-alias keys, like the data table) + ["Rage of Ages"] = "Interface\\Icons\\INV_Stone_15", + ["R.O.I.D.S."] = "Interface\\Icons\\INV_Stone_15", + ["Strike of the Scorpok"] = "Interface\\Icons\\INV_Misc_Dust_02", + ["Ground Scorpok Assay"] = "Interface\\Icons\\INV_Misc_Dust_02", + ["Spirit of Boar"] = "Interface\\Icons\\INV_Drink_12", + ["Lung Juice Cocktail"] = "Interface\\Icons\\INV_Drink_12", + ["Infallible Mind"] = "Interface\\Icons\\INV_Potion_32", + ["Spiritual Domination"] = "Interface\\Icons\\INV_Misc_Food_30", + ["Gizzard Gum"] = "Interface\\Icons\\INV_Misc_Food_30", + -- ZANZA + ["Spirit of Zanza"] = "Interface\\Icons\\INV_Potion_30", + -- GAE / SCHOOL / DREAMT / SHARD (split out of the old SP column) ["Greater Arcane Elixir"] = "Interface\\Icons\\INV_Potion_25", ["Arcane Elixir"] = "Interface\\Icons\\INV_Potion_30", ["Elixir of Shadow Power"] = "Interface\\Icons\\INV_Potion_46", + ["Elixir of Frost Power"] = "Interface\\Icons\\INV_Potion_03", ["Greater Frost Power"] = "Interface\\Icons\\INV_Potion_13", + ["Dreamtonic"] = "Interface\\Icons\\INV_Potion_10", + ["Dreamshard Elixir"] = "Interface\\Icons\\INV_Potion_12", -- MP5 (both TWoW name variants share the item icon) ["Mageblood Potion"] = "Interface\\Icons\\INV_Potion_45", ["Mageblood"] = "Interface\\Icons\\INV_Potion_45", -- ARM ["Elixir of Superior Defense"] = "Interface\\Icons\\INV_Potion_66", ["Elixir of Greater Defense"] = "Interface\\Icons\\INV_Potion_43", - -- HP + -- HPELX ["Elixir of Fortitude"] = "Interface\\Icons\\INV_Potion_44", ["Health II"] = "Interface\\Icons\\INV_Potion_44", + -- ALC (alcohol column, split out of the old HP column) ["Rumsey Rum Black Label"] = "Interface\\Icons\\INV_Drink_04", ["Rumsey Rum"] = "Interface\\Icons\\INV_Drink_08", + ["Medivh's Merlot"] = "Interface\\Icons\\INV_Drink_Waterskin_05", + ["Medivh's Merlot Blue"] = "Interface\\Icons\\INV_Drink_Waterskin_01", + ["Medivh's Merlot Blue Label"] = "Interface\\Icons\\INV_Drink_Waterskin_01", + ["Kreeg's Stout Beatdown"] = "Interface\\Icons\\INV_Drink_05", -- FR ["Greater Fire Protection"] = "Interface\\Icons\\INV_Potion_24", ["Fire Protection"] = "Interface\\Icons\\INV_Potion_16", + -- FrR/NR/SR/HR/AR (school protection showcase -- these columns are + -- hidden until their expectation is enabled per role/class, and demo + -- mode switches all six on at once; the carriers hold their potion by + -- SPELL ID, never by name -- "Shadow Protection" is also the priest + -- class buff, so a name-based carrier would fake an SR fulfillment) + ["Greater Shadow Protection"] = "Interface\\Icons\\INV_Potion_23", + ["Greater Frost Protection"] = "Interface\\Icons\\INV_Potion_20", + ["Greater Nature Protection"] = "Interface\\Icons\\INV_Potion_22", + ["Holy Protection"] = "Interface\\Icons\\INV_Potion_09", + ["Greater Arcane Protection"] = "Interface\\Icons\\INV_Potion_83", -- class buffs ["Arcane Intellect"] = "Interface\\Icons\\Spell_Holy_MagicalSentry", ["Arcane Brilliance"] = "Interface\\Icons\\Spell_Holy_ArcaneIntellect", @@ -257,7 +322,17 @@ local ICONS = { ["Gift of the Wild"] = "Interface\\Icons\\Spell_Nature_GiftoftheWild", ["Power Word: Fortitude"] = "Interface\\Icons\\Spell_Holy_WordFortitude", ["Prayer of Fortitude"] = "Interface\\Icons\\Spell_Holy_PrayerOfFortitude", + -- the bare name stays: ID-bearing SPROT carriers resolve through + -- idToName (976/10957/10958 -> "Shadow Protection") and texturesFor + -- looks the icon up under THAT name. Name-only players use the Prayer + -- variant instead (see CLEAN_AURA_NAMES). + -- both spellings share the vanilla Shadow Protection art (the same + -- alias convention the AP/STR/MP5 entries above use). Deliberately NOT + -- a "Spell_Holy_PrayerofShadowProtection" path: the Prayer rank is not + -- a stock 1.12 spell, so that texture cannot be assumed to exist in + -- the client, and a missing path renders an empty icon. ["Shadow Protection"] = "Interface\\Icons\\Spell_Shadow_AntiShadow", + ["Prayer of Shadow Protection"] = "Interface\\Icons\\Spell_Shadow_AntiShadow", ["Soulstone Resurrection"] = "Interface\\Icons\\Spell_Shadow_SoulGem", } @@ -335,9 +410,10 @@ local HIT_MAIN_SUB = { DRUID = "Staves", } --- All 16 equipment slots the scan reads (11 enchant + neck + 2 rings + --- 2 trinket invSlots). -local INV_SLOTS = { 1, 2, 3, 15, 5, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 } +-- All 17 equipment slots the scan reads (12 armor enchant, WAIST +-- included -- 2026-08-30 owner decision, WAIST is worn equipment like +-- any other slot now -- + neck + 2 rings + 2 trinket invSlots). +local INV_SLOTS = { 1, 2, 3, 15, 5, 6, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 } -- Per-slot base item cosmetics: EVERY gear entry table carries texture, -- name and quality (the UI renders item icons). @@ -347,6 +423,7 @@ local SLOT_BASE = { [3] = { name = "Simulated Spaulders", quality = 3, texture = "Interface\\Icons\\INV_Shoulder_01" }, [15] = { name = "Simulated Drape", quality = 3, texture = "Interface\\Icons\\INV_Misc_Cape_16" }, [5] = { name = "Simulated Breastplate", quality = 4, texture = "Interface\\Icons\\INV_Chest_Plate04" }, + [6] = { name = "Simulated Girdle", quality = 3, texture = "Interface\\Icons\\INV_Belt_03" }, [9] = { name = "Simulated Bracers", quality = 3, texture = "Interface\\Icons\\INV_Bracer_13" }, [10] = { name = "Simulated Gauntlets", quality = 3, texture = "Interface\\Icons\\INV_Gauntlets_28" }, [7] = { name = "Simulated Legplates", quality = 4, texture = "Interface\\Icons\\INV_Pants_04" }, @@ -365,6 +442,9 @@ local SLOT_BASE = { -- SLOT_BASE. Table overrides are MERGED with the slot cosmetics (missing -- name/quality/texture filled in from SLOT_BASE, everything else -- id, -- enchant, subType, own cosmetics -- kept); "EMPTY" stays "EMPTY". +-- WAIST (6) is treated like the plain armor slots here -- always +-- enchanted in the base gear, expected by default for every role -- NOT +-- grouped with the enchant-free neck/rings/trinkets below. -- Neck/rings (2/11/12) default to enchant=0 like trinkets -- but unlike -- trinkets they ARE enchantable on this server (data/enchants.lua, kind -- "ench"), so Simtank's override below demonstrates a real neck enchant @@ -438,10 +518,15 @@ function DC_Sim.buildStore() add{ name = "Simtank", class = "WARRIOR", online = true, guid = "0xF530000000000001", subgroup = 1, aurasRead = true, - auras = { names = {}, ids = set({ 17626, 15852, 16323, 17538, - 11348, 3593, 17543, 9885, 10938, 10958 }) }, - -- FLASK Titans, FOOD Chili, STR JujuPower, AGI Mongoose, ARM SupDef, - -- HP Fortitude, FR GFPP, MOTW r7, PWF r6, SPROT r3 + auras = { names = {}, ids = set({ 17626, 15852, 17038, 16323, + 17538, 10667, 24382, 11348, 3593, 25804, 17543, 17548, + 9885, 10938, 10958 }) }, + -- FLASK Titans, FOOD Chili, AP Firewater, STR JujuPower, + -- AGI Mongoose, BL RageOfAges(ROIDS), ZANZA, ARM SupDef, + -- HPELX Fortitude, ALC RumseyBlack, FR GFPP, SR GSPP (shadow-boss + -- prep -- NOTEXP/hidden until the SR expectation is enabled, then + -- the column lights up green for him), MOTW r7, PWF r6, + -- SPROT r3 -- the full KG tank set, 0 gaps (green pill) weaponMain = true, weaponMainName = "Dense Sharpening Stone", gearRead = true, resistsRead = true, resists = { [2] = 315 }, @@ -476,10 +561,16 @@ function DC_Sim.buildStore() guid = "0xF530000000000002", subgroup = 1, aurasRead = true, auras = { names = set({ "Flask of the Titans", "Well Fed", - "Juju Power", "Elixir of the Mongoose", + "Winterfall Firewater", "Juju Power", "Elixir of the Mongoose", + "R.O.I.D.S.", "Spirit of Zanza", "Elixir of Superior Defense", "Elixir of Fortitude", - "Greater Fire Protection", "Mark of the Wild", - "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + "Medivh's Merlot", "Greater Fire Protection", + "Mark of the Wild", "Power Word: Fortitude", + -- Prayer variant, not the bare name: see CLEAN_AURA_NAMES + "Prayer of Shadow Protection" }), ids = {}, + -- effect line for the generic "Well Fed": the identification + -- ladder resolves it to Smoked Desert Dumplings in the matrix + effects = { ["Well Fed"] = "Increases Strength by 20." } }, weaponMain = true, gearRead = true, resistsRead = true, resists = { [2] = 240 }, -- hit: the BELOW-cap showcase -- a human swordsman, so the racial @@ -505,10 +596,15 @@ function DC_Sim.buildStore() guid = "0xF530000000000003", subgroup = 1, aurasRead = true, auras = { names = set({ "Well Fed" }), - ids = set({ 17629, 11405, 11334, 11349, 25804, 7233, - 21849, 21562, 10957 }) }, - -- FLASK ChromRes, STR Giants, AGI GreaterAgi, ARM GreaterDef, - -- HP RumseyBlack, FR FireProt, MOTW Gift, PWF Prayer, SPROT r2 + ids = set({ 17629, 16329, 11405, 11334, 10669, 24382, + 11349, 3593, 25804, 7233, 17544, 21849, 21562, 10957 }) }, + -- FLASK ChromRes, AP JujuMight, STR Giants, AGI GreaterAgi, + -- BL Scorpok, ZANZA, ARM GreaterDef, HPELX Fortitude(HealthII), + -- ALC RumseyBlack, FR FireProt, FrR GFrPP (17544 -- the frost + -- carrier of the six-school demo view, hidden by default), + -- MOTW Gift, PWF Prayer, SPROT r2. + -- NO effects table: his "Well Fed" stays unresolvable -- the + -- matrix ladder's slot-generic-icon floor ("item unknown") weaponMain = true, gearRead = true, resistsRead = true, resists = { [2] = 180 }, -- hit: the BOOK showcase -- an orc axeman (racial +5 => cap 7.0); @@ -553,9 +649,13 @@ function DC_Sim.buildStore() guid = "0xF530000000000005", subgroup = 2, aurasRead = true, auras = { names = set({ "Well Fed" }), - ids = set({ 16329, 17538, 16323, 3593, 9885, 10938 }) }, - -- AP JujuMight, AGI Mongoose, STR JujuPower, HP Fortitude, - -- MOTW r7, PWF r6 + ids = set({ 17626, 16329, 17538, 16323, 10692, 24382, + 3593, 9885, 10938 }), + -- +10 Agility resolves to Grilled Squid through the ladder + effects = { ["Well Fed"] = "Increases Agility by 10." } }, + -- FLASK Titans, AP JujuMight, AGI Mongoose, STR JujuPower, + -- BL Cortex(InfallibleMind), ZANZA, HPELX Fortitude (NOTEXP for + -- MELEE, harmless), MOTW r7, PWF r6 debuffs = debuffsOf({ "Resurrection Sickness" }, { 15007 }), weaponMain = true, gearRead = true, -- hit: the DUAL-WIELD showcase -- two weapon subtypes, so the cell @@ -612,10 +712,12 @@ function DC_Sim.buildStore() add{ name = "Simstab", class = "ROGUE", online = true, guid = "0xF530000000000007", subgroup = 2, aurasRead = true, - auras = { names = set({ "Well Fed", "Juju Might", "Juju Power", - "Elixir of Greater Agility", "Elixir of Fortitude", - "Fire Protection", "Mark of the Wild", - "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + auras = { names = set({ "Flask of the Titans", "Well Fed", + "Juju Might", "Juju Power", "Elixir of Greater Agility", + "Lung Juice Cocktail", "Spirit of Zanza", + "Elixir of Fortitude", "Fire Protection", "Mark of the Wild", + "Power Word: Fortitude", + "Prayer of Shadow Protection" }), ids = {} }, weaponMain = false, gearRead = true, gear = gearWith({ [9] = { id = 5169, enchant = 0 }, -- wrist enchant missing @@ -650,10 +752,15 @@ function DC_Sim.buildStore() guid = "0xF530000000000009", subgroup = 2, aurasRead = true, auras = { names = set({ "Well Fed" }), - ids = set({ 17038, 11405, 17538, 25804, 17543, - 9885, 10938, 10958 }) }, - -- AP Firewater, STR Giants, AGI Mongoose, HP RumseyBlack, FR GFPP, - -- MOTW r7, PWF r6, SPROT r3 + ids = set({ 17626, 17038, 11405, 17538, 10693, 24382, + 25804, 17543, 17546, 9885, 10938, 10958 }) }, + -- FLASK Titans, AP Firewater, STR Giants, AGI Mongoose, + -- BL Gizzard(SpiritualDomination), ZANZA, ALC RumseyBlack + -- (NOTEXP for MELEE, harmless), FR GFPP, NR GNPP (17546 -- the + -- nature carrier of the six-school demo view), MOTW r7, PWF r6, + -- SPROT r3 (the priest buff by ID -- he holds no SR potion, which + -- is exactly the name-collision case the demo view must not + -- mistake for a fulfilled SR column) weaponMain = true, gearRead = true, gear = gearWith({ [17] = { id = 5197, enchant = 917, subType = "Shields", quality = 3 }, @@ -694,9 +801,12 @@ function DC_Sim.buildStore() guid = "0xF53000000000000B", subgroup = 3, distance = 31, aurasRead = true, auras = { names = set({ "Well Fed", "Juju Might", - "Elixir of Greater Agility", "Rumsey Rum", + "Elixir of Greater Agility", "Ground Scorpok Assay", + "Spirit of Zanza", "Rumsey Rum", "Greater Fire Protection", "Mark of the Wild", - "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + "Power Word: Fortitude", + "Prayer of Shadow Protection" }), ids = {} }, + -- ALC (Rumsey Rum) is NOTEXP for RANGED -- harmless extra weaponMain = true, gearRead = true, -- hit: the RANGED showcase -- a real bow, so the ranged column is -- a question at all; 8 meets its 8.0 cap (a dwarf's racial is on @@ -727,8 +837,15 @@ function DC_Sim.buildStore() guid = "0xF53000000000000D", subgroup = 4, aurasRead = true, auras = { names = set({ "Well Fed" }), - ids = set({ 17628, 23028, 17539, 24363, 9885, 10938, 10958 }) }, - -- FLASK Supreme, AI Brilliance, SP GAE, MP5 Mageblood, MOTW, PWF, SPROT + ids = set({ 17628, 23028, 17539, 21920, 45489, 45427, 24382, + 24363, 17549, 9885, 10938, 10958 }), + -- +22 spell damage -> Danonzo's Tel'Abim Delight (ladder) + effects = { ["Well Fed"] = + "Increases spell damage by up to 22." } }, + -- FLASK Supreme, AI Brilliance, GAE, SCHOOL FrostPower, + -- DREAMT, SHARD, ZANZA, MP5 Mageblood, AR GAPP (17549 -- the + -- arcane carrier of the six-school demo view), MOTW, PWF, SPROT -- + -- the full KG caster set except FR (her designed gap) weaponMain = true, weaponMainName = "Brilliant Wizard Oil", gearRead = true, resistsRead = true, resists = { [4] = 85, [6] = 45 }, @@ -795,7 +912,9 @@ function DC_Sim.buildStore() guid = "0xF530000000000010", subgroup = 5, aurasRead = true, auras = { names = set({ "Well Fed", "Flask of Supreme Power", - "Greater Arcane Elixir", "Mageblood", "Fire Protection", + "Greater Arcane Elixir", "Elixir of Shadow Power", + "Dreamtonic", "Dreamshard Elixir", "Spirit of Zanza", + "Mageblood", "Fire Protection", "Arcane Intellect", "Mark of the Wild", "Power Word: Fortitude" }), ids = {} }, @@ -838,10 +957,12 @@ function DC_Sim.buildStore() guid = "0xF530000000000012", subgroup = 5, aurasRead = true, auras = { names = set({ "Shadowform", "Flask of Supreme Power", - "Well Fed", "Elixir of Shadow Power", "Mageblood Potion", + "Well Fed", "Greater Arcane Elixir", "Elixir of Shadow Power", + "Dreamtonic", "Dreamshard Elixir", "Spirit of Zanza", + "Mageblood Potion", "Greater Fire Protection", "Arcane Intellect", "Mark of the Wild", "Power Word: Fortitude", - "Shadow Protection" }), ids = {} }, + "Prayer of Shadow Protection" }), ids = {} }, weaponMain = true, gearRead = true, gear = gearWith({ [18] = { id = 5238, enchant = 0, subType = "Wands" }, @@ -868,10 +989,21 @@ function DC_Sim.buildStore() guid = "0xF530000000000014", subgroup = 6, aurasRead = true, auras = { names = set({ "Well Fed" }), - ids = set({ 17627, 20765, 24363, 3593, 7233, - 10157, 9885, 10938, 10958 }) }, - -- FLASK Wisdom, SOUL r5, MP5 Mageblood, HP Fortitude, FR FireProt, - -- AI r5, MOTW r7, PWF r6, SPROT r3 + ids = set({ 17627, 20765, 24363, 45427, 24382, 22790, + 3593, 7233, 7245, 10157, 9885, 10938, 10958 }), + -- mid-ladder showcase: Le Fishe Au Chocolat resolves by its + -- dodge effect line but has NO sourced icon in the data (icon + -- is a questionmark placeholder even upstream) -> the matrix + -- shows the slot-generic FOOD icon while the tooltip still + -- names the item. (Herbal Salad carried this showcase until + -- its icon was sourced on 2026-08-11.) + effects = { ["Well Fed"] = + "Increases your chance to dodge by 1% and Defense by 4." } }, + -- FLASK Wisdom, SOUL r5, MP5 Mageblood, SHARD, ZANZA, + -- ALC Kreeg's, HPELX Fortitude (NOTEXP for HEALER, harmless), + -- FR FireProt, HR HPP (7245 -- the holy carrier of the six-school + -- demo view; no Greater tier exists for holy), AI r5, MOTW r7, + -- PWF r6, SPROT r3 weaponMain = true, gearRead = true, gear = gearWith({ [17] = { id = 5257, enchant = 0, subType = "Miscellaneous", @@ -895,7 +1027,8 @@ function DC_Sim.buildStore() aurasRead = true, auras = { names = set({ "Flask of Distilled Wisdom", "Well Fed", "Mageblood", "Arcane Intellect", "Mark of the Wild", - "Power Word: Fortitude", "Shadow Protection" }), ids = {} }, + "Power Word: Fortitude", + "Prayer of Shadow Protection" }), ids = {} }, debuffs = debuffsOf({ "Resurrection Sickness" }, { 15007 }), weaponMain = false, gearRead = true, gear = gearWith({ @@ -908,9 +1041,10 @@ function DC_Sim.buildStore() guid = "0xF530000000000017", subgroup = 7, aurasRead = true, auras = { names = set({ "Well Fed" }), - ids = set({ 17627, 24363, 25804, 7233, 10156, 9885, 10938 }) }, - -- FLASK Wisdom, MP5 Mageblood, HP RumseyBlack, FR FireProt, - -- AI r4, MOTW r7, PWF r6 + ids = set({ 17627, 24363, 45427, 24382, 25804, 7233, + 10156, 9885, 10938 }) }, + -- FLASK Wisdom, MP5 Mageblood, SHARD, ZANZA, ALC RumseyBlack, + -- FR FireProt, AI r4, MOTW r7, PWF r6 weaponMain = true, gearRead = true, gear = gearWith({ [17] = { id = 5277, enchant = 917, subType = "Shields", quality = 3 }, @@ -923,10 +1057,12 @@ function DC_Sim.buildStore() guid = "0xF530000000000018", subgroup = 7, aurasRead = true, auras = { names = set({ "Flask of Distilled Wisdom", "Well Fed", - "Mageblood Potion", "Elixir of Fortitude", + "Mageblood Potion", "Dreamshard Elixir", "Spirit of Zanza", + "Kreeg's Stout Beatdown", "Elixir of Fortitude", "Greater Fire Protection", "Arcane Intellect", "Mark of the Wild", "Power Word: Fortitude", - "Shadow Protection", "Soulstone Resurrection" }), ids = {} }, + "Prayer of Shadow Protection", + "Soulstone Resurrection" }), ids = {} }, weaponMain = true, gearRead = true, gear = gearWith({ [5] = { id = 5285, enchant = 0 }, @@ -935,16 +1071,18 @@ function DC_Sim.buildStore() [18] = "EMPTY", }) } - -- Simglow: HP via the id-less "Rumsey Rum" name entry (spellID 0) WHILE - -- ids are present -- exercises exactly the model rule that id-less - -- names still count for players with ids. SPROT missing, EMPTY ranged. + -- Simglow: ALC via the id-less "Medivh's Merlot Blue" name entry + -- (spellID 0) WHILE ids are present -- exercises exactly the model + -- rule that id-less names still count for players with ids. + -- SPROT missing, EMPTY ranged. add{ name = "Simglow", class = "PALADIN", online = true, guid = "0xF530000000000019", subgroup = 7, aurasRead = true, - auras = { names = set({ "Well Fed", "Rumsey Rum" }), - ids = set({ 17627, 21850, 21564, 24363, 7233, 1461, 20762 }) }, - -- FLASK Wisdom, MOTW Gift, PWF Prayer, MP5 Mageblood, FR FireProt, - -- AI r3, SOUL r2 + auras = { names = set({ "Well Fed", "Medivh's Merlot Blue" }), + ids = set({ 17627, 21850, 21564, 24363, 45427, 24382, + 7233, 1461, 20762 }) }, + -- FLASK Wisdom, MOTW Gift, PWF Prayer, MP5 Mageblood, SHARD, + -- ZANZA, FR FireProt, AI r3, SOUL r2 weaponMain = true, gearRead = true, gear = gearWith({ [17] = { id = 5297, enchant = 917, subType = "Shields", quality = 3 }, diff --git a/data/classbuffs.lua b/data/classbuffs.lua index 1e66bbb..59a0c0b 100644 --- a/data/classbuffs.lua +++ b/data/classbuffs.lua @@ -66,3 +66,37 @@ for name, def in pairs(DC.CLASSBUFFS) 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, +} diff --git a/data/consumables.lua b/data/consumables.lua index c8f6aaa..519cdd2 100644 --- a/data/consumables.lua +++ b/data/consumables.lua @@ -1,6 +1,65 @@ -- DopingControl data/consumables.lua --- Consumables tab: slot definitions + buff->slot table + spellID->slot map. +-- 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 @@ -8,116 +67,294 @@ local DC = DopingControl -- ------------------------------------------------------------------ -- Slot definitions (array, order = column order). -- Fields: id (stable key, used by DEFAULT_EXPECT / model / UI), --- label (column tile abbrev), sub (tile sub-label), full (long name, --- used in whisper/report), kind ("aura" | "weapon"), --- items (tooltip example text, optional). --- Separator decision (once for all data files): items strings are --- tooltip/FontString-only, so they may use the middle-dot list separator --- "\194\183" (UTF-8 middle dot renders fine in own --- FontStrings; the group headers already use the same glyph). Em dashes --- stay ASCII "-" (that glyph is unproven in the 1.12 font). SENT --- chat lines remain pure ASCII -- they never use these --- strings. +-- 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", + { 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", - items = "Well Fed - e.g. Grilled Squid \194\183 Blessed Sunfruit" }, - { id = "AP", label = "AP", sub = "Attack", full = "Attack power", kind = "aura", + { 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", + { 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", - items = "Elixir of the Mongoose" }, - { id = "SP", label = "SP", sub = "Spell", full = "Spell power", kind = "aura", - items = "Greater Arcane Elixir \194\183 Shadow Power \194\183 Firepower" }, - { id = "MP5", label = "MP5", sub = "Mana", full = "Mana regen", kind = "aura", - items = "Mageblood Potion \194\183 Nightfin Soup" }, - { id = "ARM", label = "ARM", sub = "Armor", full = "Armor", kind = "aura", + { 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 = "HP", label = "HP", sub = "Stam.", full = "Stamina/HP", kind = "aura", - items = "Elixir of Fortitude \194\183 Rumsey Rum Black Label" }, - { id = "FR", label = "FR", sub = "fire", full = "Fire protection", kind = "aura", + { 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" }, - { id = "WPN", label = "WPN", sub = "temp.", full = "Weapon (temporary)", kind = "weapon", -- NOT an aura slot - items = "Dense Sharpening Stone \194\183 Brilliant Wizard/Mana Oil - permanent enchant: Equipment tab" }, + -- 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 -> slot table. +-- 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" }, - ["Flask of Supreme Power"] = { slot = "FLASK", spellID = 17628, item = "Flask of Supreme Power" }, - ["Flask of Distilled Wisdom"] = { slot = "FLASK", spellID = 17627, item = "Flask of Distilled Wisdom" }, - ["Flask of Chromatic Resistance"] = { slot = "FLASK", spellID = 17629, item = "Flask of Chromatic Resistance" }, - ["Flask of Petrification"] = { slot = "FLASK", spellID = 17624, item = "Flask of Petrification" }, + ["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; TWoW custom foods likely all show "Well Fed" - unverified) - ["Well Fed"] = { slot = "FOOD", spellID = 0, item = "Grilled Squid etc." }, - -- ["Food"] (the eating tick) deliberately REMOVED: it is - -- the sit-and-eat channel aura, not a food buff -- it made the FOOD - -- cell green for anyone currently chewing (field-tested). Only - -- lasting food buffs count. - ["Increased Stamina"] = { slot = "FOOD", spellID = 0, item = "stamina food" }, - ["Dragonbreath Chili"] = { slot = "FOOD", spellID = 15852, item = "Dragonbreath Chili" }, + -- 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 KG + -- stacking-rules atom 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" }, - ["Juju Might"] = { slot = "AP", spellID = 16329, item = "Juju Might" }, + ["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" }, - ["Elixir of Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants" }, - ["Elixir of the Giants"] = { slot = "STR", spellID = 11405, item = "Elixir of Giants" }, -- AURA name on this client (measured live: SpellInfo(11405) = "Elixir of the Giants"); the item is named without "the", and matching runs on the aura name first - ["Elixir of Brute Force"] = { slot = "STR", spellID = 17537, item = "Elixir of Brute Force" }, - ["Strike of the Scorpok"] = { slot = "STR", spellID = 10669, item = "Strike of the Scorpok" }, -- measured live in a raid (26 players); was tallied as unknown - ["Rage of Ages"] = { slot = "STR", spellID = 0, item = "R.O.I.D.S." }, -- aura name differs from the item; own spell ID not measured yet + ["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" }, - ["Elixir of Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility" }, - ["Greater Agility"] = { slot = "AGI", spellID = 11334, item = "Elixir of Greater Agility" }, -- AURA name (the item name never appears as a buff); was tallied as unknown 14x - ["Elixir of Agility"] = { slot = "AGI", spellID = 11328, item = "Elixir of Agility" }, + ["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" }, - -- SP (merged: arcane_elixir + school_elixir + dreamtonic + dreamshard) - ["Greater Arcane Elixir"] = { slot = "SP", spellID = 17539, item = "Greater Arcane Elixir" }, - ["Arcane Elixir"] = { slot = "SP", spellID = 11390, item = "Arcane Elixir" }, - ["Elixir of Shadow Power"] = { slot = "SP", spellID = 11474, item = "Elixir of Shadow Power" }, - ["Elixir of Frost Power"] = { slot = "SP", spellID = 21920, item = "Elixir of Frost Power" }, - ["Greater Frost Power"] = { slot = "SP", spellID = 56544, item = "Elixir of Greater Frost Power" }, -- AURA name (TurtleWoW-added). NOT measured in-game: taken from a TurtleWoW spell database (56544 = "Greater Frost Power", "Increases frost spell damage by up to 40 for 3600 sec.") plus the item name in pfQuest-turtle's item DB; the aura drops "Elixir of" the same way Giants/Agility above do - ["Elixir of Greater Firepower"] = { slot = "SP", spellID = 26276, item = "Elixir of Greater Firepower" }, - ["Elixir of Firepower"] = { slot = "SP", spellID = 7844, item = "Elixir of Firepower" }, - ["Dreamtonic"] = { slot = "SP", spellID = 45489, item = "Dreamtonic" }, -- use-spell ID; the aura's own ID is unverified - ["Dreamshard Elixir"] = { slot = "SP", spellID = 45427, item = "Dreamshard Elixir" }, -- use-spell ID; the aura's own ID is unverified + -- 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 (KG + -- 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" }, - -- MP5 - ["Mageblood Potion"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion" }, - ["Mageblood"] = { slot = "MP5", spellID = 24363, item = "Mageblood Potion" }, -- TWoW buff-name variant (old Data.lua kept both) + -- 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" }, - ["Elixir of Greater Defense"] = { slot = "ARM", spellID = 11349, item = "Elixir of Greater Defense" }, + ["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" }, - -- HP (merged: def_fortitude + alcohol STA variants) - ["Elixir of Fortitude"] = { slot = "HP", spellID = 3593, item = "Elixir of Fortitude" }, - ["Health II"] = { slot = "HP", spellID = 3593, item = "Elixir of Fortitude" }, -- AURA name on this client (measured live: SpellInfo(3593) = "Health II") - ["Rumsey Rum Black Label"] = { slot = "HP", spellID = 25804, item = "Rumsey Rum Black Label" }, - ["Rumsey Rum"] = { slot = "HP", spellID = 0, item = "Rumsey Rum" }, - ["Rumsey Rum Light"] = { slot = "HP", spellID = 0, item = "Rumsey Rum Light" }, - ["Medivh's Merlot"] = { slot = "HP", spellID = 57106, item = "Medivh's Merlot" }, -- use-spell ID; the aura's own ID is unverified - ["Gordok Green Grog"] = { slot = "HP", spellID = 0, item = "Gordok Green Grog" }, + -- 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") - -- FR - ["Greater Fire Protection"] = { slot = "FR", spellID = 17543, item = "Greater Fire Protection Potion" }, - ["Fire Protection"] = { slot = "FR", spellID = 7233, item = "Fire Protection Potion" }, + -- 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 + -- " 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 diff --git a/data/enchants.lua b/data/enchants.lua index e2b28d9..3816973 100644 --- a/data/enchants.lua +++ b/data/enchants.lua @@ -1,5 +1,5 @@ -- DopingControl data/enchants.lua --- Equipment tab: 14 enchant slots + 2 item-tile slots (both trinkets), in +-- 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 @@ -7,13 +7,26 @@ -- question is. Item-tile slots (trinkets) show the equipped item and -- ignore expectations entirely; only an EMPTY slot counts as a gap there. -- --- Server note: neck and both rings ARE enchantable on this server (a --- server-custom feature beyond vanilla WoW, where they take no enchant) -- --- NECK/R1/R2 are therefore kind="ench" like any armor slot, not item --- tiles. Their expectation defaults to OFF (data/expectations.lua): unlike --- the ARMOR enchant slots, a neck/ring enchant is not assumed baseline --- raid gear, so the columns only track it once a role/class opts in via --- the options grid. +-- 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 KG 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 {} @@ -30,6 +43,7 @@ DC.SLOTS_EQUIPMENT = { { 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 }, diff --git a/data/expectations.lua b/data/expectations.lua index 7323479..34da887 100644 --- a/data/expectations.lua +++ b/data/expectations.lua @@ -15,7 +15,21 @@ -- a CASTER/HEALER role default for every performing class. -- -- Role-line choices: --- Consumables: standard raid role lines. +-- Consumables: the four role sets of the KG stacking-rules atom +-- (data-tables/consumable-stacking-rules, TurtleWoW +-- 1.17.2 -- melee / caster / tank / healer, each "all +-- active simultaneously") ARE the defaults now; see the +-- spec 2026-08-11-slot-model-item-icons-design.md. RANGED +-- has no KG 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 @@ -23,12 +37,16 @@ -- 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; +-- 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 (enchantable on this server, a custom feature --- beyond vanilla) default to OFF for every role: unlike --- the armor slots, a neck/ring enchant is not assumed +-- 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 @@ -37,19 +55,16 @@ -- -- Known debatable rows -- documented deliberately, the shipped defaults -- win: --- * TANK+AP: some tank consumable lists include Winterfall --- Firewater; the default has no AP for TANK. --- * MELEE+FLASK: some melee lists include Flask of the Titans; the --- default has no FLASK for MELEE. --- * MELEE/RANGED+HP: some melee lists have neither Fortitude nor --- alcohol; HP=true is the weakest row of the matrix. --- * RANGED+AP/WPN: Firewater/Juju Might are MELEE attack power; ranged --- AP comes from food. Arguably AP=false, WPN=false --- for RANGED; the default says true. --- * HEALER+SP: healer sets may carry Dreamshard (+healing), which the --- 11-slot merge puts into SP; the default has no SP for --- HEALER -> Dreamshard is invisible for healers by --- default. +-- * TANK+AP / MELEE+FLASK: the OLD defaults deliberately excluded them; +-- the KG 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 KG-covered (see above) -- BL is included +-- (Ground Scorpok Assay is +25 AGI), STR/FLASK are not. +-- * HEALER+HPELX: the KG 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 KG sets carry it). -- * Equipment: an alternative default would differentiate per role -- (OFFH only TANK/MELEE, RNGD only RANGED, -- casters/healers neither); deliberately flattened to @@ -65,9 +80,12 @@ local DC = DopingControl DC.DEFAULT_EXPECT = { TANK = { - -- consumables - FLASK = true, FOOD = true, STR = true, AGI = true, ARM = true, - HP = true, FR = true, WPN = true, + -- consumables (KG 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: @@ -75,48 +93,61 @@ DC.DEFAULT_EXPECT = { -- 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 - FOOD = true, AP = true, STR = true, AGI = true, - HP = true, FR = true, WPN = true, + -- consumables (KG 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 - FOOD = true, AP = true, AGI = true, - HP = true, FR = true, WPN = true, + -- consumables (derived from the melee line -- no KG 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 - FLASK = true, FOOD = true, SP = true, MP5 = true, + -- consumables (KG 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 - FLASK = true, FOOD = true, MP5 = true, - HP = true, FR = true, WPN = true, + -- consumables (KG 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, }, } diff --git a/scan/aura.lua b/scan/aura.lua index 3fd9798..d060080 100644 --- a/scan/aura.lua +++ b/scan/aura.lua @@ -19,6 +19,12 @@ -- 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 @@ -68,8 +74,9 @@ local A = DC_Aura -- Assemble(rawA, rawB, idNames) -> auras, aurasRead, disagreements, unknown -- rawA : array of { name = string|nil, id = number|nil, --- texture = string|nil } (path A; texture is the 1st --- UnitBuff return, optional) +-- 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 -- @@ -78,9 +85,10 @@ local A = DC_Aura -- as before (no behavior change without it). -- Returns: -- auras : { names = { [name]=true }, ids = { [id]=true }, --- textures = { [name]=iconPath } } --- (store shape; textures may be empty/partial -- --- consumers nil-guard) +-- 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 @@ -131,6 +139,7 @@ function A.Assemble(rawA, rawB, idNames) 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) @@ -144,6 +153,9 @@ function A.Assemble(rawA, rawB, idNames) 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 @@ -259,7 +271,8 @@ function A.Assemble(rawA, rawB, idNames) end end - return { names = names, ids = ids, textures = textures }, + return { names = names, ids = ids, textures = textures, + effects = effects }, aurasRead, disagreements, unknown end @@ -376,15 +389,22 @@ 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 - return textObj:GetText() + name = textObj:GetText() end - return nil + local effObj = getglobal("DopingControlScanTipTextLeft2") + if effObj then + effect = effObj:GetText() + end + return name, effect end -- debuff twin of tipBuffName (same hidden tooltip, SetUnitDebuff); @@ -420,20 +440,23 @@ function A.ReadUnit(unit, guid) if not ok or not texture then break end - local name = nil + local name, effect = nil, nil if tip then - local ok2, nm = pcall(tipBuffName, unit, i) + 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) rides along so Assemble can key - -- it under the tooltip name (auras.textures) - table.insert(rawA, { name = name, id = id, texture = texture }) + -- 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 diff --git a/scan/engine.lua b/scan/engine.lua index 39ad35c..26e13d6 100644 --- a/scan/engine.lua +++ b/scan/engine.lua @@ -120,6 +120,15 @@ function E.NormalizeWeapon(isSelf, v, readable) end return false end + -- Stock 1.12 GetWeaponEnchantInfo takes NO arguments: on a client without + -- SuperWoW the extra unit is ignored and the first return is the PLAYER's + -- hasMainHandEnchant (1|nil). Only the SuperWoW name channel answers about + -- the foreign unit, and it is always string-or-nil (measured: 11 names, + -- 14 nils, "" never). A non-string therefore says nothing about this unit + -- => not readable, never "has an imbue". + if v ~= nil and type(v) ~= "string" then + return nil + end if v == nil then if readable == true then return false @@ -998,10 +1007,12 @@ if CreateFrame then end E.SweepLastKnown(DC.lastKnown, rosterSet, E.CACHE_CAP) local db = DopingControlDB - if db and db.testMode then - -- Test mode was switched ON while this scan was in flight: the - -- simulator fully replaces the data source ("real - -- data never beats the simulation") -- drop the result. + if DC.SimOwnsStore(db) then + -- Test mode OR demo mode was on (or switched on while this scan + -- was in flight): the simulator fully replaces the data source + -- ("real data never beats the simulation") -- drop the result. + -- This is the load-bearing gate: it is the only thing between a + -- finished scan and DC.store = E.NewStore(...) below. results = {} return end @@ -1055,8 +1066,11 @@ if CreateFrame then -- Start(force) -> bool (scan started/restarted). -- Already running: no-op unless force, which restarts with a fresh - -- roster snapshot. Test mode is NOT checked here -- an explicit Start - -- is user intent; only the READY_CHECK auto-trigger honors testMode. + -- roster snapshot. DC.SimOwnsStore is NOT checked here -- an explicit + -- Start is user intent, and the callers that are NOT user intent + -- (READY_CHECK, the roster-change and window-open auto-scans) check it + -- themselves. The scan may therefore run in test/demo mode; what it may + -- never do is publish, so finish() drops the result instead. function E.Start(force) if running then if not force then @@ -1087,7 +1101,7 @@ if CreateFrame then eventFrame:SetScript("OnEvent", function() if event == "READY_CHECK" then local db = DopingControlDB - if db and db.readyCheckScan and not db.testMode then + if db and db.readyCheckScan and not DC.SimOwnsStore(db) then E.Start() end return diff --git a/scan/gear.lua b/scan/gear.lua index f5273be..21b9f1b 100644 --- a/scan/gear.lua +++ b/scan/gear.lua @@ -29,9 +29,9 @@ local G = DC_Gear -- Fallback inventory-slot list (= invSlot column of DC.SLOTS_EQUIPMENT, -- data/enchants.lua) so this file works standalone in tests. -local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 } +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 16 inventory slot numbers, in column order. +-- 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 diff --git a/scan/hit.lua b/scan/hit.lua index 3b1af34..018e9b8 100644 --- a/scan/hit.lua +++ b/scan/hit.lua @@ -122,9 +122,11 @@ DC_Hit = DC_Hit or {} local H = DC_Hit -- Equipment slots that can carry +hit, in inventory-slot order. This is --- deliberately its OWN list and not DC_Gear.InvSlots(): the enchant tab --- has no waist column, but a belt most certainly can carry hit. Shirt (4) --- and tabard (19) never carry stats and are left out. +-- deliberately its OWN list and not DC_Gear.InvSlots(): the two lists +-- happen to agree today (both cover the waist, invSlot 6 -- see +-- data/enchants.lua's bug-history note), but this file must not assume +-- that stays true, so it keeps every hit-bearing slot spelled out here. +-- Shirt (4) and tabard (19) never carry stats and are left out. H.SLOTS = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 } H.SLOT_MAIN = 16 @@ -888,9 +890,8 @@ end -- ReadUnit(unit, rawLinks) -> hit, race -- rawLinks : OPTIONAL { [invSlot] = link } that scan/gear.lua already -- fetched for this unit. Slots it covers are taken from it --- (no second GetInventoryItemLink pass); slots outside its --- coverage -- the waist, which the enchant tab has no column --- for -- are fetched here. +-- (no second GetInventoryItemLink pass); any slot H.SLOTS +-- needs that rawLinks did not cover is fetched here instead. -- hit : the store shape documented in the file header -- race : race token from UnitRace (2nd return, e.g. "NightElf"); -- needed for the racial weapon-skill estimate of foreign diff --git a/scan/talents.lua b/scan/talents.lua index 783f0b8..e414b3d 100644 --- a/scan/talents.lua +++ b/scan/talents.lua @@ -67,6 +67,11 @@ 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) @@ -147,9 +152,24 @@ end -- spent = , -- sumRank = , -- trees = , --- complete = } -function T.NewAcc() - return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false } +-- complete = , +-- at = } +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 @@ -281,6 +301,9 @@ if CreateFrame then 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) @@ -296,6 +319,9 @@ if CreateFrame then table.insert(queue, n) end end + if arm and table.getn(queue) > 0 then + arm() + end end local f = CreateFrame("Frame", "DopingControlTalentFrame") @@ -313,9 +339,14 @@ if CreateFrame then 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() + a = T.NewAcc(now) acc[sender] = a end T.Feed(a, kind, data) @@ -338,14 +369,26 @@ if CreateFrame then -- 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 - f:SetScript("OnUpdate", function() + + 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() @@ -359,10 +402,23 @@ if CreateFrame then T.Ask(name) end 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 = {} diff --git a/textures/cells.tga b/textures/cells.tga new file mode 100644 index 0000000000000000000000000000000000000000..9e14c5a8f233b41e1540afea13d51826f8c2347d GIT binary patch literal 262162 zcmeI5L5f{f7e-%fIe-Wu7}PZEL?jJChz-F3H~QUg^s{$)<`R_@E?7P?Zt#?Z){7t`i+||e)r_@==J{dW@^7nbAkFH`!j!&htYqv?mzvj`&aO zS(wN4+2mpLU+p^zVC>I+sXT=Kn|(+8`QP{d`saWC-?DP)f|K?{79?CD_`yb|?|6UpL zfBxTpn|;64wEQ3c-lzBf$3JhQ@(}u;{~zaX@-W_iYTxk!F!ty7e=3jsAAkQb|NMuP zgU9^u`+xd4ea`%YR5Yc+CI&r;pR;{2vq^qw+uhr+w$V{D+i-$NbNK`Z#^g z|3Tp~D*y9;+IP;&e@Hoa%>VxTzwiCK`8jCye+N6ik6QgVuKgLzdt>*bnI?l~M<4!Hw+ z*1t7Y<8gj^J{gy`Fa{gDAI-#LO&wPe`Sa-4KChWm`(2u2>`%TbkDD&?d9Hna|9NvM zn5nM|`;)K9!|1=-cl65GpZTjig#Md-NBpPtEX-s2Z1OPrul5}UF!pD^R31YA&Auc4 z{J$w5`*`iM+IJKn|L5O-`H%nqr<>1U)N<@-^gsVU&R^xx`+xlX$M>uGd6@t6-~TZG z{2zPW-5w--{xSdjza7ie?--wd%s>C9(mt4z|Ks2PsC`ET^1pxn=jZYCx%WS$96aWK z{!jbPdHFvmJVxbz{?o_lbN)lh!DIgC|FrL%m;ZypV^sdZ^pm9 z?9cp79!CGozB7Mgf97xUF#2!yo%tL4Gk=qZ(SNh=%-`6b`I|h9{+oSg{>J{y-{fKR z-|Rc{H}+@#CJ&?kX5X2=u|M-Sc^LgS`_BB0{h7bX!|1=+cjj;G&-_guM*q#eGk;@$ z=5O*a`fv7~`5XH)f0KvNf3xq*-`Jn|n>>vEn|){g#{SIT^t)}_GkVk52OEP z-~n| z?9cp79!CGozB7Mgf97xUF#2!yo%tL4Gk=qZ(SNh=%-`6b`I|h9{+oSg{>J{y-{fKR z-|Rc{H}+@#CJ&?kX5X2=u|M-Sc^LgS`_BB0{h7bXV|CBCn1gjTc0ZbF?Z3KbT-Lsv zYwUhBlh4&T0uhKn1R@ZD2t*(P5r{wpA`pQHL?8kYh(H7)5P=9pAOaDHKm;NXfe1t( z0uhM7B?2#BzBwD$rRSRZy0QDw%&C1j=kGtBosEmnVV#ZLk7io?uP*ZEk*|GTGp&6& z*VvzYO&+U@e4cBc*G%SMosIp;*W_XJ-|RbkX6(=WO&&)7&Au~#V}Isv@-X^u_MQ0~ z`!j!&htYqt@66xWpZS|SjQ*Q_Xa2_i%-`f;^xy0|^EdWq{w5Ej|7PErzp+2_H+dNS zH~Y@~js2Ow$;0Ts*>~n|?9cp79!CGozB7Mgf97xUF#2!yo%tL4Gk=qZ(SNh=%-`6b z`I|h9{+oSg{>J{y-{fKR-|Rc{H}+@#CJ&?kX5X2=u|M-Sc^LgS`_BB0{h7bX!|1=+ zcjj;G&-_guM*q#eGk;@$=5O*a`fv7~`5XH)f0KvNf3xq*-`Jn|n>>vEn|){g#{SIT z^t)}_GkVk52OEP-%Jzc?EgpTjyEyC2Q8_FrA(&m&*^yk=Vaa;~vI`IJ{y-{fKR-|Rc{H}+@#CJ&?kX5X2=u|M-Sc^LgS`_BB0 z{h7bX!|1=+cjj;G&-_guM*q#eGk;@$=5O*a`fv7~`5XH)f0KvNf3xq*-`Jn|n>>vE zn|){g#{SIT^t)}_GkVk52OEP-~n|?9cp79!CGozB7Mgf97xUF#2!yo%tL4Gk=qZ z(SNh=%-`6b`I|h9{+oSg{>J{y-{fKR-|Rc{H}+@#CJ&?kX5X2=u|M-Sc^LgS`_BB0 z{h7bX!|1=+cjj;G&-_guM*q#eGk;@$=5O*a`fv7~`5XH)f0M`Ro^de;>ul_PG}GFD zbU{mI+pvAX!plgFdin#mljv#~#Un>>vEn|)``jQ#UJ|M$;;{HK+3=lt*e zzq@GM=l`7VoRt6hZ#cit%zs)rch3L(-(58B^MB5FPRjrMH=N&R=0B~RJLiA??=Bkm z`9J47C*^hl}IKR)# ze_A7#{T4O@-X^u_MN>l_Rs(P-#-KLpH|MD^S}51?xJy@|8u@`QvT<^;ru=` z|7qpiIsfy2chR`d|2f||DgX1|aDJbe|Fm-Mod5a1yJ+0!|D5lfl>hl}IKR)#e_AG`F&>o)5^JX{^$ShqH&-9bG~y@{^!5p{5~`PY31BG|MP!$(YUw$Uk*%P nbB5nNX?pKZ&Aa<4ulLo}J>$CGM)&)hjopuC-tVdZzd!vSB0qg) literal 0 HcmV?d00001 diff --git a/ui/cellsheet.lua b/ui/cellsheet.lua new file mode 100644 index 0000000..3a8f2ea --- /dev/null +++ b/ui/cellsheet.lua @@ -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 diff --git a/ui/matrix.lua b/ui/matrix.lua index da311e4..5929216 100644 --- a/ui/matrix.lua +++ b/ui/matrix.lua @@ -76,10 +76,14 @@ -- * suggested role badge: no dashed borders on 1.12 -> "?" suffix on the -- badge text + dimmer border + tooltip reason. -- * glyphs: HAS/MISSING use the vanilla check/cross textures (tinted); --- UNKNOWN "?" and skip middle-dot are FontStrings. Aura HAS cells with --- a known buff texture (cell.icon) and equipment cells with a known --- item (cell.item) render the icon on a lazy named per-cell texture --- (DopingControlCellIcon) instead of the glyph. Caret = "-"/"+" +-- UNKNOWN "?" and skip middle-dot are FontStrings. CONSUMABLE HAS +-- cells render the ITEM's icon (phase-1 ladder via +-- MX.ConsumableCellVisual: resolved item icon -> slot-generic icon -> +-- green check -- never the aura texture, never blank); class-buff HAS +-- cells keep the buff texture (cell.icon); equipment cells with a +-- known item (cell.item) render the item icon. All of them on a lazy +-- named per-cell texture (DopingControlCellIcon) instead of the +-- glyph. Caret = "-"/"+" -- (quest-log convention), whisper button = "PST", unreadable sum = "--" -- (the unicode carets/envelope/em-dash are not in the 1.12 font). -- * decorative logo glyph + tab chip -> a tab tooltip note instead. @@ -122,8 +126,10 @@ MX.PAD = 22 -- must clear the 16px opaque panel_border band with -- which put left-edge texts inside the border art) MX.ROW_H = 27 MX.GROUP_H = 24 -MX.MAX_COLS = 16 -- "N slots hidden" fallback only ABOVE this - -- (equipment: 11 ench + neck + 2 rings + 2 trinkets) +MX.MAX_COLS = 22 -- "N slots hidden" fallback only ABOVE this + -- (consumables v6: 22 slots incl. the five school + -- protection columns is the widest tab now; + -- equipment is 17) MX.SCROLLBAR_W = 34 -- 6 gap + 16 bar (UIPanelScrollFrameTemplate puts the -- bar at +6..+22 right of the scroll frame edge) + 12 -- clearance before the 16px opaque border band @@ -132,6 +138,11 @@ MX.WHISPER_COOLDOWN = 30 -- seconds per player MX.HEADER_ICON = 20 -- header tile icon edge (the tile is -- 26px high inside a 30px row -- a 30px icon crossed -- both row hairlines, seen in-game) +MX.CELL_ICON = 18 -- cell icon edge (fits the 30x23 cell with + -- >=2px clearance to the cell border on every side) +MX.ICON_CROP = 0.08 -- TexCoord inset (0.08..0.92) -- crops the baked-in + -- dark icon border so the tile border stays the + -- only frame (same crop the item/debuff icons use) -- Tab strip geometry. The strip starts at MX.PAD and must still fit the -- NARROWEST window (MX.MIN_FRAME_W, the titlebar minimum) with the same -- padding on the right -- one more tab therefore costs width per tab, not @@ -225,6 +236,160 @@ function MX.RowVisible(row, gapsOnly) return row.gaps > 0 or (row.unknowns or 0) > 0 end +-- Which slice of the unit list is on screen at a given scroll offset, where +-- units may differ in height (group heads are shorter than rows). Two spare +-- units so a partially scrolled unit is never blank. Pure math: no frames, +-- no globals, offline-tested. +MX.WINDOW_SPARE = 2 + +function MX.VisibleWindow(heights, viewportH, scrollOffset) + local n = table.getn(heights) + if n <= 0 then + return 1, 0, 0 + end + local offset = scrollOffset or 0 + if offset < 0 then + offset = 0 + end + -- the unit that contains the offset, and its distance from the top + local first, firstY, y = 1, 0, 0 + for i = 1, n do + if y + heights[i] > offset then + first, firstY = i, y + break + end + y = y + heights[i] + first, firstY = i + 1, y -- offset past the end: keep walking + end + if first > n then + first, firstY = n, y - heights[n] + end + -- extend until the viewport is covered, then add the spare units. + -- `reached` records whether that coverage was actually hit (as opposed + -- to running out of units first) -- see the pull-back note below. + local covered, last, reached = 0, first, false + for i = first, n do + covered = covered + heights[i] + last = i + if covered >= viewportH then + reached = true + break + end + end + last = last + MX.WINDOW_SPARE + if last > n then + last = n + end + -- do not leave a gap at the bottom: pull `first` back, but ONLY when + -- extending forward genuinely could not cover the viewport (`reached` + -- is false, i.e. the list ran out before the loop above ever broke). + -- A naive `last == n` check is not enough: the spare units above can + -- push `last` to exactly `n` by coincidence even when the extension + -- already covered the viewport comfortably (e.g. a short list tail + -- right after `first`) -- pulling back in that case would drag `first` + -- toward 1 for no reason and defeat the window entirely. + if not reached then + local back = 0 + while first > 1 and back < viewportH do + back = back + heights[first - 1] + first = first - 1 + firstY = firstY - heights[first] + end + end + return first, last, firstY +end + +-- Pool-slot assignment for ONE unit, by WINDOW POSITION rather than data +-- index: this is what keeps the pool size fixed at roughly the viewport's +-- worth of frames (~20-23) instead of growing with the full roster (up to +-- 45) -- NOT a claim that fewer frames get repositioned per redraw. Every +-- unit inside the window gets ClearAllPoints/SetPoint on every single call +-- to M.RenderWindow regardless (that function always repaints the whole +-- window from scratch, see the loop body there): window-position pooling +-- caps how MANY pooled frames exist, not how many of them get touched on +-- any one redraw. Heads and rows are pooled separately (M.EnsureGroupHead +-- / M.EnsureRow are two different pools), so each kind gets its own +-- counter, threaded through as two plain numbers (headSlot, rowSlot) +-- rather than folded into a returned table, because M.RenderWindow calls +-- this once per visible unit +-- on every scroll-throttled redraw: a full window (~20-23 units) at the +-- 20 Hz throttle ceiling would otherwise allocate ~450 short-lived tables a +-- second, straight into the "no allocation per frame" project rule this +-- whole task exists to satisfy. Pure (plain values only, no table, no +-- frame): offline-tested by driving it in the same incremental loop +-- M.RenderWindow uses. Returns kind, slot (for `unit`), and the updated +-- headSlot/rowSlot counters to pass into the next call. +function MX.NextWindowSlot(unit, headSlot, rowSlot) + if unit.kind == "head" then + headSlot = headSlot + 1 + return "head", headSlot, headSlot, rowSlot + end + rowSlot = rowSlot + 1 + return "row", rowSlot, headSlot, rowSlot +end + +-- Whether an open tooltip should be closed because the pooled widget that +-- owns it is about to be rebound to different data. Pure decision core of +-- M.RenderWindow's tooltip guard: no frames, no GameTooltip -- just the +-- comparison, so it is offline-testable (unlike the real-frame-identity +-- ownership matching, which needs W.dcTipOwner (ui/widgets.lua) and stays +-- in M.RowOwnsTooltip in the WoW section). +-- +-- `stampedPlayer` is the player name the tooltip's builder recorded when +-- it opened (M.CellTooltip / M.SumTooltip set owner.dcTipPlayer; nil for +-- tooltip kinds that never stamp one -- whisper/badge/pill). `newPlayer` +-- is the player about to be bound to that same pooled widget (nil for a +-- group-head unit, which has no stamp either). `sliceMoved` is the coarser +-- "did the window actually scroll" fallback used only when there is no +-- stamp to compare exactly. +-- +-- With a stamp: compare it directly, independent of sliceMoved -- this is +-- what covers a roster join/leave at an unchanged scroll offset (`first` +-- stays numerically the same, but the slot's player still changes). +-- Without one: fall back to sliceMoved, since whisper/badge/pill tooltips +-- print the player's name themselves (or aren't about one single player at +-- all, for the group pill), so a moment of staleness there is self-evident +-- rather than silently wrong -- see M.RenderWindow for that reasoning in +-- full. +function MX.ShouldHideTooltip(stampedPlayer, newPlayer, sliceMoved) + if stampedPlayer ~= nil then + return stampedPlayer ~= newPlayer + end + return sliceMoved and true or false +end + +-- Whether `owner` (GameTooltip's current owner) is one of a pooled row's +-- tooltip-bearing widgets: the three named ones (`a1`/`a2`/`a3` -- the +-- whisper button, role badge, sum) or one of its live slot cells +-- (`cells[1..cellMax]`, matching rf.cells/rf.cellMax's shape exactly, no +-- array copy). Pure: table-identity comparison and a bounded loop only, no +-- WoW API -- works identically for real frames and plain stub tables, so +-- it is offline-testable without a CreateFrame harness. +-- +-- Shared by two different call sites in the WoW section: M.RowOwnsTooltip +-- asks it from M.RenderWindow while a row is about to be REBOUND (still +-- shown, different data); M.RowOnHide asks the same question when a row +-- is about to be HIDDEN entirely (the leftover-hide loop when the roster +-- shrinks, a group collapses, or a filter reduces the visible count) -- +-- that path never touches M.RenderWindow's rebind check at all, so +-- without a dedicated OnHide guard a tooltip anchored to a +-- now-invisible widget would keep showing a vanished player's values with +-- nothing to close it. +function MX.OwnsTooltip(a1, a2, a3, cells, cellMax, owner) + if owner == nil then + return false + end + if owner == a1 or owner == a2 or owner == a3 then + return true + end + for i = 1, (cellMax or 0) do + if cells and cells[i] == owner then + return true + end + end + return false +end + -- Unassigned-chip predicate (pure): the titlebar chip renders only while -- at least one player's role is a heuristic suggestion (vm.unassigned -- from DC_Model.build). n > 0 => visible; 0 or absent aggregate => hidden. @@ -318,6 +483,114 @@ function MX.KnownSpellIds(name) return nil end +-- ------------------------------------------------------------------ +-- Consumable HAS-cell visual: the item, not a check (spec +-- 2026-08-11-slot-model-item-icons-design.md, "Display"). The cell shows +-- the icon of the CONSUMABLE that produced the buff -- resolved through +-- the phase-1 identification ladder (DC.ResolveItem: unique buff name -> +-- entry; generic name -> tooltip effect-line discriminator) -- and falls +-- back down a fixed ladder when the item cannot be determined: +-- resolved item icon -> slot-generic icon (SLOTS_CONSUMABLES[i].icon) +-- -> today's green check. No cell ever goes blank. +-- All pure -- the painter and the tooltip builder consume the same +-- resolution, so what the eye sees and what the tooltip says can never +-- disagree. +-- ------------------------------------------------------------------ + +-- Data icons are BARE texture names; the path prefix is a render-time +-- concern and lives here exactly once. +MX.ICON_PREFIX = "Interface\\Icons\\" + +function MX.IconPath(bare) + if type(bare) ~= "string" or bare == "" then + return nil + end + return MX.ICON_PREFIX .. bare +end + +-- Buff name out of a HAS detail string: ID-matched details embed +-- " (spell N)" (core/model.lua), name matches are the plain name. +function MX.BuffNameFromDetail(detail) + if type(detail) ~= "string" or detail == "" then + return nil + end + local cut = string.find(detail, " (spell ", 1, true) + if cut then + return string.sub(detail, 1, cut - 1) + end + return detail +end + +-- The ladder, resolved for one cell. `effects` is the scanned +-- player.auras.effects table ([buffName] = tooltip effect line, optional/ +-- partial -- scan/aura.lua). Returns { icon, item, generic }: +-- icon full texture path to render, nil = keep the green check +-- item resolved consumable name, nil = item unknown +-- generic true when the icon is the slot-generic fallback +function MX.ConsumableCellVisual(sd, detail, effects) + local buff = MX.BuffNameFromDetail(detail) + local entry = buff and DC.CONSUMABLES and DC.CONSUMABLES[buff] + if entry and DC.ResolveItem then + local eff = nil + if type(effects) == "table" then + eff = effects[buff] + end + local item, icon = DC.ResolveItem(buff, eff) + if item then + local path = MX.IconPath(icon) + if path then + return { icon = path, item = item } + end + -- item resolved but no sourced icon (icon=nil in the data): + -- slot-generic icon, the tooltip still names the item + return { icon = MX.IconPath(sd and sd.icon), item = item, + generic = true } + end + end + -- unresolved buff (generic name without a matching effect line, or a + -- bare "spell N" detail): slot filled, item unknown + return { icon = MX.IconPath(sd and sd.icon), generic = true } +end + +-- Tooltip content of a consumable aura HAS cell, as a PURE array of +-- { text, color, wrap } (the WoW builder only applies colors): item name +-- + buff name; on the slot-generic fallback " active - item +-- unknown" + buff name + the pick-one alternatives. +function MX.HasTooltipLines(sd, detail, effects) + local vis = MX.ConsumableCellVisual(sd, detail, effects) + local buff = MX.BuffNameFromDetail(detail) or (sd and sd.full) or "?" + -- spell id rides along like before: ID-match details already embed + -- "(spell N)", name matches get the table-known id appended + local buffLine = detail + if type(detail) ~= "string" + or not string.find(detail, " (spell ", 1, true) then + local ids = MX.KnownSpellIds(buff) + if ids then + buffLine = buff .. " (spell " .. ids .. ")" + else + buffLine = buff + end + end + local lines = {} + if vis.item then + table.insert(lines, { text = vis.item, color = "hat" }) + table.insert(lines, { text = "Buff: " .. buffLine, color = "parch", + wrap = 1 }) + table.insert(lines, { text = "Slot " .. ((sd and sd.full) or "?"), + color = "parchDim", wrap = 1 }) + else + table.insert(lines, { text = ((sd and sd.full) or "?") + .. " active - item unknown", color = "hat" }) + table.insert(lines, { text = "Buff: " .. buffLine, color = "parch", + wrap = 1 }) + if sd and sd.items then + table.insert(lines, { text = "e.g. " .. sd.items, + color = "parchDim", wrap = 1 }) + end + end + return lines +end + -- Trinket monogram fallback (no icon texture known): first letter of each -- word, max 3 ("Hand of Justice" -> "HOJ"); single words use their first -- three letters ("Earthstrike" -> "EAR"). @@ -920,6 +1193,87 @@ function MX.ExpectedForString(slotId, expectTbl) return table.concat(parts, ", ") end +-- ------------------------------------------------------------------ +-- The ONE expectation-table resolver for the whole window. Both consumers +-- -- M.RenderHeader (header tiles + their "expected for:" tooltip) and +-- MX.Refresh (the model build behind the cells) -- must read the SAME +-- table, or the header drifts from the grid below it. +-- +-- Demo mode (core/demo.lua) swaps the whole table for a TRANSIENT layer +-- that expects all six school protection columns; it is built lazily here +-- if the mode was armed without one. Normal mode reads db.expectations, +-- falling back to the seed while the DB is not materialized yet. +-- +-- The demo layer is only legitimate over the SIMULATED store. DC.SimOwnsStore +-- keeps a real scan result from ever landing while demo mode is armed; this +-- is the belt-and-suspenders half of that guarantee. Should a gate ever be +-- missed again, grading real raid members against five columns that ship OFF +-- would fabricate MISSING cells and arm their whisper buttons -- so a +-- non-"sim" store degrades to the user's OWN expectations instead. A store +-- that does not exist yet (pre-ADDON_LOADED) is not a real scan and keeps +-- the layer. +-- ------------------------------------------------------------------ +function MX.ExpectTable(d) + local st = DC.store + if DC.demoMode and (not st or st.source == "sim") then + local demo = DC.demoExpectations + if not demo and DC.BuildDemoExpectations then + demo = DC.BuildDemoExpectations(d) + end + if demo then + return demo + end + end + -- Defensive re-top-up (2026-08-30 bug): EnsureExpectations normally + -- runs once, at ADDON_LOADED (core/init.lua) -- but a materialized + -- db.expectations that predates a slot added to DC.DEFAULT_EXPECT + -- since (e.g. WAIST, v0.6.3) is loaded as-is otherwise and stays + -- missing that key until the NEXT full ADDON_LOADED cycle actually + -- runs against the CURRENT seed. anyClassExpects (core/model.lua) + -- then finds the new slot true nowhere and the whole column stays + -- hidden -- reproduced offline in tools/luatests/test_expectations_topup.lua + -- with the exact real SavedVariables shape. EnsureExpectations is + -- idempotent and only fills nils (topUpLine, core/config.lua), so + -- calling it again here every refresh is side-effect-free and cheap + -- (~5 roles x a handful of classes x slot count) -- it self-heals + -- this class of bug the moment the window is drawn, without waiting + -- for a second reload. + if d and d.expectations and DC.EnsureExpectations then + DC.EnsureExpectations(d) + end + return (d and d.expectations) or DC.DEFAULT_EXPECT or {} +end + +-- Banner visibility. The store source drives it (test mode and demo mode +-- both run on the "sim" store), but demo mode shows it unconditionally: the +-- banner is the ONE visual marker that the extra columns are a mode and not +-- a measurement, and it must not be the thing that disappears if a real +-- store ever slips past the scan gates. +function MX.ShowBanner(source) + if source == "sim" then + return true + end + if DC.demoMode then + return true + end + return false +end + +-- Banner line for the simulated-raid banner (shown whenever the store +-- source is "sim"). Demo mode says so explicitly -- its extra columns are +-- a mode, not a scan result. +-- +-- Wording: "protection columns", NOT "resistance columns". What demo mode +-- reveals are the six school PROTECTION-POTION columns of the CONSUMABLES +-- tab; the RESIST tab has actual resistance columns and demo mode does not +-- touch it. Same wording as the /dc demo chat line (core/init.lua). +function MX.BannerText() + if DC.demoMode then + return "DEMO MODE - simulated raid \194\183 all six protection columns shown" + end + return "TEST MODE - simulated raid \194\183 chat output disabled" +end + -- Tab/filter state setters (state part is pure; re-render only in-game). function MX.SetTab(id) local n = table.getn(DC.TABS) @@ -963,7 +1317,7 @@ local S = DC.STATE local UI = { -- every frame reference + pool lives here (upvalue budget) headerTiles = {}, headerMax = 0, footerFS = {}, footerMax = 0, - groupHeads = {}, + groupHeads = {}, groupHeadMax = 0, rowPool = {}, rowMax = 0, cellIconN = 0, -- running index for named per-cell icon textures cellCornerN = 0, -- running index for named per-cell corner markers @@ -1019,6 +1373,14 @@ function M.CellTooltip(cell) if not (sd and cvm and row) then return end + -- Identity stamp for M.RenderWindow: this tooltip's text never prints + -- the player's name anywhere below (it is entirely slot/state driven), + -- so if the pooled cell is rebound to a DIFFERENT player while this + -- tooltip is still open (no OnEnter fires without real mouse movement, + -- so nothing else would rebuild it), the stale text would read as + -- fact for the new player with no way for the reader to tell. Stamped + -- fresh on every open; M.RenderWindow compares it before rebinding. + cell.dcTipPlayer = row.player and row.player.name local state = cvm.state -- Equipment cell with a known item: native -- item tooltip via SetHyperlink -- it includes the green enchant line, @@ -1134,6 +1496,17 @@ function M.CellTooltip(cell) local qc = M.qualityColors(e.quality) W.TipLine(e.name or sd.full, qc) W.TipLine(sd.full .. " - inventory slot " .. sd.invSlot, C.parchDim, 1) + elseif sd.kind == "aura" and sd.icon then + -- consumable cell: same ladder as the painter -- + -- item name + buff name, or " active - item unknown" + -- on the slot-generic fallback (MX.HasTooltipLines) + local p = row.player + local hLines = MX.HasTooltipLines(sd, cvm.detail, + p and p.auras and p.auras.effects) + for i = 1, table.getn(hLines) do + local hl = hLines[i] + W.TipLine(hl.text, C[hl.color] or C.parch, hl.wrap) + end else -- Fixed HAS tooltip content: buff name, -- spell ID ("ID before name"), example item, slot. ID-match @@ -1304,6 +1677,10 @@ function M.SumTooltip(sumf) if not row then return end + -- Identity stamp for M.RenderWindow -- see the same comment on + -- M.CellTooltip. Like the cell tooltip, nothing below prints the + -- player's name. + sumf.dcTipPlayer = row.player and row.player.name if not row.readable then W.TipLine("not readable - no claim", C.unbek) return @@ -1593,12 +1970,23 @@ function M.EnsureGroupHead(i) gh.pill = W.MakePill(gh, "DopingControlGroupHead" .. i .. "Pill") gh.pill:SetPoint("LEFT", gh.label, "RIGHT", 12, 0) W.AttachTooltip(gh.pill, M.PillTooltip, "ANCHOR_LEFT") + -- marks this as a scroll-window pooled widget, so M.RenderWindow can + -- tell "our tooltip, safe to close on rebind" from "some other addon's + -- (or Blizzard's own) tooltip, leave it alone" + gh.pill.dcPoolOwned = true gh.unread = W.MakeText(gh, 9, C.unbek) gh.unread:SetPoint("LEFT", gh.pill, "RIGHT", 10, 0) gh.gcount = W.MakeText(gh, 9, C.dim) gh.gcount:SetPoint("RIGHT", gh, "RIGHT", -10, 0) gh:SetScript("OnClick", M.GroupHeadClick) + -- closes a tooltip anchored to gh.pill if this frame gets hidden + -- outright (the leftover-hide loop in M.RenderWindow) -- see + -- M.GroupHeadOnHide for why the rebind-path check alone is not enough + gh:SetScript("OnHide", M.GroupHeadOnHide) UI.groupHeads[i] = gh + if i > (UI.groupHeadMax or 0) then + UI.groupHeadMax = i + end return gh end @@ -1683,6 +2071,9 @@ function M.EnsureRow(i) rf.wsp.text:SetText("PST") rf.wsp:SetScript("OnClick", M.WhisperClick) W.AttachTooltip(rf.wsp, M.WhisperTooltip, "ANCHOR_LEFT") + -- marks scroll-window pooled tooltip owners -- see rf.sum.dcPoolOwned + -- below for why M.RenderWindow needs this + rf.wsp.dcPoolOwned = true -- role badge (right-click cycles, so the factory's -- left-only registration is widened here) rf.badge = W.MakeBadge(rf, nil) @@ -1691,6 +2082,7 @@ function M.EnsureRow(i) MX.PAD + MX.NAME_COL_MIN + MX.CELL_GAP + 3, 0) rf.badge:SetScript("OnClick", M.BadgeClick) W.AttachTooltip(rf.badge, M.BadgeTooltip, "ANCHOR_LEFT") + rf.badge.dcPoolOwned = true -- sum (own mini frame so it can carry a tooltip) rf.sum = CreateFrame("Frame", nil, rf) -- pool child (factory exception) rf.sum:SetWidth(MX.SUM_COL_W) @@ -1699,8 +2091,16 @@ function M.EnsureRow(i) rf.sum.text = W.MakeText(rf.sum, 10, C.parch) rf.sum.text:SetPoint("CENTER", rf.sum, "CENTER", 0, 0) W.AttachTooltip(rf.sum, M.SumTooltip, "ANCHOR_LEFT") + -- marks this as a scroll-window pooled widget, so M.RenderWindow can + -- tell "our tooltip, safe to close on a real rebind" from "some other + -- addon's (or Blizzard's own) tooltip, leave it alone" + rf.sum.dcPoolOwned = true rf.cells = {} rf.cellMax = 0 + -- closes a tooltip anchored to any of this row's children if the row + -- gets hidden outright (the leftover-hide loop in M.RenderWindow) -- + -- see M.RowOnHide for why the rebind-path check alone is not enough + rf:SetScript("OnHide", M.RowOnHide) UI.rowPool[i] = rf if i > UI.rowMax then UI.rowMax = i @@ -1732,6 +2132,7 @@ function M.EnsureCell(rf, i) end cell = W.MakeCell(rf, nil, MX.CELL_W, MX.CELL_H) -- pool child (factory exception) W.AttachTooltip(cell, M.CellTooltip, "ANCHOR_CURSOR") + cell.dcPoolOwned = true -- see rf.sum.dcPoolOwned: scroll-window rebind marker rf.cells[i] = cell if i > rf.cellMax then rf.cellMax = i @@ -1752,9 +2153,10 @@ function M.EnsureIconTex(cell) local t = cell:CreateTexture("DopingControlCell" .. UI.cellIconN .. "Icon", "ARTWORK") t:SetPoint("CENTER", cell, "CENTER", 0, 0) - t:SetWidth(18) - t:SetHeight(18) - t:SetTexCoord(0.08, 0.92, 0.08, 0.92) + t:SetWidth(MX.CELL_ICON) + t:SetHeight(MX.CELL_ICON) + t:SetTexCoord(MX.ICON_CROP, 1 - MX.ICON_CROP, + MX.ICON_CROP, 1 - MX.ICON_CROP) t:Hide() cell.iconTex = t return t @@ -1832,6 +2234,10 @@ end -- (cached) rendering below (M.PaintLastCell). Icon preferred over -- monogram whenever item.texture is present. function M.PaintItemTile(cell, sd, state, item) + -- The cell's icon and FontString are created on demand (see W.MakeCell); + -- this path touches both, so materialize both before anything reads them. + W.CellIcon(cell) + W.CellText(cell) local bg, border, textColor local marker = nil if sd.kind == "trinket" then @@ -1854,7 +2260,7 @@ function M.PaintItemTile(cell, sd, state, item) border = qBorder or C.line end end - W.SetBackdropG(cell, bg, border) + W.SetCellBox(cell, DC_Cells.VariantFor(state, sd.kind, item and item.quality)) if marker then local mk = M.EnsureCornerTex(cell) W.SetVertexG(mk, marker) @@ -1888,7 +2294,11 @@ end -- the same guarded setter. Ench tiles keep their (dimmed) corner marker -- -- on a tile it is what encodes enchanted vs not. function M.PaintLastCell(cell, sd, last) - W.SetBackdropG(cell, C.unbekBg, C.unbekBorder) + -- The cell's icon and FontString are created on demand (see W.MakeCell); + -- this path touches both, so materialize both before anything reads them. + W.CellIcon(cell) + W.CellText(cell) + W.SetCellBox(cell, "UNKNOWN") local item = nil if (sd.kind == "ench" or sd.kind == "trinket") and type(last.item) == "table" then @@ -1961,6 +2371,10 @@ function M.PaintLastCell(cell, sd, last) end function M.PaintCell(cell, sd, cvm, row) + -- The cell's icon and FontString are created on demand (see W.MakeCell); + -- this path touches both, so materialize both before anything reads them. + W.CellIcon(cell) + W.CellText(cell) cell.dcSD = sd cell.dcVM = cvm cell.dcRow = row @@ -1976,7 +2390,19 @@ function M.PaintCell(cell, sd, cvm, row) end local icon = nil if state == S.HAS and (sd.kind == "aura" or sd.kind == "weapon") then - icon = cvm.icon -- only set when HAS; weapon: always nil + if sd.icon then + -- consumable slot (only SLOTS_CONSUMABLES carries a slot + -- icon): the ITEM behind the buff, never the aura texture -- + -- resolved item icon -> slot-generic icon -> green check + -- (MX.ConsumableCellVisual, spec "Display"). Runs on repaint + -- only (rebuild-on-scan), never per frame. + local p = row.player + local vis = MX.ConsumableCellVisual(sd, cvm.detail, + p and p.auras and p.auras.effects) + icon = vis.icon + else + icon = cvm.icon -- class buffs keep the aura texture + end elseif state == S.MISSING and sd.kind == "debuff" then icon = cvm.icon -- afflicted cell = debuff texture end @@ -2033,7 +2459,7 @@ function M.PaintCell(cell, sd, cvm, row) -- legacy trinket path (model without cell.item): unchanged local e = cvm.detail local qText, qBorder = M.qualityColors(e.quality) - W.SetBackdropG(cell, C.theadBg, qBorder) + W.SetCellBox(cell, DC_Cells.VariantFor(state, sd.kind, e.quality)) M.HideIconTex(cell) M.HideCornerTex(cell) if e.texture then @@ -2055,7 +2481,7 @@ function M.PaintCell(cell, sd, cvm, row) elseif state == S.HAS and icon then -- aura HAS with known buff texture: icon instead of the check -- glyph, green HAS backdrop kept - W.SetBackdropG(cell, C.hatBg, C.hatBorder) + W.SetCellBox(cell, "HAS") cell.text:Hide() cell.icon:Hide() M.HideCornerTex(cell) @@ -2066,7 +2492,7 @@ function M.PaintCell(cell, sd, cvm, row) elseif state == S.MISSING and icon then -- afflicted debuff cell: the debuff icon on the -- red MISSING backdrop -- same lazy icon mechanism as aura HAS - W.SetBackdropG(cell, C.fehltBg, C.fehltBorder) + W.SetCellBox(cell, "MISSING") cell.text:Hide() cell.icon:Hide() M.HideCornerTex(cell) @@ -2079,7 +2505,7 @@ function M.PaintCell(cell, sd, cvm, row) -- visual): on a warning-signal tab the absence of a debuff is -- the unremarkable normal state, not a fulfilled expectation -- -- NO green check. Afflicted cells keep the red tile + icon above. - W.SetBackdropG(cell, C.skip, C.skipBorder) + W.SetCellBox(cell, "SKIP") cell.icon:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2091,7 +2517,7 @@ function M.PaintCell(cell, sd, cvm, row) -- RESIST: neutral tile, the number is the text -- 0 is legitimate -- and renders dimmed (faint), any other value in parch. Never -- green/red -- this tab is display only, never a gap. - W.SetBackdropG(cell, C.theadBg, C.line) + W.SetCellBox(cell, "NEUTRAL") cell.icon:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2112,7 +2538,7 @@ function M.PaintCell(cell, sd, cvm, row) -- parchment text, faint on a plain 0. Never red: a hit number -- below its cap is information on a display tab, not a gap. local hs = MX.HitCellStyle(cvm) - W.SetBackdropG(cell, C[hs.bg] or C.theadBg, C[hs.border] or C.line) + W.SetCellBox(cell, hs.bg == "hatBg" and "HAS" or "NEUTRAL") cell.icon:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2121,7 +2547,7 @@ function M.PaintCell(cell, sd, cvm, row) W.SetAlphaG(cell.text, 1) cell.text:Show() elseif state == S.HAS then - W.SetBackdropG(cell, C.hatBg, C.hatBorder) + W.SetCellBox(cell, "HAS") cell.text:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2133,7 +2559,7 @@ function M.PaintCell(cell, sd, cvm, row) W.SetAlphaG(cell.icon, 1) cell.icon:Show() elseif state == S.MISSING then - W.SetBackdropG(cell, C.fehltBg, C.fehltBorder) + W.SetCellBox(cell, "MISSING") cell.text:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2145,7 +2571,7 @@ function M.PaintCell(cell, sd, cvm, row) W.SetAlphaG(cell.icon, 1) cell.icon:Show() elseif state == S.UNKNOWN then - W.SetBackdropG(cell, C.unbekBg, C.unbekBorder) + W.SetCellBox(cell, "UNKNOWN") cell.icon:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2154,7 +2580,7 @@ function M.PaintCell(cell, sd, cvm, row) W.SetAlphaG(cell.text, 1) cell.text:Show() else -- NOTEXP: near-invisible middle dot - W.SetBackdropG(cell, C.skip, C.skipBorder) + W.SetCellBox(cell, "SKIP") cell.icon:Hide() M.HideIconTex(cell) M.HideCornerTex(cell) @@ -2332,6 +2758,18 @@ function M.PaintRow(rf, row, layout, slots, now) local col = layout.cols[i] local cell = M.EnsureCell(rf, i) if cell.dcX ~= col.x or cell.dcW ~= col.w then + if cell.dcW ~= col.w then + -- The cell sprite is width-specific: DC_Cells.Coords picks + -- a DIFFERENT sheet tile for 30 and for 34 (the trinket + -- columns). M.PaintCell short-circuits on an unchanged + -- style key -- and the plain state keys ("HAS"/"MISSING"/ + -- ...) carry no width -- so a pooled cell reused at the + -- other width would keep the old tile's texcoord and + -- render its baked 1px border squeezed or stretched. + -- W.SetCellBox guards on the width itself, but never gets + -- called; dropping the style key here is what lets it run. + cell.dcStyle = nil + end cell.dcX = col.x cell.dcW = col.w cell:ClearAllPoints() @@ -2561,7 +2999,8 @@ function M.BuildBody(f) W.SetBackdropG(banner, C.bannerYellowBg, C.bannerYellowBorder) banner.text = W.MakeText(banner, 9, C.bannerYellowText) banner.text:SetPoint("CENTER", banner, "CENTER", 0, 0) - banner.text:SetText("TEST MODE - simulated raid \194\183 chat output disabled") + banner.dcText = MX.BannerText() -- cache + text stay in step (see M.Render) + banner.text:SetText(banner.dcText) banner:Hide() UI.banner = banner @@ -2589,6 +3028,41 @@ function M.BuildBody(f) UI.scrollChild:SetHeight(100) UI.scrollFrame:SetScrollChild(UI.scrollChild) + -- Scrolling now changes WHICH rows are materialized, so the window has + -- to be redrawn on top of whatever UIPanelScrollFrameTemplate's own + -- OnVerticalScroll already does. That template handler is the ONLY + -- place in FrameXML that re-syncs the scrollbar thumb to the new + -- offset (scrollbar:SetValue(arg1)) and enables/disables the up/down + -- arrow buttons at the ends of the range + -- (_wow-reference/FrameXML-1.12.1/FrameXML/UIPanelTemplates.xml: + -- 191-206) -- overwriting it outright instead of chaining would leave + -- the up arrow permanently disabled and the down arrow never disabling + -- at the bottom. Chained the same way W.AttachTooltip chains + -- OnEnter/OnLeave (ui/widgets.lua:307-308): capture the template's + -- handler and call it FIRST, on every event -- only OUR repaint is + -- throttled, never the template's own bookkeeping. + local dcOldOnVerticalScroll = UI.scrollFrame:GetScript("OnVerticalScroll") + UI.scrollFrame:SetScript("OnVerticalScroll", function() + if dcOldOnVerticalScroll then + dcOldOnVerticalScroll() + end + local t = GetTime() + if t < (UI.nextWindowDraw or 0) then + -- throttled: remember a redraw is owed. The self-unhooking + -- OnUpdate below catches up once the throttle window opens + -- back up, so a dropped event's final position (a fast wheel + -- spin, or the end of a quick thumb-drag) is never silently + -- lost -- see M.CatchUpScrollWindow. + UI.windowDirty = true + if not UI.scrollFrame:GetScript("OnUpdate") then + UI.scrollFrame:SetScript("OnUpdate", M.CatchUpScrollWindow) + end + return + end + UI.nextWindowDraw = t + 0.05 + M.RenderWindow() + end) + -- empty state (no scan yet) UI.emptyFS = W.MakeText(f, 11, C.parch) UI.emptyFS:SetPoint("CENTER", UI.scrollFrame, "CENTER", 0, 14) @@ -2744,7 +3218,7 @@ function M.RenderHeader(layout, slots, yTop, frameW) hr:SetPoint("TOPLEFT", UI.main, "TOPLEFT", LC.BAR_X_OFF, yTop) hr:SetWidth(frameW - LC.BAR_X_OFF * 2) local d = M.db() - local expectTbl = (d and d.expectations) or DC.DEFAULT_EXPECT or {} + local expectTbl = MX.ExpectTable(d) local nSlots = table.getn(slots) for i = 1, nSlots do local sd = slots[i] @@ -2771,7 +3245,11 @@ function M.RenderHeader(layout, slots, yTop, frameW) -- texture; the -- fallback for icon-less debuff columns is a monogram of the -- name (slot.label is nil on debuff columns by design). - if sd.icon then + if sd.kind == "debuff" and sd.icon then + -- icon header tiles are a DEBUFFS feature (dynamic columns + -- carry the debuff texture as a FULL path). Consumable slots + -- now carry a BARE slot-generic icon for the CELL fallback + -- ladder -- their header keeps the label/sub text. local it = M.EnsureHeaderIcon(tile, i) W.SetTextureG(it, sd.icon) it:Show() @@ -2853,53 +3331,236 @@ function M.SyncScrollRange() end end -function M.RenderRows(vm, layout, slots) +-- Flatten the view model into one ordered list of render units (group head or +-- data row) plus their heights. Built once per refresh so a scroll does not +-- have to re-walk the model, and so the window math sees real heights. +function M.BuildUnits(vm) local d = M.db() local collapsed = (d and d.collapse) or {} local gapsOnly = MX.state.gapsOnly - local now = GetTime() - local y = 0 - local rowIdx = 0 + local units, heights = {}, {} + local visIdx = 0 local nGroups = table.getn(vm.groups) for gi = 1, nGroups do local g = vm.groups[gi] local isCollapsed = collapsed[g.role] and true or false - local gh = M.EnsureGroupHead(gi) - gh:ClearAllPoints() - gh:SetPoint("TOPLEFT", UI.scrollChild, "TOPLEFT", MX.PAD, y) - gh:SetWidth(layout.totalW - MX.PAD * 2) - M.PaintGroupHead(gh, g, isCollapsed) - gh:Show() - y = y - MX.GROUP_H + table.insert(units, { kind = "head", g = g, collapsed = isCollapsed }) + table.insert(heights, MX.GROUP_H) if not isCollapsed then local nRows = table.getn(g.rows) for ri = 1, nRows do local row = g.rows[ri] if MX.RowVisible(row, gapsOnly) then - rowIdx = rowIdx + 1 - local rf = M.EnsureRow(rowIdx) - rf:ClearAllPoints() - rf:SetPoint("TOPLEFT", UI.scrollChild, "TOPLEFT", 0, y) - rf:SetWidth(layout.totalW) - M.PaintRow(rf, row, layout, slots, now) - -- zebra by VISIBLE row index, not by the roster - -- position: with "Gaps only" on or a group collapsed - -- the stripes must still alternate on screen - if math.mod(rowIdx, 2) == 0 then - rf.zebra:Show() - else - rf.zebra:Hide() - end - rf:Show() - y = y - MX.ROW_H + -- zebra by VISIBLE index, exactly as before: with "gaps + -- only" on or a group collapsed the stripes must still + -- alternate on screen + visIdx = visIdx + 1 + table.insert(units, { + kind = "row", row = row, + zebra = (math.mod(visIdx, 2) == 0), + }) + table.insert(heights, MX.ROW_H) end end end end - for i = rowIdx + 1, UI.rowMax do + return units, heights +end + +function M.RenderRows(vm, layout, slots) + local units, heights = M.BuildUnits(vm) + local total = 0 + for i = 1, table.getn(heights) do + total = total + heights[i] + end + UI.units = { units = units, heights = heights, layout = layout, + slots = slots, total = total } + UI.windowFirst = nil -- force a full window draw + UI.windowDirty = false -- this draw covers whatever was owed + M.RenderWindow() + return total -- content height, same contract as before +end + +-- Self-unhooking catch-up for the scroll throttle in M.BuildBody's +-- OnVerticalScroll handler: an event that lands inside the 0.05s window +-- only sets UI.windowDirty and returns without drawing (see there). +-- Without a follow-up, that dropped event's final scroll position would +-- never get painted -- a fast wheel spin (a mouse-wheel notch moves the +-- thumb by scrollBar:GetHeight()/2, roughly ten rows here, well past the +-- two spare units MX.VisibleWindow keeps) or the end of a quick +-- thumb-drag would leave a visibly blank strip, or a window that does not +-- match where the thumb ended up, until some UNRELATED refresh happened +-- to run later. Hooked onto UI.scrollFrame only while a redraw is owed +-- (OnVerticalScroll attaches it on the first throttled event), and +-- unhooks itself the instant nothing is pending -- no permanent +-- per-frame OnUpdate (project rule: hang up in the idle case). +function M.CatchUpScrollWindow() + local t = GetTime() + if UI.windowDirty and t >= (UI.nextWindowDraw or 0) then + UI.windowDirty = false + UI.nextWindowDraw = t + 0.05 + M.RenderWindow() + end + if not UI.windowDirty then + this:SetScript("OnUpdate", nil) + end +end + +-- Whether `owner` (a GameTooltip owner frame) is one of `rf`'s pooled +-- tooltip-bearing children -- the whisper button, role badge, sum, or one +-- of its slot cells. Delegates to MX.OwnsTooltip (pure, offline-tested); +-- this wrapper only exists to supply the real frame references. Two call +-- sites: M.RenderWindow, to find which row (if any) is about to be +-- rebound out from under a currently open tooltip; M.RowOnHide, for a row +-- about to be hidden entirely (see there). Cheap pointer comparisons only, +-- no allocation; only ever called while a tooltip is actually shown and +-- owned by one of our pooled widgets (rare). +function M.RowOwnsTooltip(rf, owner) + return MX.OwnsTooltip(rf.wsp, rf.badge, rf.sum, rf.cells, rf.cellMax, owner) +end + +-- A pooled row frame being hidden entirely (the leftover-hide loop in +-- M.RenderWindow when the visible unit count shrinks -- a smaller roster, +-- a collapsed group, the "gaps only" filter -- or any future path that +-- hides a row) does NOT go through M.RenderWindow's per-unit rebind check +-- at all: that check only runs for units still inside [first, last] this +-- redraw. There is also no other OnHide handler anywhere in this addon +-- that touches GameTooltip (confirmed by grep across the repo). Without +-- this, a tooltip anchored to a widget on a row that just scrolled, +-- collapsed, or filtered out of view would keep showing that vanished +-- player's values, unboundedly, on an invisible frame -- the same failure +-- class fixed in M.RenderWindow's rebind path (fix round 2), reached +-- through a different door. Unconditional close, not routed through +-- MX.ShouldHideTooltip: a row that is disappearing has no "new identity" +-- to compare against the way a row being rebound in place does. +function M.RowOnHide() + if not (GameTooltip and GameTooltip:IsShown()) then + return + end + -- W.dcTipOwner, not GameTooltip's non-existent "GetOwner" method (see + -- ui/widgets.lua's W.dcTipOwner / W.AttachTooltip for the full + -- reasoning). GameTooltip:IsOwned(owner) IS real 1.12 API (used + -- e.g. by FrameXML/ActionButton.lua and Blizzard_TalentUI) and serves + -- here as the gegenprobe: it can only make this MORE conservative + -- (refuse to hide if our bookkeeping and the tooltip's real owner ever + -- disagree), never less -- matching the "never touch a foreign + -- tooltip" rule dcPoolOwned already enforces. + local owner = W.dcTipOwner + if owner and owner.dcPoolOwned and GameTooltip:IsOwned(owner) + and M.RowOwnsTooltip(this, owner) then + GameTooltip:Hide() + end +end + +-- Same reasoning as M.RowOnHide, for the group-head pool -- its one +-- tooltip-bearing child is the ready/status pill. +function M.GroupHeadOnHide() + if not (GameTooltip and GameTooltip:IsShown()) then + return + end + -- W.dcTipOwner, not GameTooltip's non-existent "GetOwner" method -- see M.RowOnHide. + local owner = W.dcTipOwner + if owner and owner.dcPoolOwned and GameTooltip:IsOwned(owner) + and owner == this.pill then + GameTooltip:Hide() + end +end + +-- Draws only the units inside the scroll window. Pool slots are handed out +-- by window position rather than data index (MX.NextWindowSlot), so a +-- scrolled window still uses the SAME roughly-22-slot pool -- what changes +-- is which unit each slot paints. Every call here repaints the WHOLE +-- window (there is no per-row diffing against the previous slice), which +-- is exactly why the caller throttles how often this runs. +-- +-- Viewport height: LC.MAX_SCROLL_H, the fixed design cap, rather than +-- UI.scrollFrame:GetHeight(). M.Render() computes contentH via +-- M.RenderRows() (which calls this) BEFORE it calls +-- UI.scrollFrame:SetHeight(scrollH) later in the same function -- reading +-- GetHeight() here would therefore see last render's height (0 on the very +-- first paint), not this one's. The constant sidesteps that ordering +-- hazard entirely; MX.VisibleWindow already clamps `last` to the real unit +-- count, so a short list (scrollH < MAX_SCROLL_H) just gets a slightly +-- larger-than-strictly-needed window, never an under-sized one. +function M.RenderWindow() + local c = UI.units + if not c then + return + end + local offset = UI.scrollFrame:GetVerticalScroll() or 0 + local first, last, firstY = + MX.VisibleWindow(c.heights, LC.MAX_SCROLL_H, offset) + if UI.windowFirst == first then + return -- same slice, nothing to redraw + end + -- A pooled widget whose row is about to be rebound may still own an + -- open tooltip. GameTooltip is a SINGLETON shared with every other + -- addon and Blizzard's own UI (SetOwner in ui/widgets.lua's + -- W.AttachTooltip), so the + -- owner check (dcPoolOwned, set at widget creation) always applies + -- first -- a foreign tooltip is never touched here, full stop. Past + -- that, the actual close/keep decision is MX.ShouldHideTooltip (pure, + -- offline-tested) -- see its comment for the identity-stamp vs. + -- sliceMoved reasoning. + local sliceMoved = (UI.windowSliceFirst ~= first) + UI.windowSliceFirst = first + local tipOwner = nil + if GameTooltip and GameTooltip:IsShown() then + -- W.dcTipOwner, not GameTooltip's non-existent "GetOwner" method -- see M.RowOnHide. + local o = W.dcTipOwner + if o and o.dcPoolOwned and GameTooltip:IsOwned(o) then + tipOwner = o + end + end + UI.windowFirst = first + local now = GetTime() + local y = -firstY + local headSlot, rowSlot = 0, 0 + for i = first, last do + local u = c.units[i] + local kind, slot + kind, slot, headSlot, rowSlot = MX.NextWindowSlot(u, headSlot, rowSlot) + if kind == "head" then + local gh = M.EnsureGroupHead(slot) + if tipOwner == gh.pill + and MX.ShouldHideTooltip(gh.pill.dcTipPlayer, nil, sliceMoved) then + GameTooltip:Hide() + tipOwner = nil -- handled -- at most one unit owns it + end + gh:ClearAllPoints() + gh:SetPoint("TOPLEFT", UI.scrollChild, "TOPLEFT", MX.PAD, y) + gh:SetWidth(c.layout.totalW - MX.PAD * 2) + M.PaintGroupHead(gh, u.g, u.collapsed) + gh:Show() + y = y - MX.GROUP_H + else + local rf = M.EnsureRow(slot) + if tipOwner and M.RowOwnsTooltip(rf, tipOwner) then + local newName = u.row.player and u.row.player.name + if MX.ShouldHideTooltip(tipOwner.dcTipPlayer, newName, sliceMoved) then + GameTooltip:Hide() + end + tipOwner = nil -- handled -- at most one unit owns it + end + rf:ClearAllPoints() + rf:SetPoint("TOPLEFT", UI.scrollChild, "TOPLEFT", 0, y) + rf:SetWidth(c.layout.totalW) + M.PaintRow(rf, u.row, c.layout, c.slots, now) + if u.zebra then + rf.zebra:Show() + else + rf.zebra:Hide() + end + rf:Show() + y = y - MX.ROW_H + end + end + for i = rowSlot + 1, UI.rowMax do UI.rowPool[i]:Hide() end - return -y -- content height + for i = headSlot + 1, (UI.groupHeadMax or 0) do + UI.groupHeads[i]:Hide() + end end function M.RenderFooter(vm, layout, slots, footerY, frameW) @@ -2966,7 +3627,14 @@ function M.Render() -- banner (source read from the store -- the one permitted raw read) local yTop = LC.CONTENT_TOP - if st.source == "sim" then + if MX.ShowBanner(st.source) then + -- test mode and demo mode share the banner; the text is cached on + -- the frame so a re-render only calls SetText when it changed + local btxt = MX.BannerText() + if UI.banner.dcText ~= btxt then + UI.banner.dcText = btxt + UI.banner.text:SetText(btxt) + end UI.banner:ClearAllPoints() UI.banner:SetPoint("TOPLEFT", UI.main, "TOPLEFT", LC.BAR_X_OFF, yTop) UI.banner:SetWidth(frameW - LC.BAR_X_OFF * 2) @@ -3026,7 +3694,9 @@ function MX.Refresh() MX.SweepWhispers(GetTime()) -- evict stale whisper-cooldown entries local st = M.store() local d = M.db() - local expectTbl = (d and d.expectations) or DC.DEFAULT_EXPECT or {} + -- demo mode substitutes its own transient expectation layer here -- + -- the SAME resolver M.RenderHeader uses, so header and cells agree + local expectTbl = MX.ExpectTable(d) -- pass GetTime() as the model's `now` (optional 5th -- build param) so UNKNOWN cells with cached data get -- cell.last.age, and db.skillBooks as the 6th so the hit columns @@ -3045,7 +3715,10 @@ function MX.Show() MX.Refresh() local db = DopingControlDB local st = DC.store - if MX.WantScanOnShow(db and db.testMode, st and st.scanAt, GetTime()) + -- demo mode owns the store exactly like test mode does, so the + -- staleness auto-scan must not fire for it either (DC.SimOwnsStore): + -- the sim store is stamped at build time and goes stale after 60 s + if MX.WantScanOnShow(DC.SimOwnsStore(db), st and st.scanAt, GetTime()) and DC_Scan and DC_Scan.Start then DC_Scan.Start() end diff --git a/ui/options.lua b/ui/options.lua index 1956fb1..d2624c7 100644 --- a/ui/options.lua +++ b/ui/options.lua @@ -149,10 +149,12 @@ O.GRID_LABEL_W = 74 -- role/class label column (expectation grid) O.BOOK_NAME_W = 96 -- player-name column (skill book grid) O.ROW_H = 22 -- one grid row's height, both grids --- equipment has the most configurable slots (11 armor enchants + neck + --- 2 rings; trinkets are item tiles and never configurable, see --- slotsForTab) -- the widest grid the pools have to serve. -O.MAX_SLOT_COLS = 14 +-- consumables (v6 slot model) has the most configurable slots (22 incl. +-- the five school protection columns); +-- equipment follows with 14 (11 armor enchants + neck + 2 rings -- +-- trinkets are item tiles and never configurable, see slotsForTab). +-- The widest grid the pools have to serve. +O.MAX_SLOT_COLS = 22 -- x-offset of the 20 px check button inside its O.CELL_W-wide column O.CELL_INSET = math.floor((O.CELL_W - 20) / 2) @@ -489,6 +491,17 @@ function O.ClassTitle(class) .. string.lower(string.sub(class, 2)) end +-- Label of the demo-mode button. Demo mode is TRANSIENT (core/demo.lua: +-- never SavedVariables), so it gets a self-relabeling button rather than a +-- checkbox -- the checkbox row above is bound to db fields, and a checkbox +-- whose state dies on reload would read like a broken setting. +function O.DemoButtonLabel() + if DC.demoMode then + return "Demo mode: ON (click to stop)" + end + return "Demo mode: OFF (click to start)" +end + -- ================================================================== -- WOW SECTION -- frame, widgets, wiring -- ================================================================== @@ -504,7 +517,7 @@ if CreateFrame then -- O.GRID_LABEL_W label column O.COL_PITCH column pitch -- O.BOOK_NAME_W player-name column O.ROW_H row pitch -- O.CELL_INSET check button inset O.MAX_SLOT_COLS widest grid - -- The two grids differ in column count (14 equipment slots vs. 15 + -- The two grids differ in column count (22 consumable slots vs. 15 -- weapon skills); the frame is BUILT at the wider of the two and -- narrowed per tab by ApplyEditorWidth, so no widget is ever created -- against a width it later has to grow past. @@ -558,9 +571,9 @@ if CreateFrame then elseif tab == "CLASSBUFFS" then return DC.SLOTS_CLASSBUFFS or {} elseif tab == "EQUIPMENT" then - -- only kind=="ench" slots are configurable here (14: the 11 - -- armor slots + neck/rings, which are enchantable on this - -- server -- data/enchants.lua). T1/T2 (trinket item tiles) + -- only kind=="ench" slots are configurable here (15: the 11 + -- armor slots + neck/rings/waist, which are enchantable on + -- this server -- data/enchants.lua). T1/T2 (trinket item tiles) -- have no enchant question to ask and never appear: the -- matrix always shows them (item present/absent decides, -- expectations are never consulted for tiles). @@ -1536,6 +1549,42 @@ if CreateFrame then place(cbTest, -28) AddTooltip(cbTest, "Replace the scan with a fixed simulated raid.\nChat outputs are disabled while active.") + -- Demo mode sits right below the test-mode row: same idea (the + -- simulated raid), plus a transient expectation layer that shows + -- all six school protection columns at once. A BUTTON, not a + -- checkbox -- the state is never saved, so it relabels itself + -- instead of pretending to be a stored setting. + local btnDemo = CreateButton(f, "DopingControlOptDemoBtn", + O.DemoButtonLabel(), 200, + function() + -- Capture the widget BEFORE SetDemoMode runs. `this` is a + -- plain global the 1.12 dispatcher sets per script call and + -- never restores: SetDemoMode synchronously drives + -- DC_Matrix.Refresh -> M.Render, whose leftover-hide loop + -- Hide()s pooled rows and group heads that carry their own + -- OnHide handlers (ui/matrix.lua M.RowOnHide / + -- M.GroupHeadOnHide -- they read `this` themselves). After + -- that nested dispatch `this` is the last-hidden widget, and + -- a row frame is a plain Frame with no SetText. Same order + -- the other handlers in this file already keep + -- (GridCellOnClick, ResetOnClick): finish the `this` reads + -- and writes before refreshing anything. + local btn = this + if DC.SetDemoMode then + DC.SetDemoMode(not DC.demoMode) + end + btn:SetText(O.DemoButtonLabel()) + end) + place(btnDemo, -28) + AddTooltip(btnDemo, "Simulated raid with ALL SIX protection columns" + .. " (fire, frost, nature, shadow, holy, arcane) shown at once." + .. "\nYour saved expectations are left untouched, and demo mode" + .. " is never saved - it ends with the next reload." + -- the demo layer is snapshotted on entry (core/demo.lua), so + -- grid edits made while it is armed do not reach the matrix + .. "\nThe view is a snapshot: restart demo mode to pick up" + .. " expectation changes made while it is running.") + local cbMinimap = CreateCheckbox(f, "Show minimap button", function() return db().showMinimapButton end, function(v) @@ -1788,6 +1837,11 @@ if CreateFrame then end RefreshEditor() -- grid/books pools + tab visibility + buttons ResetDisarm(getglobal("DopingControlOptResetBtn")) + -- demo mode can have been toggled by /dc demo meanwhile + local bd = getglobal("DopingControlOptDemoBtn") + if bd then + bd:SetText(O.DemoButtonLabel()) + end end) -- ESC closes @@ -1827,14 +1881,18 @@ if CreateFrame then f:Show() end - -- external state changes (/dc test, minimap toggle) can resync an - -- open options window + -- external state changes (/dc test, /dc demo, minimap toggle) can + -- resync an open options window function O.Refresh() if frame and frame:IsShown() then for i = 1, table.getn(checkboxes) do local cb = checkboxes[i] cb:SetChecked(cb.dcGet() and 1 or nil) end + local bd = getglobal("DopingControlOptDemoBtn") + if bd then + bd:SetText(O.DemoButtonLabel()) + end RefreshEditor() -- a fresh scan can add or drop book rows end end diff --git a/ui/report.lua b/ui/report.lua index 40194e4..1863c86 100644 --- a/ui/report.lua +++ b/ui/report.lua @@ -59,6 +59,22 @@ 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) @@ -135,7 +151,18 @@ end function R.BuildLines(store, dbt, tab) store = store or DC.store or { players = {} } tab = tab or "CONSUMABLES" - local expect = (dbt and dbt.expectations) or DC.DEFAULT_EXPECT or {} + -- 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 @@ -234,7 +261,7 @@ if CreateFrame then end local line = R.QueueTick(queue, GetTime(), currentSource()) if line then - SendChatMessage(line, R.CHAT_TYPE) + SendChatMessage(R.ChatSafe(line), R.CHAT_TYPE) UpdateSendState() end if queue.done or queue.aborted then diff --git a/ui/widgets.lua b/ui/widgets.lua index a5d5311..d355d75 100644 --- a/ui/widgets.lua +++ b/ui/widgets.lua @@ -44,6 +44,88 @@ W.SOLID_BACKDROP = { 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 @@ -125,28 +207,117 @@ end -- ------------------------------------------------------------------ -- Cell factory: 30x23 state cell (the 30x23 size is part of the fixed --- visual layout). Carries one texture region (check/cross glyph OR trinket --- item icon) and one FontString (?, skip dot, trinket monogram); the --- matrix paint code shows exactly one of them per state. +-- 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:SetBackdrop(W.SOLID_BACKDROP) c:EnableMouse(true) - c.icon = c:CreateTexture(nil, "ARTWORK") - c.icon:SetPoint("CENTER", c, "CENTER", 0, 0) - c.icon:SetWidth(16) - c.icon:SetHeight(16) - c.icon:Hide() - c.text = c:CreateFontString(nil, "OVERLAY") - W.ApplyFont(c.text, 9) - c.text:SetPoint("CENTER", c, "CENTER", 0, 0) + 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. @@ -247,6 +418,21 @@ function W.TipDouble(left, right, c, cr) 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" @@ -258,6 +444,7 @@ function W.AttachTooltip(frame, builder, anchor) end if GameTooltip and this.dcTipBuilder then GameTooltip:SetOwner(this, this.dcTipAnchor) + W.dcTipOwner = this this.dcTipBuilder(this) GameTooltip:Show() end @@ -266,6 +453,9 @@ function W.AttachTooltip(frame, builder, anchor) if this.dcOldLeave then this.dcOldLeave() end + if W.dcTipOwner == this then + W.dcTipOwner = nil + end if GameTooltip then GameTooltip:Hide() end