From d51d2556ef1ca2e19fe1e5a6517a9030c72f718b Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Wed, 4 Nov 2026 21:39:57 -0400 Subject: [PATCH] Release 3.8.2: healing predictions and native API improvements --- CHANGELOG.md | 25 + Docs/CAST_TRACKING_REVIEW.md | 46 ++ Docs/CLASSICAPI_PFUI_REVIEW_382.md | 208 +++++++ Docs/CLASSICAPI_UPDATES.md | 85 ++- Docs/HEALING_PREDICTION_REVIEW.md | 70 +++ Docs/PERFORMANCE.md | 24 +- Docs/PERFORMANCE_TEST_382.md | 93 +++ README.md | 16 +- ShaguTweaks.toc | 3 +- THIRD_PARTY_NOTICES.md | 11 + api.lua | 55 ++ compat-dragonflightui.lua | 1 + libs/libcast.lua | 425 ++----------- libs/libpredict.lua | 823 ++++++++----------------- main.lua | 7 + mods/actionbar-grid.lua | 35 ++ mods/actionbar-move.lua | 15 +- mods/actionbar-range-color.lua | 86 +++ mods/auto-stance.lua | 2 +- mods/castbar-move.lua | 8 +- mods/equip-compare.lua | 164 +++++ mods/health-numbers.lua | 19 +- mods/loss-of-control.lua | 8 +- mods/macro-icons.lua | 101 ++- mods/macro-tweaks.lua | 41 +- mods/move-unitframes.lua | 2 +- mods/superwow.lua | 65 +- mods/swing-timer.lua | 132 +++- mods/target-castbar.lua | 10 +- mods/totem-timers.lua | 8 +- mods/unitframes-energy-tick.lua | 209 ++++--- mods/unitframes-healing-prediction.lua | 72 +++ mods/unitframes-standalone-player.lua | 24 +- tests/README.md | 63 +- tests/run_all.sh | 24 + tests/test_actionbar_grid.lua | 52 +- tests/test_actionbar_movers.lua | 2 + tests/test_cast_tracking.lua | 93 +++ tests/test_castbar_mover.lua | 1 + tests/test_energy_tick.lua | 80 +++ tests/test_equip_compare_native.lua | 107 ++++ tests/test_healing_prediction.lua | 129 ++++ tests/test_healing_shared.lua | 35 ++ tests/test_health_event_routing.lua | 34 + tests/test_loss_of_control_style.lua | 1 + tests/test_macro_native.lua | 86 +++ tests/test_range_events.lua | 44 ++ tests/test_runtime_helpers.lua | 59 ++ tests/test_standalone_player.lua | 2 + tests/test_swing_native.lua | 149 +++++ 50 files changed, 2680 insertions(+), 1174 deletions(-) create mode 100644 Docs/CAST_TRACKING_REVIEW.md create mode 100644 Docs/CLASSICAPI_PFUI_REVIEW_382.md create mode 100644 Docs/HEALING_PREDICTION_REVIEW.md create mode 100644 Docs/PERFORMANCE_TEST_382.md create mode 100644 mods/unitframes-healing-prediction.lua create mode 100644 tests/run_all.sh create mode 100644 tests/test_cast_tracking.lua create mode 100644 tests/test_energy_tick.lua create mode 100644 tests/test_equip_compare_native.lua create mode 100644 tests/test_healing_prediction.lua create mode 100644 tests/test_healing_shared.lua create mode 100644 tests/test_health_event_routing.lua create mode 100644 tests/test_macro_native.lua create mode 100644 tests/test_range_events.lua create mode 100644 tests/test_runtime_helpers.lua create mode 100644 tests/test_swing_native.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd816f..e7dce05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ Changes in this fork (OEVG/ShaguTweaks-ClassicAPI), newest first. Each entry is This fork is based on [Dusk/ShaguTweaks-ClassicAPI](https://octowow.st/git/Dusk/ShaguTweaks-ClassicAPI) at commit `4391564` (2026-09-08). Earlier changes are in that repository's history. +## 3.8.2 — 2026-11-04 — Healing predictions and native API improvements + +- Validation: 25 Lua 5.0 regression suites pass; changed/new Lua files parse with Lua 5.0. The user confirmed range/healing/cast behavior and the final energy/mana, macro and equipment-comparison paths in game. Profiling remains a separate follow-up; no measured performance gain is claimed. +- Consolidate the comparison controller's repeated callbacks and visible-tooltip lookup after user validation of the ownership fix. Preserve the tested deferred hide/re-show recovery and older-API fallback; no debug command or polling driver is added. +- Fix unstable native equipment comparisons during Bagshui/stock tooltip refresh: defer source closure checks, preserve unchanged comparisons, and rebind/refill native tooltips whose ownership was cleared by Hide. Own comparisons through the hovered item button instead of another tooltip, and recover independent comparison hides on events without polling. Equipment/item-cache changes invalidate the display; sources that stay hidden and Shift release still clear it. +- Improve Energy & Mana Tick: accept ClassicAPI login payloads, reset samples between power modes, start the five-second display only from mana-costing casts (including channel-stop ownership), and track regen phase independently so mid-period gains do not reset it. Native duration fills move the spark without Lua OnUpdate; older APIs retain visible-only animation. Resize the spark with its bar. +- Let current ClassicAPI own ordinary macro icons/directives and preserve chosen icons. Macro Icons now adapts only legacy `--showtooltip` and `/pfcast` hints through native display metadata, releases edited/logout overrides, and polls only conditional custom hints. Defer to SuperCleveRoidMacros. Macro Tweaks keeps history filtering and missing interactive aliases, using native command parsing without wrapping all chat messages on current APIs; older supported paths remain. +- Equip Compare uses native full-link comparisons when available, including filled ring/trinket slots, weapons, equipped headers and stat differences. Preserve Shift gating/placement, batch item callbacks, hide comparisons with their owner, and include item-link tooltips. Older APIs retain the previous construction path. +- Remove the temporary `/staction` diagnostic, refresh current integration notes while retaining the historical review, and add a single Lua 5.0 regression runner that discovers all test suites and reports failures. +- Route health and power number events independently for player, target and pet; retain full refreshes for startup, target changes and pet replacement, plus alternate druid power updates. +- Adopt pfUI's native duration-object approach for swing/wand fill and countdown text when supported. Attach the spark to the fill texture; schedule completion/grace and Hunter wind-up boundaries with cancellable timers; reconcile queued-attack colors on action-state events. Preserve classic artwork, positions and behavior. No Lua OnUpdate driver on the native path; older supported APIs retain a throttled fallback that formats text only when the displayed tenth changes. +- Consolidate the Ctrl+Shift+Alt movement/preview chord across eight modules and the one-shot action-bar/standalone layout initialization sequence in the API layer. Preserve native left/right modifier handling and each layout's fallback delay; release initialization event/update scripts after use. This is maintenance cleanup, with no measured FPS gain. +- Remove libcast's unused name-keyed ClassicAPI event mirror and localized combat-text parser. Keep native unit queries authoritative and isolate legacy Aimed Shot/Multi-Shot hooks to Hunters. SuperWoW tracks only missing timing for Enemy Castbars, skips player/complete native records, bounds its cache to 128 remote records and prunes without idle polling. See [cast tracking review](Docs/CAST_TRACKING_REVIEW.md). + +- Hide underlying caster-bar buttons while the actual form/bonus bar is visible, including during native Show refreshes. Restore their native filled/empty visibility when the form bar closes. This prevents caster Attack icons showing through empty Cat/Bear slots with floating artwork; action contents and grid counters are unchanged. +- Clarify Auto Stance: it enters required forms; leaving Cat Form to cast Moonfire uses ClassicAPI Auto Cancel Form. +- Range Color uses ClassicAPI action-range subscriptions and change events when available, including slot paging, visibility and macro targets. Each shown button owns one balanced subscription. Older supported DLLs retain the throttled range path; friendly/hostile spell applicability and usability tints are preserved. +- Rename the option to **Healing Predictions**, preserving both previous test-build setting names. Keep learned heal amounts across druid form/spellbook refreshes and unchanged equipment notifications; invalidate only for actual gear, talent or level changes. +- Add **Healing Predictions** (Unit Frames, off): estimated incoming direct heals and upcoming HoT ticks on player and target bars, capped at full health, with native bar texture/size and standalone/Big Health support. +- Reuse OEVG Puppeteer's compatible prediction total and update API when available (verified against private Gitea main `ee273a9`). Do not run a second estimator or add totals twice. Add standalone native-aura HoT tracking for observed tick amounts, including Rejuvenation and Regrowth; each contributes one upcoming tick, matching Puppeteer. See [review findings](Docs/HEALING_PREDICTION_REVIEW.md). +- Replace the unused legacy prediction implementation with a lazy, focused ClassicAPI cast/observed-heal estimator plus explicit-duration vanilla HealComm reception. No tooltip scans, spell-casting hooks, resurrection tracking, localized combat-message parsing or prediction SavedVariables are needed. A single expiry driver runs only while predictions are pending. +- Amounts are estimates, not a native incoming-heal API: own casts need a prior observed non-critical heal this session; the standalone direct-heal path receives compatible vanilla HealComm announcements. HoT estimates require an observed periodic amount and known live aura timing. Compact/group HealComm formats and unresolved percentage-only targets are not projected. +- Reference reviewed: brues-code/pfUI at `560343bd746e00898606cf756bdfe533a4823110`; native range subscription ownership and separated healing-data/display responsibilities guided this implementation. +- New regression checks cover range subscriptions and tints, clamped prediction geometry, native healing events, interruption/expiry and disabled-option lifecycle. In-game tests remain necessary. + ## 3.8.1 — 2026-11-04 — ClassicAPI baseline and legacy cleanup - Remove Auto Dismount, its TurtleWoW patches, unused API wrappers and obsolete saved toggle in favor of ClassicAPI's Auto Dismount, Auto Stand and Auto Cancel Form settings. Retain Auto Stance: it enters a required stance/form rather than cancelling one. diff --git a/Docs/CAST_TRACKING_REVIEW.md b/Docs/CAST_TRACKING_REVIEW.md new file mode 100644 index 0000000..261f9e6 --- /dev/null +++ b/Docs/CAST_TRACKING_REVIEW.md @@ -0,0 +1,46 @@ +# Cast tracking review (3.8.2) + +## Data ownership + +The Improved Castbar and healing estimator query the normalized ClassicAPI cast +surface directly. Enemy Castbars also queries ClassicAPI first. The old libcast +copy of ClassicAPI cast events, keyed by unit name, had no built-in display +consumer. Name keys also cannot distinguish two enemies with the same name. + +Removed the native-event mirror and legacy localized combat-text parser from +libcast. Ordinary non-Hunter initialization now registers no cast events or +casting hooks. Native unit queries do not create cached Lua records. + +SuperWoW still supplies timed GUID records when ClassicAPI cannot supply complete +timing. Remote channels require ClassicAPI's live channel state and matching +spell ID before display, so an old timed record cannot resurrect a stopped or +different channel. A cache update refreshes the current target immediately. + +The SuperWoW listener is created only when Super WoW Compatibility and Enemy +Castbars are enabled. It ignores the local player and complete native records. +It retains at most 128 remote records, prunes expired records on incoming events +at most every half second, and expires records when read. There is no cache +cleanup OnUpdate loop. Expired records may remain until another event/query, +but the cache remains bounded and expired entries cannot be displayed. + +Hunter-only Aimed Shot/Multi-Shot custom timing is retained for compatibility +through ShaguTweaks' legacy query wrappers. Those hooks are no longer installed +for other classes. The built-in Improved Castbar continues to use ClassicAPI +directly; this cleanup does not add custom Hunter casts to that display. + +## Validation and limits + +`tests/test_cast_tracking.lua` covers native query ownership, duplicate enable +guards, disabled consumers, complete native records, missing channel timing, +live channel identity, delayed failures, expiry, cache limits, and Hunter +custom-cast entry points. These are mocked Lua 5.0 checks, not client/DLL tests. + +Before release, test player casts/channels and interruptions; enemy casts and +interruptions; switching targets during a channel; and Enemy Castbars with +Super WoW Compatibility both enabled and disabled. If a Hunter is available, +also test Aimed Shot and Multi-Shot through spellbook, macro and action buttons. + +This reduces redundant Lua handlers and records. No FPS, memory or CPU gain has +been measured. Compare the same encounter/options using ClassicAPI's +GetAddOnCPUUsage/GetAddOnMemoryUsage with script profiling enabled; do not rely +on GetFrameCPUUsage or infer a frame-rate gain from code size. diff --git a/Docs/CLASSICAPI_PFUI_REVIEW_382.md b/Docs/CLASSICAPI_PFUI_REVIEW_382.md new file mode 100644 index 0000000..205f7d9 --- /dev/null +++ b/Docs/CLASSICAPI_PFUI_REVIEW_382.md @@ -0,0 +1,208 @@ +# ClassicAPI / pfUI overlap review — 3.8.2 + +Reviewed 2026-11-04 against the current, uncommitted ShaguTweaks build. +This is a source review and implementation shortlist, not a performance result +or a claim that every finding has been reproduced in game. No addon behavior +or recoloring was changed during this review. + +Implementation follow-up: the energy/mana, macro ownership and native equipment +comparison work below has now been applied and has three new mocked regression +suites (25 total). The user confirmed the final energy/mana, macro and comparison +paths work in game, including the Bagshui comparison ownership fix. The energy adaptation retains +the original five-second mana sweep and two-second regen baseline; it adopts +phase rejection/native movement, not pfUI's separate configurable FSR bar or its +server-specific agility/regen-aura modifier calculation. Theme candidates remain +unimplemented. + +## Sources and coverage + +- [ClassicAPI API.md, commit c0b403ba05a917176fa680cbfc96c619c98d46d6](https://github.com/brues-code/ClassicAPI/blob/c0b403ba05a917176fa680cbfc96c619c98d46d6/docs/API.md). + Downloaded the pinned document after resolving current master. Reviewed its + system/function catalog and the contracts relevant to this addon. Its roughly + 1.15 MB includes APIs for systems this addon does not implement. +- [brues-code/pfUI, commit 560343bd746e00898606cf756bdfe533a4823110](https://github.com/brues-code/pfUI/tree/560343bd746e00898606cf756bdfe533a4823110). + Fetched origin and verified the clean reference checkout matches current + master. Indexed its 90 module/API Lua files and examined the implementations + overlapping ShaguTweaks, including native timing, events, macros, tooltips, + inventories, maps, unit frames, skinning, and helper APIs. +- Indexed ShaguTweaks' 83 module and seven library files, plus its API/helpers, + loader, compatibility files and current TOC. Read the relevant implementations + and traced consumers for the findings below. API-symbol inventories are only + discovery aids: a wrapper can already use a native API elsewhere. + +| Overlap | Result | +| --- | --- | +| Action bars, range, reagents and macros | Range subscriptions already adopted; macro ownership is the next substantial cleanup opportunity. | +| Player/target frames, health/power, portraits | Routing already separated; energy tick correctness and portrait event routing remain candidates. | +| Cast, swing, loss-of-control, cooldown, totem and roll displays | Swing native timing adopted; several other displays still animate/count down in Lua. | +| Item/tooltip/vendor, bags and mail/trade rarity | Many native item/tooltip APIs already used; equipment comparison can be simplified. Preserve working rarity behavior. | +| Chat, social/player cache | Native identity and cache interfaces already integrated; no justification to delete the Lua cache wholesale. | +| Minimap, map and coordinate helpers | Mostly visibility-gated/throttled; many updates genuinely depend on cursor/unit movement. Profile before further changes. | +| Golden/Dark UI and addon compatibility | Optional candidates listed separately below. Keep the tested implementation intact. | +| Auction replacement, equipment manager, full raid/unit-frame replacement, Glue/login, TTS and similar systems | Not equivalent to current addon features; do not import simply because pfUI or ClassicAPI offers them. | + +## Recommended correctness and ownership work + +### 1. Energy/mana tick: strongest next candidate + +Current source: `mods/unitframes-energy-tick.lua:21` and its event/update handlers. +Reference: pfUI `modules/energytick.lua`. + +Three concrete issues are visible in the source: + +- The unit filter runs before event dispatch. ClassicAPI's + PLAYER_ENTERING_WORLD now carries `true` in arg1 on a fresh login; the filter + rejects it as not equal to `"player"`, skipping initialization. Reload's arg1 + is nil, so this need not reproduce the same way after /reload. +- `lastMana` is seeded on world entry but not on UNIT_DISPLAYPOWER. Changing + forms can compare an energy value with the previous mana sample or vice versa. +- Any mana loss sets a five-second sweep. A drain such as Mana Burn does not + establish the same five-second-rule condition as paying for a spell. Any + sufficiently large gain can also reset the regen sweep, including a potion + or unrelated mana proc. The spark's initial width/height are captured once, + so later standalone/Big Health relayout also deserves testing. + +pfUI separates power-mode samples, spell-cost/FSR tracking and the periodic regen +phase. It also uses an invisible native status-bar duration to move the spark. +Adapt the model to the existing player mana bar rather than importing pfUI's +unit-frame layout or assuming every Octo/Turtle regen rule matches its comments. + +Benefit: correctness first, plus removal of the spark's Lua per-frame movement. +Risk: medium for a full rewrite; low for the login-payload/unit-filter fix alone. +Test mana spending, drains, potions/procs, channels, full mana, form switching, +and Big Health/standalone layouts. Event-filter initialization can be fixed +independently of the broader native-timer conversion. + +### 2. Native macro ownership instead of repeated manual scans + +Current sources: `mods/macro-icons.lua:18`, `:47`, `:113`; +`mods/macro-tweaks.lua:17` and its slash registrations. + +Current ClassicAPI handles native macro conditions, #showtooltip/#show, spell +and item icons, range, cooldown, usability, count and current castsequence step. +It also exposes C_Macro.GetMacroIcon and C_Macro.SetMacroDisplay. The current +Macro Icons module rebuilds a 36-slot macro cache and scans visible action bars +on several events, parses bodies, resolves spellbook slots and may rewrite icons. + +There is a concrete behavior difference: ClassicAPI preserves a manually chosen +macro icon and substitutes spell/item artwork only for the question-mark icon. +Our resolver can replace that chosen artwork with GetSpellTexture when the +specified spell resolves. This is a source-level difference, not a reproduced +user complaint. Conditional/item macros already fall back to GetActionTexture +when our parser cannot resolve them; do not claim every conditional macro fails. + +Proposed migration: let native #showtooltip/#show own ordinary macros. Retain +the nonstandard `--showtooltip` / `/pfcast` behavior explicitly if desired, +using SetMacroDisplay where appropriate. Review the global SendChatMessage +metadata filter and old /equip, /use, focus and attack aliases against the +installed native command surface. Finding a native macro command does not prove +that an equivalent interactive chat alias exists in SlashCmdList; test both. + +Benefit: less parsing/allocation and fewer competing owners; better consistency +with native macro behavior. Risk: medium, due to existing custom syntax, chosen +icons, macro addons and older supported DLLs. Feature/version detection and +tests must precede deletion. Do not remove Macro Tweaks as one undifferentiated +block. pfUI's current `macroicons.lua` is an icon-picker UI, not the same feature. + +### 3. Equip Compare can delegate comparison construction + +Current source: `mods/equip-compare.lua:21`, `:53`, `:129`. +Reference: pfUI `modules/eqcompare.lua`. + +pfUI calls GameTooltip:SetHyperlinkCompareItem for comparison offsets 1 and 2. +ShaguTweaks still maintains an equipment-type-to-slot table and manually shifts +tooltip lines to insert CURRENTLY_EQUIPPED. A guarded native path could replace +much of that construction while retaining our Shift condition, positioning, +font/color style and third-party tooltip behavior. + +Benefit: smaller code and native handling of comparison slots/item instances; +likely little FPS effect. Risk: medium visual/compatibility risk. Test rings, +trinkets, dual wield, two-hand weapons, equipped enchants/random suffixes, bags, +links, vendors and simultaneous tooltip addons. Preserve the actual item link, +not just its item ID, when comparing. + +## Native runtime opportunities to prioritize after profiling + +| Feature | Current work | Native/pfUI approach | Benefit and risk | +| --- | --- | --- | --- | +| Cooldown Numbers | One visible helper OnUpdate per active cooldown, formatting at 0.1 s, plus alpha synchronization. | pfUI configures native Cooldown countdown fonts/formatters. For stock 1.12 Model indicators, retain the original swipe and consider a duration-text binding for our overlay. | Potentially useful with many cooldowns; medium compatibility risk. Stock Models are not native Cooldown frames, so this is not a blanket method substitution. Preserve GCD suppression, TimeConvert formatting, noCooldownCount/pfCooldownType and other addons' ownership; do not enable a global CVar merely to style ours. | +| Loss of Control | While shown, reselects effects, rebuilds metadata and reapplies theme/text/width every 0.05 s. | pfUI reselects on LOC events plus visible-only UNIT_AURA_DELTA and SPELL_UPDATE_COOLDOWN; native duration/countdown handles display timing. | Reduces repeated aura/list scans and formatting during CC. Medium correctness risk: refreshed CC or an already-locked school can extend without a LOC diff event; expiration/selection also need explicit handling. Keep priority and classic styling. | +| Player/target cast displays | Already discover casts on events; remaining Lua work animates text/fill/spark/alpha while visible. | pfUI uses duration objects, native fill-edge anchors and text bindings. | Conditional saving during casts. Medium risk from pushback, channel updates, fade/preview and SuperWoW fallback. Native cast-duration objects are snapshots, not automatically updated: refresh on delay/channel-update events. Remote channels whose start was unseen can still lack native timing. | +| Totem Timers | Four-slot timed text/color/width updates while totems are up. | GetTotemDuration and duration bindings; scheduled 10/5-second color thresholds and expiry; pfUI uses native cooldown displays. | Shaman-only saving; lower priority for the user's druid. Preserve our bar/tooltip/click design rather than copying pfUI's panel layout. Replacements/destruction/preview must cancel old bindings/timers. | +| Class Portraits | Every portrait event refreshes six frames; target-of-target adds a 0.2 s GUID check through the stock updater. | pfUI's chained frames use UNIT_TARGET subscriptions. Update the relevant portrait on unit events and handle same-name target changes by GUID. | Small conditional saving and clearer ownership. Preserve native portrait refresh hooks: stock code can overwrite custom artwork in the same frame. Validate actual event order before removing the safety path. | +| Group roll bars | One shared visible-only Lua loop queries remaining time for every active roll each frame. | Native durations for the fill with event-driven reset/cancel, as other pfUI displays do. | Low priority: already consolidated and runs only during rolls. Native snapshot timing must account for actual roll changes; profile first. | + +No table above promises a measured CPU or frame-rate improvement. Native work +still costs client time, even when it disappears from Lua profiling. + +## Golden/Dark UI: optional findings only — do not change now + +The current implementation is a compatibility recolorer. pfUI replaces or skins +many widgets with its own artwork/backdrops; copying its skin code would change +the look and bypass the fixes that made parchment, slots, rarity and addon borders +correct. There is no documented single ClassicAPI switch that replaces our theme. + +| Candidate | What could improve | Risk | Expected benefit | +| --- | --- | --- | --- | +| Read-only theme inspection | A diagnostic using GetDebugName/GetSourceLocation and region/owner inspection could identify the actual texture/addon behind a missed border, reducing trial and error. It need not change any colors. | Low; keep it on demand. | Maintenance/debugging only, no FPS gain. | +| Coalesce repeated deferred recolors | RecolorOnShow currently applies immediately and schedules another pass. Merge multiple pending requests for the same frame while retaining the immediate and trailing behavior. | Moderate: timing relative to row/frame creation is essential. | Probably small; mainly window creation/show bursts. Profile before considering. | +| Texture-path classification/cache and traversal scratch | Recolor traversals create region/child arrays and repeatedly classify texture paths. A bounded path-only classification cache or carefully scoped reusable arrays could avoid some garbage. Name-based exclusions must remain separate; recursive scratch cannot be shared at the same depth, and reused arrays need correct Lua 5.0 lengths. | Low-to-moderate for narrow path caching; moderate for traversal changes. | Small/unmeasured and mostly startup/window-open work. Existing path/region markers and dynamic texture changes must remain correct. | +| Targeted addon lifecycle hooks | pfUI's skins commonly use addon-load or widget-construction hooks. These might narrow the global CreateFrame wrapper in our recolorer. | High compatibility risk: anonymous or late-created Bagshui/aux/VCB/RollFor widgets can be missed. | Unmeasured; avoid unless the wrapper is actually expensive. | +| Explicit native-menu styling | pfUI styles MenuVariants' default/context menu mixins and selection controls. If a current ClassicAPI-created menu is visibly unthemed, a targeted adapter could extend Golden/Dark coverage. | Moderate; pooled/reused controls and checked/disabled states need testing. | Visual consistency only. This is a potential coverage extension, not a confirmed silver-border defect in the user's build. | + +Retain all parchment/slot-edge markers, neutral-color checks, rarity preservation, +TrinketMenu exclusions, default-UI guards and the trailing recolor timing. No +recommendation here requires reopening the working recoloring before profiling. + +## Small cleanup and deliberate non-changes + +- An internal source-reference scan found no production consumer outside api.lua + for these wrappers: GetNamePlateForUnit, GetDebuffDataByIndex, UnitDebuff, + GetDebuffType, SetOverrideBindingClick, ClearOverrideBindings, GetMapOverlays + and UnitInRange. These are cleanup candidates, not proven safe deletions: + exposed ShaguTweaks APIs may have external consumers. Runtime benefit is + negligible; capability flags may still be needed by other helpers. +- Macro migration could shrink libspell/libtipscan further. Currently the macro + module uses GetSpellIndex and Hunter emulation uses GetSpellInfo. Both + libraries' scanners are lazy. Do not delete them before migrating these users + and deciding how to preserve their public interfaces. +- Keep libnameplate: Golden/Dark compatibility registers its callbacks for + ShaguPlates. It is dormant without consumers and is not an unused library. +- Native HookScript/hooksecurefunc can simplify selected hook sites, but replacing + our helper globally is unsafe without an argument/order audit. Our helper + supports prepend and returns a fixed legacy argument shape; native script + handlers can receive explicit self/event arguments. Styling locks deliberately + replace methods to intercept colors, which a simple posthook does not replace. +- Keep Auto Stance; ClassicAPI Auto Cancel Form handles the opposite operation. + pfUI's older autoshift/error handler is not a better replacement for ours. +- Keep observed/shared healing prediction. The reviewed API has healing bonus + and spell metadata, not a complete authoritative incoming-heal total. Raw DBC + effects alone cannot safely replace the estimator or the Puppeteer source. +- Keep independent Range Color subscriptions and the existing native merchant, + item-cache, loot-history, gossip, map-overlay and player-cache integrations. + Many of the obvious pfUI native implementations are already represented here. +- A cursor-following tooltip still needs movement observation; pfUI also uses a + visible-only follower. World-map coordinates must track moving cursors/units. + Replacing every visible OnUpdate with a ticker is not inherently faster. +- ClassicAPI now documents syntax transpilation, extra Lua library functions and + a raised upvalue limit. That does not supersede this repository's Lua 5.0 + coding/testing rules. Do not modernize syntax merely for stylistic cleanup. +- Do not add pfUI's bags, auction/raid-frame replacements, equipment manager, + alternate heal model or other large features solely for parity. Existing + dedicated addons and the user's chosen Blizzard styling remain the design. + +## Suggested order at the time of review + +1. Energy-tick initialization/form-sample fixes; then decide whether to adopt its + native sweep and more accurate regen/FSR model. +2. Native macro ownership migration with a clear policy for the two custom + syntaxes and chosen macro icons. +3. Native equipment comparison if that behavior change is wanted. +4. Profile the current build to select among cooldown, LOC, cast, portrait and + other runtime candidates. Keep theme candidates documented unless a real + defect or measured bottleneck justifies their risk. + +The existing 22 regression suites passed before this documentation-only review. +The later implementation follow-up noted above added three suites (25 total) +for energy/mana, macros and native equipment comparison. Other recommendations +remain unimplemented and have no measured performance benefit. diff --git a/Docs/CLASSICAPI_UPDATES.md b/Docs/CLASSICAPI_UPDATES.md index a4db32f..c5488ea 100644 --- a/Docs/CLASSICAPI_UPDATES.md +++ b/Docs/CLASSICAPI_UPDATES.md @@ -1,20 +1,54 @@ # ClassicAPI integration updates -The Debuff Timer notes below describe older releases. In 3.8 that module was -removed in favor of [TargetFrameBuff](https://octowow.st/git/OEVG/TargetFrameBuff) -with ClassicAPI; see [Removed later](EXTRAS_MERGE.md#removed-later). +## Current integration (3.8.2, 2026-11-04) -These changes use newer ClassicAPI features while retaining older fallback paths. -ClassicAPI v1.18.13 was the latest release reviewed on 2026-10-31 (the previous -review covered v1.17.6 on 2026-10-24). Update both ClassicAPI's DLL and its -bundled `!!!ClassicAPI` addon together, following its installation -instructions; a DLL update needs a full client restart. +The [ClassicAPI/pfUI overlap review](CLASSICAPI_PFUI_REVIEW_382.md) pins current +source revisions and records optional recoloring improvements. Its energy/mana +tick, macro ownership and native equipment comparison recommendations have +subsequently been implemented; other candidates remain recommendations. -The integration code below was written against v1.17.6. The v1.17.7 to v1.18.13 -review found nothing that needed a rewrite; see -[Reviewed since v1.17.6](#reviewed-since-v1176). +ClassicAPI is required, with a minimum loaded DLL version of 1.18.10 and core +timer/nameplate API checks. Install the DLL and its bundled `!!!ClassicAPI` addon +together and fully restart the client after a DLL update. Optional SuperWoW +support is detected separately. Newer APIs are detected per feature rather than +assumed available from the minimum version. -## Changes +- Range Color uses native action-range subscriptions/change events when present; + older supported APIs retain a throttled fallback. +- Healing Predictions shares compatible OEVG Puppeteer data when available; + otherwise it uses a lazy ClassicAPI event/observed-heal estimator with live HoT + aura timing. See [the estimator review](HEALING_PREDICTION_REVIEW.md). +- Castbars query ClassicAPI directly. The unused name-keyed cast mirror and + combat-text parser are removed; SuperWoW supplements missing enemy timing with + a bounded GUID cache. See [the cast review](CAST_TRACKING_REVIEW.md). +- Swing/wand fill and text use native duration objects/bindings when supported, + with cancellable boundary timers and no Lua per-frame driver. Older APIs retain + the throttled path. The implementation follows brues-code/pfUI at `560343b`. +- Health and power events refresh only their respective number overlays; + identity changes/startup retain complete refreshes. Modifier-key checks and + the repeated delayed layout initialization use shared API helpers. +- Energy/mana ticks handle login/form payloads and use cast-cost-driven FSR with + a separately observed regen phase and native spark movement where supported. + Ordinary macro icons are left to ClassicAPI; only custom legacy hints need + our adapter. Missing interactive command aliases use native macro parsing. +- Native equipment comparison builds equipped headers/slot selection/stat + differences from full item links, retaining our Shift and positioning rules. +- Legacy debuff timing and nameplate discovery have been removed. Auto Dismount + is provided by ClassicAPI; ShaguTweaks Auto Stance remains distinct from Auto + Cancel Form. Theme recoloring remains unchanged by the latest cleanup. + +These focused reviews do not constitute a complete audit of all changes between +ClassicAPI releases. Current automated checks and their limits are listed below. + +## Historical review (2026-10-31) + +The following sections record the integration written against v1.17.6 and the +subsequent v1.17.7–v1.18.13 review. They describe that snapshot, not the current +feature list. Debuff Timer was removed in 3.8 in favor of +[TargetFrameBuff](https://octowow.st/git/OEVG/TargetFrameBuff), and its unused +library was removed in 3.8.1. See [migration notes](EXTRAS_MERGE.md#removed-later). + +### Changes at that time - **Health correctness:** `UnitHealthMissing` returns a deficit in the same units as the health getters. It can be a percentage, so it is no longer used to infer @@ -38,7 +72,7 @@ review found nothing that needed a rewrite; see intact. With persistence off or native storage unavailable, the original Lua cache behavior remains. The addon never enables native persistence itself. -## Reviewed since v1.17.6 +### Reviewed since v1.17.6 156 ClassicAPI commits (v1.17.7 to v1.18.13) were read against the modules in this addon. @@ -80,7 +114,7 @@ this addon. - `UNIT_TARGET`, `UnitFacing`, `wipe`, the `Cooldown` countdown formatter and the other new APIs: no module here needs them. -Nothing from this review was tested in game. +At the time of that historical review, its changes had not been tested in game. ## Measuring performance @@ -108,14 +142,21 @@ turned on by this addon. ## Verification -Lua 5.1 syntax checks cover all addon Lua files. Mocked regression checks cover: -unknown percentage health, valid cached estimates, direct health, native and -fallback unit-event registration, sixth-return chat identity and name mismatch, -native chat payload with corrupted globals, duplicate filtering, native/legacy -cooldown creation, and persistence enabled/disabled while retaining levels and -history. Existing health overlays and English-only load-list checks also pass. -These checks do not replace in-game rendering, event-order and combat testing; -no memory/FPS improvement is claimed without a live measurement. +Changed/new Lua files are parse-checked with a real Lua 5.0 interpreter. The +current 25 mocked regression suites cover UI styling/rarity, options search, +unit-frame layouts, action-bar behavior/movement, runtime guards, range-event +ownership, cast tracking, healing prediction, shared startup/modifier helpers, +health/power routing, native/fallback swing and energy timers, macro ownership +and equipment comparison. Run them together with +`sh tests/run_all.sh ../tools/lua5.0/lua5.0` from the addon root; see +[tests/README.md](../tests/README.md). + +These checks do not replace in-game rendering, DLL event-order and combat tests. +The user confirmed the range/healing/cast changes and the final energy/mana, +macro and equipment-comparison paths in game, including stable Bagshui comparisons +after the ownership recovery fix. Automated coverage does not establish every +possible addon/client combination. No memory/FPS +improvement is claimed without a live measurement. ## Primary references diff --git a/Docs/HEALING_PREDICTION_REVIEW.md b/Docs/HEALING_PREDICTION_REVIEW.md new file mode 100644 index 0000000..74cf728 --- /dev/null +++ b/Docs/HEALING_PREDICTION_REVIEW.md @@ -0,0 +1,70 @@ +# Healing prediction review — 2026-11-04 + +## Reviewed versions + +- ShaguTweaks: local 3.8.2 test build based on `dfe8cea` (3.8.1). +- OEVG's private [Puppeteer](https://octowow.st/git/OEVG/Puppeteer): + `ee273a93ef7f6d3f60376b601e27a9ec2f49595f`. Fetched `origin/main` and verified + the clean local checkout matched it. This review does not use upstream + OldManAlpha Puppeteer as a substitute. + +## Implemented in ShaguTweaks + +- Prefer Puppeteer's public `PTHealPredict.GetIncomingHealing(guid)` and + `HookUpdates(callback)` when present and player GUID formats match. Use the + provider's total once, including its HoTs. Do not add its direct-heal subtotal + or ShaguTweaks estimates to it. +- Do not start ShaguTweaks' estimator event listener, combat-log processing, + HealComm reception/broadcasting or expiry polling while this shared provider + is active. A guarded callback is registered once per provider; repeated + enable/disable cycles do not accumulate listeners. Puppeteer owns its data + and its roster of tracked units; this integration does not modify either. +- Without that provider, keep direct cast estimates separate from HoT tick + samples. Learn periodic amounts only for the two displayed units, by caster + GUID and spell ID. Observe native aura timing/caster identity and removal; + unknown amounts or unknown timing are not invented. +- The displayed HoT contribution is **one upcoming tick per active HoT**, + matching Puppeteer's current total. It is not the sum of every future tick. + Rejuvenation and Regrowth can coexist; Regrowth's direct cast is not + confused with its periodic amount. Stopping a direct cast does not remove HoTs. +- Preserve learned values across form changes. Real gear/talent/level changes + invalidate the standalone player's own estimates. Reuse native positional + aura getters where possible; scan on events rather than on every frame. +- Expiry work stops when no prediction remains. Explicit Lua 5.0 array-length + bookkeeping prevents stale entries after repeated HoT expiry/refresh cycles. + +## Improvements identified in private Puppeteer (not modified here) + +1. **Caster-specific HoT identity.** `AddHot` stores + `IncomingHots[target][spellName]`; `UpdateCacheHot` and `RemoveHoT` use the + same name-only identity. If a server permits same-name HoTs from multiple + casters, the later one overwrites the earlier entry. The ClassicAPI aura + removal handler also supplies no caster identity to `RemoveHoT`. Prefer + caster GUID plus spell ID/aura instance, with tests for refresh/remove ordering. +2. **Native expiry rather than a fixed emergency timeout.** The normal path + removes a HoT on an aura-removal event, but the cleanup fallback drops all + HoTs after 25 seconds. Actual aura expiry would improve correctness when + events are missed and avoid treating every effect as the same duration. +3. **Persistent cache validity.** `PTHealCache` and `PTPlayerHealCache` retain + estimates across sessions; observed values converge gradually. A gear, + talent, level or build signature for the local player's estimates would + prevent an old build's values being used until enough new samples arrive. + Avoid invalidating on form-only spellbook/inventory notifications. +4. **Callback removal API.** `HookUpdates` appends a callback but exposes no + corresponding removal function. A token/unsubscribe API would allow addon + integrations to detach cleanly. ShaguTweaks currently registers once and + guards disabled callbacks. +5. **Clarify HoT semantics.** `GetIncomingHealing` adds each HoT's stored tick + amount, not all remaining ticks. The UI/help should make this explicit; + changing to total remaining healing would be a separate behavior change. + +## Validation and limits + +Lua 5.0 mocks exercise direct/periodic separation, multiple casters, +refresh/removal/expiry, gear changes, shared totals, provider absence/GUID +mismatch, late activation and disabled callbacks. Neither estimator promises +server-authoritative heal amounts. Unknown/cold caches, healing modifiers, +critical heals and remote visibility can affect estimates. Standalone reception +supports explicit-duration vanilla HealComm direct-heal messages; compact/group +protocols are not implemented. Native rendering, real provider event ordering, +and CPU/memory costs still require in-game verification. diff --git a/Docs/PERFORMANCE.md b/Docs/PERFORMANCE.md index 101a8c9..71c8657 100644 --- a/Docs/PERFORMANCE.md +++ b/Docs/PERFORMANCE.md @@ -42,11 +42,25 @@ The separate 180-day cleanup in Social Colors is removed. It ran only every ## Range Color -The module shared Blizzard's `rangeTimer` and tested it against 0.2 seconds. -Once the timer had reset, that test was always true, so the full range check -ran on every frame for every ranged action button. It now keeps its own -0.2-second timer. Blizzard's own range check runs again, so the hotkey text -turns red out of range, as in the default UI. +3.8.2 uses native action-range subscriptions and change events when available. +Visibility and paging maintain balanced references. Older supported APIs retain +the independent 0.2-second timer introduced by the earlier fix; Blizzard's own +range timer remains untouched. + +## Health/power numbers and swing timers (3.8.2) + +Health events refresh only health text, and power events refresh only power +text, including the alternate druid power display. Target/pet identity changes +and startup still refresh both. + +Swing/wand timers use native duration objects and countdown bindings when +available. Cancellable timers cover due/grace and Hunter warning boundaries; +range and action-state events update colors. The native path has no Lua +OnUpdate driver. Older APIs retain a throttled fallback, with numeric-tenth +comparison before formatting text. Current recoloring behavior is unchanged. + +These are reductions in Lua work, not measured FPS claims. Follow the +[final 3.8.2 validation/measurement plan](PERFORMANCE_TEST_382.md) for A/B results. ## Map and portrait checks diff --git a/Docs/PERFORMANCE_TEST_382.md b/Docs/PERFORMANCE_TEST_382.md new file mode 100644 index 0000000..265cb5d --- /dev/null +++ b/Docs/PERFORMANCE_TEST_382.md @@ -0,0 +1,93 @@ +# 3.8.2 final validation and measurement + +Only the player can run these checks in the game. No performance gain has been +measured by the automated tests. The live game folder was not modified. + +## First verify behavior + +- Real Health Numbers: take damage, heal, spend/regenerate power, switch targets + and summon/dismiss a pet. Druid forms should show the appropriate resource and + update the separate mana display. Check Big Health and standalone layouts. +- Swing/Wand Timer: attack, stop, move out of range, change targets, and change + weapons. Dual-wield bars and wand Shoot should reset and stop correctly. +- Druid Maul or Warrior Heroic Strike/Cleave: queue and cancel, checking colors. + Hunter Auto Shot: check the last-half-second red warning if a Hunter is available. +- Hold Ctrl+Shift+Alt during a swing: preview labels remain stable, countdowns + resume on release, and drag/reset/saved positions still work after reload. +- Energy & Mana Tick: test a fresh login, Cat/Bear/caster transitions, spending + mana, channel completion/cancellation, a potion and a drain if practical. + Verify the spark after the first observed gain, its five-second cast-cost + sweep, and resizing with Big Health/standalone bars. +- Macros: test bare/explicit/conditional #showtooltip, item macros, castsequence, + chosen icons and any legacy --showtooltip or /pfcast display hints you use. + Edit/delete a custom hint and reload; check for stale overrides. Check missing + /equip, /use, focus and attack aliases without replacing another addon's aliases. +- Shift-compare rings, trinkets and weapons from bags/vendors/chat links; check + one empty paired slot, enchanted/random-suffix items, Shift release, hover + changes and tooltip closure. Compare with other tooltip addons enabled. + +## Find the remaining bottlenecks + +Profile the current build first. The purpose is to identify expensive workloads +and then narrow them down to modules/functions, not to prove the recent changes +made the addon faster. Older builds are not required for this investigation. + +Keep the same character, location, target, addon list, UI settings and frame-rate +limit. Enable Real Health Numbers and the applicable Swing/Wand Timer in both. +Warm up for two minutes after loading; do not include loading or option changes +in a measured run. Keep GentleGC and other client mods configured identically. + +Enable profiling, then reload: + +``` +/console scriptProfile 1 +/reload +``` + +Start each sample with: + +``` +/script ResetCPUUsage(); STPerfStart=GetTime() +``` + +Perform the chosen scenario for about 60 seconds, then print average addon Lua +CPU milliseconds per elapsed second: + +``` +/script UpdateAddOnCPUUsage(); DEFAULT_CHAT_FRAME:AddMessage(string.format("ShaguTweaks: %.3f ms/s over %.1f s",GetAddOnCPUUsage("ShaguTweaks")/(GetTime()-STPerfStart),GetTime()-STPerfStart)) +``` + +Scenarios: (1) idle with no windows open; (2) steady melee/wand attacks; +(3) repeated damage/healing and power changes, including druid form changes; +(4) repeated map, inventory, mail and auction-window interactions. Record which +windows were used and how many times. Repeat each three times on the current +build and compare medians. Repeating comparable activity matters more than +identical duration alone. + +These addon totals are the first triage step, not module/function attribution. +For workloads with meaningful cost, follow with focused function CPU profiling +and, where practical, repeat the same scenario with one relevant option disabled +(reload after changing options). That separates module cost from unrelated +activity and tells us where a code change might be worthwhile. Respect the +decision to preserve the working theme recoloring behavior. + +At the same point after warm-up and after a fixed test session, sample Lua memory: + +``` +/script UpdateAddOnMemoryUsage(); DEFAULT_CHAT_FRAME:AddMessage(string.format("ShaguTweaks: %.1f KiB",GetAddOnMemoryUsage("ShaguTweaks"))) +``` + +Memory includes uncollected objects; compare at equivalent GentleGC collection +points. Do not force collections during CPU samples. Native textures/DLL memory +and total garbage production are not represented by this single memory number. + +Send the build, scenario, enabled options, three CPU results and memory readings. +Those results guide the next targeted investigation. Lower addon Lua +CPU does not by itself prove higher FPS: native timer work moves out of Lua, +profiling adds overhead, and other addons/client rendering may dominate. Do not +use GetFrameCPUUsage for this comparison. Afterward, turn profiling off: + +``` +/console scriptProfile 0 +/reload +``` diff --git a/README.md b/README.md index c480fd7..bab6496 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,17 @@ This is an English-only customization of [Dusk/ShaguTweaks-ClassicAPI](https://o > This fork requires ClassicAPI and targets **Turtle WoW-like server/client environments**. -**Version 3.8.1.** What changed in each update: see the [changelog](CHANGELOG.md). +**Version 3.8.2.** What changed in each update: see the [changelog](CHANGELOG.md). > **Upgrading to 3.7:** the modules of the separate *ShaguTweaks Extras ClassicAPI* addon are now built into ShaguTweaks. **Disable or delete `ShaguTweaks-extras`** after updating, otherwise its older copies replace the built-in ones. Your on/off settings carry over. Raid Frames, Bag Item Click, Bag Search Bar and Auction Alt-Buy were not merged. See [Docs/EXTRAS_MERGE.md](Docs/EXTRAS_MERGE.md) for where each module went and why some were left out. ## 🔌 Requirements -- **[ClassicAPI](https://github.com/brues-code/ClassicAPI) — required.** Install both the DLL and its bundled `!!!ClassicAPI` addon, and restart the client after a DLL update. v1.18.10 or newer is required (it includes a crash fix for addons that restyle nameplates). The addon checks the loaded DLL version and core APIs before enabling tweaks. The integration was last reviewed against v1.18.13; see [Docs/CLASSICAPI_UPDATES.md](Docs/CLASSICAPI_UPDATES.md). -- **[SuperWoW](https://github.com/balakethelock/SuperWoW) — optional / recommended.** Only used as an additional fallback for some cast/GUID information. +- **[ClassicAPI](https://github.com/brues-code/ClassicAPI) — required.** Install both the DLL and its bundled `!!!ClassicAPI` addon, and restart the client after a DLL update. v1.18.10 or newer is required (it includes a crash fix for addons that restyle nameplates). The addon checks the loaded DLL version and core APIs before enabling tweaks, and feature-detects newer range and duration-timer APIs. See [current integration notes and the historical review](Docs/CLASSICAPI_UPDATES.md). +- **[SuperWoW](https://github.com/balakethelock/SuperWoW) — optional.** Supplements missing enemy cast timing when Enemy Castbars and Super WoW Compatibility are enabled. Complete ClassicAPI records are used directly without a second cache. See the [cast tracking review](Docs/CAST_TRACKING_REVIEW.md). - **[ShaguTweaks Extras ClassicAPI](https://octowow.st/git/Dusk/ShaguTweaks-Extras-ClassicAPI) — no longer needed.** Its modules are built in since 3.7 (see [Docs/EXTRAS_MERGE.md](Docs/EXTRAS_MERGE.md)). Disable or remove it; while it is enabled, its copies replace the built-in ones. -ClassicAPI is the main API layer. Use its **Auto Dismount**, **Auto Stand** and **Auto Cancel Form** settings; ShaguTweaks' duplicate Auto Dismount option was removed in 3.8.1. See the [migration guide](Docs/EXTRAS_MERGE.md#auto-dismount-replacement-381). +ClassicAPI is the main API layer. Use its **Auto Dismount**, **Auto Stand** and **Auto Cancel Form** settings; ShaguTweaks' duplicate Auto Dismount option was removed in 3.8.1. See the [migration guide](Docs/EXTRAS_MERGE.md#removed-later). ## 📦 Installation @@ -43,6 +43,8 @@ Use the **Search** field to filter by option name, description, category or slid - **Hide Player Portrait** (off): hides only the player's portrait image, including a class-icon portrait, without hiding the name, health or power bar. The surrounding portrait artwork remains. - **Hide Player Name** (off): hides the detached name piece only while **Standalone Player Frame** is enabled. It has no effect on the ordinary grouped player frame. +- **Unit Frame Energy & Mana Tick** (off): uses native spark movement when available, retains the five-second mana sweep for mana-costing spells, and observes the two-second regen phase separately. Form changes reset power samples; drains and mid-period gains do not falsely start/reset the cast-cost display. The spark resizes with Big Health/standalone bars. +- **Healing Predictions** (off): a green segment shows incoming direct heals plus one upcoming tick per active HoT, capped at maximum health. Reuses compatible Puppeteer prediction data when available, including its HoTs, without starting a second estimator. Otherwise ClassicAPI supplies cast/aura/combat events: own direct heals need an observed non-critical heal per spell rank each session, and HoTs such as Rejuvenation/Regrowth need an observed tick amount and identified live aura. Form changes preserve estimates; actual gear, talent or level changes invalidate own standalone samples. Other direct heals can arrive through explicit-duration vanilla HealComm messages. Unknown amounts/timing and percentage-only target health are not projected. Supports Big Health and Standalone Player Frame. See the [estimator review](Docs/HEALING_PREDICTION_REVIEW.md). - **Standalone Player Frame** (off): detaches the existing Blizzard portrait, name, health and power into four independently movable pieces. Hold **Ctrl+Shift+Alt** and drag a piece; right-click with those keys held to reset only that piece. Positions are stored per character. Existing bars, updates, numeric overlays, textures and click actions are retained. With **Unit Frame Big Health for Player**, the power bar matches the enlarged health bar's width and height. This option handles its own movement even when Movable Unit Frames is off; the ordinary player-frame mover yields to it. Disabling the option and reloading restores the grouped frame while keeping detached positions for later use. The standalone bars use clean rounded Blizzard border outlines, following the active UI palette. The name has a separate class-colored fill using the health texture, including with Big Health enabled. The portrait keeps one level badge on the left; the resting/combat icon covers its level number while active. ClassicAPI's portrait mask is used for the status glow when available; it is feature-detected. @@ -63,6 +65,8 @@ The standalone bars use clean rounded Blizzard border outlines, following the ac **Swing Timer and Wand Timer** +Current ClassicAPI animates the fill and countdown through native duration objects, following pfUI's approach, without a Lua per-frame driver. Older supported APIs keep a throttled fallback. Styling, movement, range/queued-attack colors and Hunter warning timing remain ShaguTweaks' own. + - **Swing Timer:** main-hand and off-hand swing timers styled like the classic cast bar, with the weapon's icon on the left. They sit just above the cast bar until you move them; hold **Ctrl+Shift+Alt** to drag each bar (main hand, off hand and the ranged/wand bar) on its own, and right-click a bar to put just that one back. - **Wand Timer:** the time until your next wand shot in the same style. It also times Auto Shot for bows, guns and crossbows, turning red during the last half second, when moving would cancel the shot. - The timers follow the server's real attack timers through ClassicAPI's `PLAYER_SWING` event, including parry haste and on-next-swing abilities. A bar turns gray while the target is out of reach. @@ -71,6 +75,8 @@ The standalone bars use clean rounded Blizzard border outlines, following the ac **Tooltips, items and action bars** +- **Equip Compare:** Shift shows native equipped-item comparisons from full item links, including paired slots and stat differences, while retaining our placement. Older APIs keep the previous comparison path. +- **Macro Icons:** current ClassicAPI handles ordinary directives, conditions and chosen icons. This option adds compatibility for legacy `--showtooltip` and `/pfcast` display hints; only conditional custom hints need periodic re-evaluation. **Macro Tweaks** supplies missing interactive command aliases through the native parser and preserves command-history filtering. Other macro parsers/registered aliases keep ownership. - **Tooltip Details:** restored guild ranks and leader marker; optional modern class colors and health texture. NPC interaction and tooltip-clear handling prevent cached colors leaking between tooltips. - **Item Rarity Borders** cover merchant, quest, mail (including every TurtleMail attachment), both sides of trade, crafting and loot frames in every UI mode. **Item Rarity Glows** add an independent glow using the same shared rarity engine. **Loot Quality** colors the loot window itself and does not enable mail or trade borders. - **Range Color:** self/rangeless spells no longer use a misleading legacy fallback; invalid friendly/hostile target checks and stale red/no-mana tint are handled. No additional AoE-specific exceptions have been added. The range check runs five times a second instead of every frame. @@ -137,7 +143,7 @@ Names match the in-game options list. Modules marked *(off)* are disabled until ### Action Bar -- Auto Stance (enters a required stance/form; distinct from ClassicAPI Auto Cancel Form) +- Auto Stance (enters a required stance/form; to cast Moonfire from Cat Form, enable ClassicAPI Auto Cancel Form instead) - Range Color *(off)* - Cooldown Numbers - Hide Gryphons *(off)* diff --git a/ShaguTweaks.toc b/ShaguTweaks.toc index 1ea44b1..467697c 100644 --- a/ShaguTweaks.toc +++ b/ShaguTweaks.toc @@ -2,7 +2,7 @@ ## Title: |cffffcc00Shagu|cffffffffTweaks |cffcccccc[ClassicAPI] ## Author: Shagu ## Notes: A collection of tweaks and enhancements to the Default UI -## Version: 3.8.1 +## Version: 3.8.2 ## Dependencies: !!!ClassicAPI ## SavedVariables: ShaguTweaks_player_cache, ShaguTweaks_social_cache ## SavedVariablesPerCharacter: ShaguTweaks_config, ShaguTweaks_cache @@ -63,6 +63,7 @@ mods\hide-gryphons.lua mods\worldmap-colors.lua mods\unitframes-bighealth.lua mods\health-numbers.lua +mods\unitframes-healing-prediction.lua mods\unitframes-classportrait.lua mods\unitframes-hide-player-portrait.lua mods\unitframes-hide-player-name.lua diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1b1edb7..0192bf1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -226,3 +226,14 @@ Matching atlas metadata and conversion details are recorded in `Docs/MODERN_MANA ### Frame-specific resource exports Additional img/ModernPower*Class.tga, *Target.tga, *Small.tga and *Party.tga variants use the matching Blizzard atlas crops documented in Docs/MODERN_MANA.md. Player/target/small variants come from FileDataID 4642466; party variants come from FileDataID 4681512, interface/hud/uipartyframe.blp. Both were retrieved for build 12.1.5.70448 from wago.tools. All resource exports now use bilinear resizing to compact power-of-two TGA dimensions. These remain Blizzard game artwork; no MIT relicensing is asserted. + +## Additional ClassicAPI implementation reference (3.8.2) + +brues-code/pfUI at commit 560343bd746e00898606cf756bdfe533a4823110 was reviewed for native action-range subscription ownership and the separation of incoming-heal estimation from frame overlays. Source: https://github.com/brues-code/pfUI . The focused ShaguTweaks implementations retain their own frame and module architecture. + +The swing/wand timer also adapts that commit's `modules/swingtimer.lua` native duration/text-binding, fill-edge anchoring and cancellable boundary-timer approach (MIT, Shagu and contributors). ShaguTweaks retains its classic artwork, movement, colors, grace behavior and older-API fallback. + +Energy/mana tick phase locking and native fill-edge spark movement were also +adapted from that commit's `modules/energytick.lua`; native equipment comparison +was informed by `modules/eqcompare.lua` (MIT, Shagu and contributors). Existing +ShaguTweaks presentation and older-API paths remain separate. diff --git a/api.lua b/api.lua index 194ff84..8d7bc4f 100644 --- a/api.lua +++ b/api.lua @@ -90,6 +90,19 @@ API.eventutils = type(_G.C_EventUtils) == "table" API.timer = type(_G.C_Timer) == "table" and type(_G.C_Timer.After) == "function" +API.durationtimers = type(_G.C_DurationUtil) == "table" + and type(_G.C_DurationUtil.CreateDuration) == "function" + and type(_G.C_DurationUtil.CreateDurationTextBinding) == "function" + and type(_G.C_StringUtil) == "table" + and type(_G.C_StringUtil.CreateNumericRuleFormatter) == "function" + and type(_G.C_Timer) == "table" and type(_G.C_Timer.NewTimer) == "function" + and type(_G.Enum) == "table" and type(_G.Enum.StatusBarInterpolation) == "table" + and type(_G.Enum.StatusBarTimerDirection) == "table" + and type(_G.Enum.NumericRuleFormatRounding) == "table" + and _G.Enum.StatusBarInterpolation.Immediate ~= nil + and _G.Enum.StatusBarTimerDirection.ElapsedTime ~= nil + and _G.Enum.NumericRuleFormatRounding.Down ~= nil + API.regionmouseover = _G.UIParent and type(_G.UIParent.IsMouseOver) == "function" @@ -155,6 +168,10 @@ API.tooltipsetitem = API.tooltipitem API.unitrange = type(_G.UnitInRange) == "function" API.macrospell = type(_G.GetMacroSpell) == "function" +API.macrodisplay = type(_G.C_Macro) == "table" + and type(_G.C_Macro.SetMacroDisplay) == "function" + and type(_G.C_Macro.GetMacroIcon) == "function" +API.nativemacrocommands = API.macrodisplay and type(_G.RunMacroText) == "function" API.attackverbs = type(_G.StartAttack) == "function" and type(_G.StopAttack) == "function" API.focus = type(_G.FocusUnit) == "function" @@ -378,6 +395,38 @@ API.Defer = function(callback) end end +-- Layout consumers must wait for world entry, then for other modules' first +-- OnUpdates, before restoring/detaching frames. Each request is one-shot and +-- releases its scripts when finished. The standalone layout needs a second +-- update without native timers; the action-bar layout only needs the first. +-- Schedule during module enable, before the first PLAYER_ENTERING_WORLD. +API.AfterWorldReady = function(callback, extraFrame) + local ready = CreateFrame("Frame") + local function Finish() + ready:SetScript("OnUpdate", nil) + ready:Hide() + callback() + end + ready:RegisterEvent("PLAYER_ENTERING_WORLD") + ready:SetScript("OnEvent", function() + ready:UnregisterAllEvents() + ready:SetScript("OnEvent", nil) + ready:SetScript("OnUpdate", function() + ready:SetScript("OnUpdate", nil) + ready:Hide() + if API.timer then + API.Defer(callback) + elseif extraFrame then + ready:SetScript("OnUpdate", Finish) + ready:Show() + else + callback() + end + end) + end) + return ready +end + API.IsMouseOver = function(region, topOffset, bottomOffset, leftOffset, rightOffset) if API.regionmouseover and region then return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) @@ -456,6 +505,12 @@ API.IsAltKeyDown = function() return false end +-- All frame movers/previews use the same three-key chord. Individual key +-- queries above retain ClassicAPI's left/right state and vanilla fallback. +API.AreMoveModifiersDown = function() + return API.IsShiftKeyDown() and API.IsControlKeyDown() and API.IsAltKeyDown() +end + -- Direct item IDs avoid a class of Turtle WoW custom-item failures caused by -- relying exclusively on legacy item-link parsing. local function GetItemIDFromLink(link) diff --git a/compat-dragonflightui.lua b/compat-dragonflightui.lua index 026bbf4..866e9ac 100644 --- a/compat-dragonflightui.lua +++ b/compat-dragonflightui.lua @@ -102,6 +102,7 @@ Block("Movable Unit Frames") -- health/power text. Keep the real ShaguTweaks modules disabled there even -- though Real Health Numbers is once again a genuine core module. Block("Real Health Numbers") +Block("Healing Predictions") Block("Unit Frame Big Health for Player") Block("Unit Frame Big Health for Target") Block("Reduced Actionbar Size") diff --git a/libs/libcast.lua b/libs/libcast.lua index 8612daf..c8c254d 100644 --- a/libs/libcast.lua +++ b/libs/libcast.lua @@ -1,14 +1,10 @@ local _G = ShaguTweaks.GetGlobalEnv() local L = ShaguTweaks.L local API = ShaguTweaks.API -local GetExpansion = ShaguTweaks.GetExpansion -local libtipscan = ShaguTweaks.libtipscan local libspell = ShaguTweaks.libspell +local libtipscan = ShaguTweaks.libtipscan local hooksecurefunc = ShaguTweaks.hooksecurefunc -local cmatch = ShaguTweaks.cmatch - --- return instantly if we're not on a vanilla client -if GetExpansion() ~= "vanilla" then return end +if ShaguTweaks.GetExpansion() ~= "vanilla" then return end local valid_units = {} valid_units["pet"] = true @@ -32,372 +28,68 @@ for i=1,4 do valid_units["partypet" .. i .. "target"] = true end for i=1,40 do valid_units["raid" .. i .. "target"] = true end for i=1,40 do valid_units["raidpet" .. i .. "target"] = true end -local lastcasttex, lastrank, _ -local scanner -local function GetScanner() - if not scanner then - scanner = libtipscan:GetScanner("libcast") - end - return scanner -end - +valid_units.focus = true +valid_units.focustarget = true local libcast = CreateFrame("Frame", "ShaguTweaksEnemyCast") +ShaguTweaks.libcast = libcast local player = UnitName("player") local _, playerClass = UnitClass("player") local useClassicCasts = API and API.casts local needsCustomHunter = useClassicCasts and playerClass == "HUNTER" +local lastrank +local scanner +local function GetScanner() + if not scanner then scanner = libtipscan:GetScanner("libcast") end + return scanner +end +libcast.db = {} +if needsCustomHunter then libcast.db[player] = {} end -ShaguTweaks.UnitChannelInfo = _G.UnitChannelInfo or function(unit) - -- ClassicAPI is authoritative for real unit tokens. Fall through to the - -- legacy database only for the player's custom Hunter cast emulation or for - -- name/GUID keys maintained for compatibility. - if valid_units[unit] and useClassicCasts then - local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill = - API.GetChannelInfo(unit) - if cast then - return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill - end - if unit ~= "player" then return end +function libcast:Prune(now) + for key, cast in pairs(self.db) do + if key ~= player and (not cast.start or not cast.casttime or cast.start + cast.casttime/1000 <= now) then self.db[key] = nil end end - - unit = valid_units[unit] and UnitName(unit) or unit - - local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill - local db = libcast.db[unit] - - if db and db.cast and db.start + db.casttime / 1000 > GetTime() then - if not db.channel then return end - cast = db.cast - nameSubtext = db.rank - text = "" - texture = db.icon - startTime = db.start * 1000 - endTime = startTime + db.casttime - isTradeSkill = nil - elseif db then - db.cast = nil - db.rank = nil - db.start = nil - db.casttime = nil - db.icon = nil - db.channel = nil +end +local function ReadCached(key, channel) + local cast = key and libcast.db[key] + if not cast or not cast.cast or not cast.start or not cast.casttime then return end + if cast.start + cast.casttime/1000 <= GetTime() then + if key == player then cast.cast = nil else libcast.db[key] = nil end + return end - - return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + if (cast.channel and true or false) ~= channel then return end + return cast.cast, cast.rank or "", "", cast.icon, cast.start*1000, + cast.start*1000 + cast.casttime, nil +end +ShaguTweaks.UnitCastingInfo = function(unit) + if valid_units[unit] then + local name, rank, text, icon, startTime, endTime, trade = API.GetCastInfo(unit) + if name then return name, rank, text, icon, startTime, endTime, trade end + -- A missing native cast is authoritative. Only the local Hunter's custom + -- emulation may fill a native gap; never consult a name-keyed remote cache. + if unit ~= "player" or not needsCustomHunter then return end + unit = player + end + return ReadCached(unit, false) +end +ShaguTweaks.UnitChannelInfo = function(unit) + if valid_units[unit] then + return API.GetChannelInfo(unit) + end + return ReadCached(unit, true) end -ShaguTweaks.UnitCastingInfo = _G.UnitCastingInfo or function(unit) - if valid_units[unit] and useClassicCasts then - local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill = - API.GetCastInfo(unit) - if cast then - return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill - end - if unit ~= "player" then return end - end - - unit = valid_units[unit] and UnitName(unit) or unit - - local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill - local db = libcast.db[unit] - - if db and db.cast and db.start + db.casttime / 1000 > GetTime() then - if db.channel then return end - cast = db.cast - nameSubtext = db.rank or "" - text = "" - texture = db.icon - startTime = db.start * 1000 - endTime = startTime + db.casttime - isTradeSkill = nil - elseif db then - db.cast = nil - db.rank = nil - db.start = nil - db.casttime = nil - db.icon = nil - db.channel = nil - end - - return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill -end - -function libcast:AddAction(mob, spell, channel) - if not mob or not spell then return nil end - - local data = L["spells"][spell] - if data and data.t then - local casttime = data.t - local icon = data.icon and string.format("%s%s", "Interface\\Icons\\", data.icon) or nil - - -- add cast action to the database - if not self.db[mob] then self.db[mob] = {} end - self.db[mob].cast = spell - self.db[mob].rank = nil - self.db[mob].start = GetTime() - self.db[mob].casttime = casttime - self.db[mob].icon = icon - self.db[mob].channel = channel - - return true - end - - return nil -end - -function libcast:RemoveAction(mob, spell) - if self.db[mob] and ( L["interrupts"][spell] ~= nil or spell == "INTERRUPT" ) then - - -- remove cast action to the database - self.db[mob].cast = nil - self.db[mob].rank = nil - self.db[mob].start = nil - self.db[mob].casttime = nil - self.db[mob].icon = nil - self.db[mob].channel = nil - return true - end - - return nil -end - --- main data -libcast.db = { [player] = {} } - --- Current ClassicAPI can maintain the legacy name-keyed libcast cache from --- structured unit spell events. This preserves the public ShaguTweaks --- UnitCastingInfo/UnitChannelInfo fallback without parsing every combat-text --- line. Older ClassicAPI builds keep the historical parser below. -local classicRemoteEvents = API and API.casts and API.eventutils - and _G.C_EventUtils - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_START") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_STOP") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_CHANNEL_START") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_CHANNEL_STOP") - -if classicRemoteEvents then - local remoteEvents = { - "UNIT_SPELLCAST_START", - "UNIT_SPELLCAST_STOP", - "UNIT_SPELLCAST_FAILED", - "UNIT_SPELLCAST_INTERRUPTED", - "UNIT_SPELLCAST_DELAYED", - "UNIT_SPELLCAST_CHANNEL_START", - "UNIT_SPELLCAST_CHANNEL_STOP", - "UNIT_SPELLCAST_CHANNEL_UPDATE", - } - - for index = 1, table.getn(remoteEvents) do - local eventName = remoteEvents[index] - if _G.C_EventUtils.IsEventValid(eventName) then - libcast:RegisterEvent(eventName) - end - end -else - libcast:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF") - libcast:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_PARTY_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_PARTY_BUFF") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS") - libcast:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE") - libcast:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF") -end - --- Full legacy player tracking is only needed when ClassicAPI cast data is --- unavailable. On current ClassicAPI, only Hunters keep the stop/fail events --- required to end the Aimed Shot / Multi-Shot custom cast emulation. -if not useClassicCasts then - libcast:RegisterEvent("SPELLCAST_START") +-- No ClassicAPI remote event mirror or combat-text parser. Official castbars +-- query ClassicAPI directly; optional SuperWoW owns timed GUID records only. +if needsCustomHunter then libcast:RegisterEvent("SPELLCAST_STOP") libcast:RegisterEvent("SPELLCAST_FAILED") libcast:RegisterEvent("SPELLCAST_INTERRUPTED") - libcast:RegisterEvent("SPELLCAST_DELAYED") - libcast:RegisterEvent("SPELLCAST_CHANNEL_START") - libcast:RegisterEvent("SPELLCAST_CHANNEL_STOP") - libcast:RegisterEvent("SPELLCAST_CHANNEL_UPDATE") -elseif needsCustomHunter then - libcast:RegisterEvent("SPELLCAST_STOP") - libcast:RegisterEvent("SPELLCAST_FAILED") - libcast:RegisterEvent("SPELLCAST_INTERRUPTED") -end - -local function ClearClassicRemote(unit) - if not unit or unit == "player" then return end - local name = UnitName(unit) - if not name then return end - - local cast = libcast.db[name] - if cast then - cast.cast = nil - cast.rank = nil - cast.start = nil - cast.casttime = nil - cast.icon = nil - cast.channel = nil - end -end - -local function UpdateClassicRemote(unit, channel) - if not unit or unit == "player" then return end - local name = UnitName(unit) - if not name then return end - - local spell, rank, text, icon, startTime, endTime - if channel then - spell, rank, text, icon, startTime, endTime = API.GetChannelInfo(unit) - else - spell, rank, text, icon, startTime, endTime = API.GetCastInfo(unit) - end - - if not spell or not startTime or not endTime then - ClearClassicRemote(unit) - return - end - - local cast = libcast.db[name] - if not cast then - cast = {} - libcast.db[name] = cast - end - - cast.cast = spell - cast.rank = rank - cast.start = startTime / 1000 - cast.casttime = endTime - startTime - cast.icon = icon - cast.channel = channel and true or nil -end - -libcast:SetScript("OnEvent", function() - -- Keep the public legacy cache current from ClassicAPI without combat text. - if classicRemoteEvents and strfind(event, "UNIT_SPELLCAST_", 1) then - if arg1 == "player" then return end - - if event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_DELAYED" then - UpdateClassicRemote(arg1, false) - elseif event == "UNIT_SPELLCAST_CHANNEL_START" or event == "UNIT_SPELLCAST_CHANNEL_UPDATE" then - UpdateClassicRemote(arg1, true) - elseif event == "UNIT_SPELLCAST_STOP" or event == "UNIT_SPELLCAST_FAILED" - or event == "UNIT_SPELLCAST_INTERRUPTED" or event == "UNIT_SPELLCAST_CHANNEL_STOP" - then - ClearClassicRemote(arg1) - end - return - end - - -- Fill database with player casts - if event == "SPELLCAST_START" then - local icon = L["spells"][arg1] and L["spells"][arg1].icon and string.format("%s%s", "Interface\\Icons\\", L["spells"][arg1].icon) or lastcasttex - -- add cast action to the database - this.db[player].cast = arg1 - this.db[player].rank = lastrank - this.db[player].start = GetTime() - this.db[player].casttime = arg2 - this.db[player].icon = icon - this.db[player].channel = nil - if not L["spells"][arg1] or not L["spells"][arg1].icon or not L["spells"][arg1].t then - L["spells"][arg1] = L["spells"][arg1] or { } - L["spells"][arg1].icon = L["spells"][arg1].icon or icon - L["spells"][arg1].t = L["spells"][arg1].t or arg2 - end - lastcasttex, lastrank = nil, nil - elseif event == "SPELLCAST_STOP" or event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then - if this.db[player] and not this.db[player].channel then - -- remove cast action to the database - this.db[player].cast = nil - this.db[player].rank = nil - this.db[player].rank = nil - this.db[player].start = nil - this.db[player].casttime = nil - this.db[player].icon = nil - this.db[player].channel = nil - else - lastcasttex, lastrank = nil, nil - end - elseif event == "SPELLCAST_DELAYED" then - if this.db[player].cast then - this.db[player].start = this.db[player].start + arg1/1000 - end - elseif event == "SPELLCAST_CHANNEL_START" then - -- add cast action to the database - this.db[player].cast = arg2 - this.db[player].rank = lastrank - this.db[player].start = GetTime() - this.db[player].casttime = arg1 - this.db[player].icon = L["spells"][arg2] and L["spells"][arg2].icon and string.format("%s%s", "Interface\\Icons\\", L["spells"][arg2].icon) or lastcasttex - this.db[player].channel = true - lastcasttex, lastrank = nil, nil - elseif event == "SPELLCAST_CHANNEL_STOP" then - if this.db[player] and this.db[player].channel then - -- remove cast action to the database - this.db[player].cast = nil - this.db[player].rank = nil - this.db[player].start = nil - this.db[player].casttime = nil - this.db[player].icon = nil - this.db[player].channel = nil - end - elseif event == "SPELLCAST_CHANNEL_UPDATE" then - if this.db[player].cast then - this.db[player].start = -this.db[player].casttime/1000 + GetTime() + arg1/1000 - end - -- Fill database with environmental casts - elseif arg1 then - local mob, spell, _ - - -- (.+) begins to cast (.+). - mob, spell = cmatch(arg1, SPELLCASTOTHERSTART) - if libcast:AddAction(mob, spell) then return end - - -- (.+) begins to perform (.+). - mob, spell = cmatch(arg1, SPELLPERFORMOTHERSTART) - if libcast:AddAction(mob, spell) then return end - - -- (.+) gains (.+). - mob, spell = cmatch(arg1, AURAADDEDOTHERHELPFUL) - if libcast:RemoveAction(mob, spell) then return end - - -- (.+) is afflicted by (.+). - mob, spell = cmatch(arg1, AURAADDEDOTHERHARMFUL) - if libcast:RemoveAction(mob, spell) then return end - - -- Your (.+) hits (.+) for (%d+). - spell, mob = cmatch(arg1, SPELLLOGSELFOTHER) - if libcast:RemoveAction(mob, spell) then return end - - -- Your (.+) crits (.+) for (%d+). - spell, mob = cmatch(arg1, SPELLLOGCRITSELFOTHER) - if libcast:RemoveAction(mob, spell) then return end - - -- (.+)'s (.+) %a hits (.+) for (%d+). - _, spell, mob = cmatch(arg1, SPELLLOGOTHEROTHER) - if libcast:RemoveAction(mob, spell) then return end - - -- (.+)'s (.+) %a crits (.+) for (%d+). - _, spell, mob = cmatch(arg1, SPELLLOGCRITOTHEROTHER) - if libcast:RemoveAction(mob, spell) then return end - - -- You interrupt (.+)'s (.+). - mob, spell = cmatch(arg1, SPELLINTERRUPTSELFOTHER) - if libcast:RemoveAction(mob, spell) then return end - - -- (.+) interrupts (.+)'s (.+). - _, mob, spell = cmatch(arg1, SPELLINTERRUPTOTHEROTHER) - if libcast:RemoveAction(mob, spell) then return end - end -end) - + libcast:SetScript("OnEvent", function() + local cast = libcast.db[player] + if cast then cast.cast = nil end + lastrank = nil + end) --[[ Custom Casts Enable Castbars for spells that don't have a castbar by default (e.g Multi-Shot and Aimed Shot) @@ -508,15 +200,10 @@ local function CastCustom(spell) end end -if not useClassicCasts or needsCustomHunter then -hooksecurefunc("UseContainerItem", function(id, index) - lastcasttex = GetContainerItemInfo(id, index) -end) - hooksecurefunc("CastSpell", function(id, bookType) local spellName, rank = GetSpellName(id, bookType) + if not GetCustomCast(spellName) then return end lastrank = rank - lastcasttex = GetSpellTexture(id, bookType) if GetSpellCooldown(id, bookType) ~= 0 then CastCustom(spellName) @@ -555,12 +242,11 @@ hooksecurefunc("UseAction", function(slot, target, button) -- CastSpellByName. Do not guess it from the macro's first cast line. return elseif actionType == "item" then - lastcasttex = GetActionTexture(slot) lastrank = nil return elseif actionType == "spell" and actionID then - local spellName, rank, icon = API.GetSpellInfo(actionID) - lastcasttex = icon or GetActionTexture(slot) + local spellName, rank = API.GetSpellInfo(actionID) + if not GetCustomCast(spellName) then return end lastrank = rank if not IsCurrentAction(slot) then return end CastCustom(spellName) @@ -575,10 +261,7 @@ hooksecurefunc("UseAction", function(slot, target, button) tip:SetAction(slot) local spellName, rank = tip:Line(1) - lastcasttex = GetActionTexture(slot) lastrank = rank CastCustom(spellName) end) end - -ShaguTweaks.libcast = libcast \ No newline at end of file diff --git a/libs/libpredict.lua b/libs/libpredict.lua index d6020c1..13739e9 100644 --- a/libs/libpredict.lua +++ b/libs/libpredict.lua @@ -1,592 +1,287 @@ +-- ClassicAPI supplies cast timing, not incoming heal amounts. Learn our own +-- direct heals and receive explicit-duration vanilla HealComm announcements. local _G = ShaguTweaks.GetGlobalEnv() -local L = ShaguTweaks.L local API = ShaguTweaks.API -local GetExpansion = ShaguTweaks.GetExpansion -local libtipscan = ShaguTweaks.libtipscan -local libspell = ShaguTweaks.libspell -local hooksecurefunc = ShaguTweaks.hooksecurefunc -local strsplit = ShaguTweaks.strsplit -local cmatch = ShaguTweaks.cmatch - -local senttarget -local heals, ress, events = {}, {}, {} - -local PRAYER_OF_HEALING -do -- Prayer of Healing - local locales = { - ["deDE"] = "Gebet der Heilung", - ["enUS"] = "Prayer of Healing", - ["esES"] = "Rezo de curación", - ["frFR"] = "Prière de soins", - ["koKR"] = "치유의 기원", - ["ruRU"] = "Молитва исцеления", - ["zhCN"] = "治疗祷言", - } - - PRAYER_OF_HEALING = locales[GetLocale()] or locales["enUS"] +local P = { pending = {}, enabled = false } +ShaguTweaks.libpredict = P +local learned, driver, player, sentGUID, sentTarget, sentSpell, activeGUID +local elapsed = 0 +local equipment, equipmentReady = {}, false +local sharedHooks = {} +local hotAmounts, hotUnits = {}, {} +local trackedUnits = {"player", "target"} +local function HotTotal(unit) + local state = hotUnits[unit] + if not state or state.guid ~= UnitGUID(unit) then return 0 end + local total, now = 0, GetTime() + for _, hot in ipairs(state.entries) do if hot.expires > now then total = total + hot.amount end end + return total end - -local libpredict = CreateFrame("Frame") -libpredict.enabled = false -libpredict:SetScript("OnEvent", function() - if event == "CHAT_MSG_ADDON" and (arg1 == "HealComm" or arg1 == "CTRA") then - this:ParseChatMessage(arg4, arg2, arg1) - elseif event == "UNIT_HEALTH" then - local name = UnitName(arg1) - if ress[name] and not UnitIsDeadOrGhost(arg1) then - ress[UnitName(arg1)] = nil - end - end -end) - -local function UpdateTimeouts() +local function HotPending() local now = GetTime() - local pending - - for timestamp in pairs(events) do - if now >= timestamp then - events[timestamp] = nil - else - pending = true + for _, state in pairs(hotUnits) do + for _, hot in ipairs(state.entries) do if hot.expires > now then return true end end + end + return false +end +local function EquipmentChanged() + if not _G.GetInventoryItemLink then return false end + local changed = false + for slot = 1, 19 do + local link = GetInventoryItemLink("player", slot) + if equipment[slot] ~= link then changed = true end + equipment[slot] = link + end + local ready = equipmentReady + equipmentReady = true + return ready and changed +end +local function Notify(target) if P.changed then P.changed(target) end end +local function Expire() + elapsed = elapsed + (arg1 or 0) + if elapsed < .1 then return end + elapsed = 0 + local now, any = GetTime(), false + for target, senders in pairs(P.pending) do + local changed, remaining = false, false + for sender, heal in pairs(senders) do + if heal.expires <= now then senders[sender] = nil; changed = true + else remaining = true; any = true end + end + if not remaining then P.pending[target] = nil end + if changed then Notify(target) end + end + for _, state in pairs(hotUnits) do + for i = table.getn(state.entries), 1, -1 do + if state.entries[i].expires <= now then + table.remove(state.entries, i) + Notify(state.name) + else any = true end end end - - -- No prediction timeout is pending: completely remove OnUpdate until the - -- next AddEvent() instead of waking this library on every rendered frame. - if not pending then - libpredict.timeoutActive = nil - libpredict:SetScript("OnUpdate", nil) - end + if not any then driver:SetScript("OnUpdate", nil) end end - -function libpredict:ParseComm(sender, msg) - local msgtype, target, heal, time - - if msg == "Healstop" or msg == "GrpHealstop" then - msgtype = "Stop" - elseif msg == "Resurrection/stop/" then - msgtype = "RessStop" - elseif msg then - local msgobj = {strsplit("/", msg)} - - if msgobj and msgobj[1] and msgobj[2] then - -- legacy healcomm object - if msgobj[1] == "GrpHealdelay" or msgobj[1] == "Healdelay" then - msgtype, time = "Delay", msgobj[2] +function P:HealStop(sender) + if not sender then return end + for target, senders in pairs(self.pending) do + if senders[sender] then + senders[sender] = nil + if not next(senders) then self.pending[target] = nil end + Notify(target) + end + end + if driver and not next(self.pending) and not HotPending() then driver:SetScript("OnUpdate", nil) end +end +function P:Heal(sender, target, amount, milliseconds) + amount, milliseconds = tonumber(amount), tonumber(milliseconds) + if not self.enabled or self.external or not sender or not target or target == "" or not amount or not milliseconds + or amount <= 0 or amount > 10000000 or milliseconds <= 0 or milliseconds > 600000 then return end + self:HealStop(sender) + local senders = self.pending[target] + if not senders then senders = {}; self.pending[target] = senders end + senders[sender] = { amount = amount, expires = GetTime() + milliseconds / 1000 } + Notify(target) + elapsed = 0 + driver:SetScript("OnUpdate", Expire) +end +function P:UnitGetIncomingHeals(unit) + if not self.enabled or not UnitExists(unit) or UnitIsDeadOrGhost(unit) then return 0 end + if self.external then + local guid = UnitGUID(unit) + local amount = guid and self.external.GetIncomingHealing(guid) + return type(amount) == "number" and amount > 0 and amount or 0 + end + local senders = self.pending[UnitName(unit)] + local sum, now = HotTotal(unit), GetTime() + if senders then + for _, heal in pairs(senders) do if heal.expires > now then sum = sum + heal.amount end end + end + return sum +end +local function RefreshHots(unit) + if unit ~= "player" and unit ~= "target" then return end + local state = hotUnits[unit] + if not state then state = {entries={}}; hotUnits[unit] = state end + state.guid, state.name = UnitGUID(unit), UnitName(unit) + local auras, count, now = _G.C_UnitAuras, 0, GetTime() + if auras and UnitExists(unit) and not UnitIsDeadOrGhost(unit) and UnitCanAssist("player", unit) then + for index = 1, 48 do + local name, duration, expires, source, spell, guid + if type(auras.UnitBuff) == "function" then + local n, _, _, _, d, e, s, _, _, id = auras.UnitBuff(unit, index) + name, duration, expires, source, spell = n, d, e, s, id + guid = source and UnitGUID(source) end - - if msgobj[1] and msgobj[1] == "Resurrection" and msgobj[2] then - msgtype, target = "Ress", msgobj[2] - end - - if msgobj[1] == "Heal" and msgobj[2] then - msgtype, target, heal, time = "Heal", msgobj[2], msgobj[3], msgobj[4] - end - - if msgobj[1] == "GrpHeal" and msgobj[2] then - msgtype, target, heal, time = "Heal", {}, msgobj[2], msgobj[3] - for i=4,8 do - if msgobj[i] then table.insert(target, msgobj[i]) end + if (not name or not guid) and type(auras.GetAuraDataByIndex) == "function" then + local aura = auras.GetAuraDataByIndex(unit, index, "HELPFUL") + if aura then + name, duration, expires, spell = aura.name, aura.duration, aura.expirationTime, aura.spellId + guid = aura.sourceGUID or (aura.sourceUnit and UnitGUID(aura.sourceUnit)) end end - elseif select and UnitCastingInfo then - -- latest healcomm - msgtype = tonumber(string.sub(msg, 1, 3)) - if not msgtype then return end - - if msgtype == 0 then - msgtype = "Heal" - heal = tonumber(string.sub(msg, 4, 8)) - target = string.sub(msg,9, -1) - - local starttime = select(5, UnitCastingInfo(sender)) - local endtime = select(6, UnitCastingInfo(sender)) - if not starttime or not endtime then return end - time = endtime - starttime - elseif msgtype == 1 then - msgtype = "Stop" - elseif msgtype == 2 then - msgtype = "Heal" - heal = tonumber(string.sub(msg,4, 8)) - target = {strsplit(":", string.sub(msg,9, -1))} - local starttime = select(5, UnitCastingInfo(sender)) - local endtime = select(6, UnitCastingInfo(sender)) - if not starttime or not endtime then return end - time = endtime - starttime + if not name then break end + local byCaster = guid and hotAmounts[guid] + local amount = byCaster and byCaster[spell] + if amount and expires and expires > now then + count = count + 1 + local hot = state.entries[count] or {} + state.entries[count] = hot + hot.amount, hot.expires = amount, expires end end end - - return msgtype, target, heal, time + for index = table.getn(state.entries), count+1, -1 do state.entries[index] = nil end + -- Lua 5.0 caches the length used by table.remove; direct slot reuse must + -- update it explicitly, or an expired/refreshed HoT can leave stale entries. + table.setn(state.entries, count) + Notify(state.name) + if count > 0 then elapsed = 0; driver:SetScript("OnUpdate", Expire) end end - -function libpredict:ParseChatMessage(sender, msg, comm) - local msgtype, target, heal, time - - if comm == "HealComm" then - msgtype, target, heal, time = libpredict:ParseComm(sender, msg) - elseif comm == "CTRA" then - local _, _, cmd, ctratarget = string.find(msg, "(%a+)%s?([^#]*)") - if cmd == "RES" and ctratarget and ctratarget ~= "" and ctratarget ~= UNKNOWN then - msgtype = "Ress" - target = ctratarget - elseif cmd == "RESNO" then - msgtype = "RessStop" - end - end - - if msgtype == "Stop" and sender then - libpredict:HealStop(sender) - return - elseif msgtype == "RessStop" and sender then - libpredict:RessStop(sender) - return - elseif msgtype == "Delay" and time then - libpredict:HealDelay(sender, time) - elseif msgtype == "Heal" and target and heal and time then - if type(target) == "table" then - for _, name in pairs(target) do - libpredict:Heal(sender, name, heal, time) +local function TryShared() + local shared = _G.PTHealPredict + if type(shared) ~= "table" or type(shared.GetIncomingHealing) ~= "function" + or type(shared.HookUpdates) ~= "function" then return false end + -- Puppeteer uses SuperWoW GUIDs; never hand it incompatible identifiers. + local _, guid = UnitExists("player") + if not guid or guid ~= UnitGUID("player") then return false end + P.external, P.source = shared, "Puppeteer" + learned, activeGUID, sentGUID, sentTarget, sentSpell = nil, nil, nil, nil, nil + if driver then driver:UnregisterAllEvents(); driver:SetScript("OnUpdate", nil) end + for name in pairs(P.pending) do P.pending[name] = nil end + hotUnits, hotAmounts = {}, {} + if not sharedHooks[shared] then + shared.HookUpdates(function(changedGUID) + if not P.enabled or P.external ~= shared then return end + for _, unit in ipairs(trackedUnits) do + if UnitGUID(unit) == changedGUID then Notify(UnitName(unit)) end end - else - libpredict:Heal(sender, target, heal, time) + end) + sharedHooks[shared] = true + end + Notify(UnitName("player")); Notify(UnitName("target")) + return true +end +local function Broadcast(message) + if _G.AceLibrary and AceLibrary.HasInstance and AceLibrary:HasInstance("HealComm-1.0") then return end + local channel = GetNumRaidMembers() > 0 and "RAID" or GetNumPartyMembers() > 0 and "PARTY" + if channel then SendAddonMessage("HealComm", message, channel) end +end +local function StopOwn(guid) + if guid and activeGUID and guid ~= activeGUID then return end + P:HealStop(player) + if activeGUID then Broadcast("Healstop") end + activeGUID = nil +end +local function RefreshOwn() + if not sentSpell or not learned[sentSpell] then return end + local name, _, _, startTime, endTime = API.UnitCastingInfo("player") + if not name or not startTime or not endTime then return end + local remaining = endTime - GetTime() * 1000 + if remaining <= 0 then return end + activeGUID = sentGUID + P:Heal(player, sentTarget or player, learned[sentSpell], remaining) + Broadcast("Heal/" .. (sentTarget or player) .. "/" .. learned[sentSpell] .. "/" .. math.floor(remaining) .. "/") +end +function P:ParseComm(sender, message) + if type(sender) ~= "string" or type(message) ~= "string" then return end + if message == "Healstop" or message == "GrpHealstop" or message == "001F" then self:HealStop(sender); return end + local _, _, target, amount, duration = string.find(message, "^Heal/([^/]+)/(%d+)/(%d+)/$") + if target then self:Heal(sender, target, amount, duration); return end + local _, _, delay = string.find(message, "^Healdelay/(%d+)/$") + delay = tonumber(delay) + if delay and delay <= 60000 then + for name, senders in pairs(self.pending) do + if senders[sender] then senders[sender].expires = senders[sender].expires + delay/1000; Notify(name) end end - elseif msgtype == "Ress" then - libpredict:Ress(sender, target) end end - -function libpredict:AddEvent(time, target) - events[time] = events[time] or {} - table.insert(events[time], target) - - if not self.timeoutActive then - self.timeoutActive = true - self:SetScript("OnUpdate", UpdateTimeouts) - end +local function Reset() + for target in pairs(P.pending) do P.pending[target] = nil; Notify(target) end + for unit, state in pairs(hotUnits) do hotUnits[unit] = nil; Notify(state.name) end + if driver then driver:SetScript("OnUpdate", nil) end + activeGUID, sentGUID, sentTarget, sentSpell = nil, nil, nil, nil end - -function libpredict:Heal(sender, target, amount, duration) - if not sender or not target or not amount or not duration then - return - end - - local timeout = duration/1000 + GetTime() - heals[target] = heals[target] or {} - heals[target][sender] = { amount, timeout } - libpredict:AddEvent(timeout, target) -end - -function libpredict:HealStop(sender) - for ttarget, t in pairs(heals) do - for tsender in pairs(heals[ttarget]) do - if sender == tsender then - heals[ttarget][tsender] = nil +local function OnEvent() + if event == "ADDON_LOADED" then + TryShared() + elseif event == "UNIT_AURA" then + RefreshHots(arg1) + elseif event == "PLAYER_TARGET_CHANGED" then + RefreshHots("target") + elseif event == "CHAT_MSG_ADDON" then + if arg1 == "HealComm" and (arg3 == "PARTY" or arg3 == "RAID" or arg3 == "BATTLEGROUND") then P:ParseComm(arg4, arg2) end + elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then + if arg2 == "SPELL_PERIODIC_HEAL" and arg4 and arg8 and arg12 and tonumber(arg15) + and tonumber(arg15) > 0 and arg18 ~= true and arg18 ~= 1 then + -- Learn only ticks landing on one of the two displayed units. Keep + -- direct and periodic amounts separate (Regrowth uses both). + if arg8 == UnitGUID("player") or arg8 == UnitGUID("target") then + local amounts = hotAmounts[arg4] or {} + hotAmounts[arg4] = amounts + amounts[arg12] = tonumber(arg15) + if arg8 == UnitGUID("player") then RefreshHots("player") end + if arg8 == UnitGUID("target") then RefreshHots("target") end end - end - end -end - -function libpredict:HealDelay(sender, delay) - local delay = delay/1000 - for target, t in pairs(heals) do - for tsender, amount in pairs(heals[target]) do - if sender == tsender then - amount[2] = amount[2] + delay - libpredict:AddEvent(amount[2], target) - end - end - end -end - -function libpredict:Ress(sender, target) - ress[target] = ress[target] or {} - ress[target][sender] = true -end - -function libpredict:RessStop(sender) - for ttarget, t in pairs(ress) do - for tsender in pairs(ress[ttarget]) do - if sender == tsender then - ress[ttarget][tsender] = nil - end - end - end -end - -function libpredict:UnitGetIncomingHeals(unit) - if not unit or not UnitName(unit) then return 0 end - if UnitIsDeadOrGhost(unit) then return 0 end - local name = UnitName(unit) - - local sumheal = 0 - if not heals[name] then - return sumheal - else - for sender, amount in pairs(heals[name]) do - if amount[2] <= GetTime() then - heals[name][sender] = nil - else - sumheal = sumheal + amount[1] - end - end - end - return sumheal -end - -function libpredict:UnitHasIncomingResurrection(unit) - if not unit or not UnitName(unit) then return nil end - local name = UnitName(unit) - - if not ress[name] then - return nil - else - for sender, val in pairs(ress[name]) do - if val == true then - return val - end - end - end - return nil -end - -local spell_queue = { "DUMMY", "DUMMYRank 9", "TARGET" } -local realm = GetRealmName() -local player = UnitName("player") -local cache, gear_string = {}, "" -local resetcache = CreateFrame("Frame") -resetcache:SetScript("OnEvent", function() - if event == "PLAYER_ENTERING_WORLD" then - -- load and initialize previous caches of spell amounts - ShaguTweaks_cache = ShaguTweaks_cache or {} - ShaguTweaks_cache["prediction"] = ShaguTweaks_cache["prediction"] or {} - ShaguTweaks_cache["prediction"][realm] = ShaguTweaks_cache["prediction"][realm] or {} - ShaguTweaks_cache["prediction"][realm][player] = ShaguTweaks_cache["prediction"][realm][player] or {} - ShaguTweaks_cache["prediction"][realm][player]["heals"] = ShaguTweaks_cache["prediction"][realm][player]["heals"] or {} - cache = ShaguTweaks_cache["prediction"][realm][player]["heals"] - return - end - - if event == "UNIT_INVENTORY_CHANGED" then - -- skip non-player events - if arg1 and arg1 ~= "player" then return end - - local gear = "" - for id = 1, 18 do - gear = gear .. (GetInventoryItemLink("player",id) or "") - end - - -- abort when inventory didn't change - if gear == gear_string then return end - gear_string = gear - end - - -- flag all cached heals for renewal - for k in pairs(cache) do - if type(cache[k]) == "number" or type(cache[k]) == "string" then - -- migrate old data - local oldval = cache[k] - cache[k] = { [1] = oldval } - end - - -- flag for reset - cache[k][2] = true - end -end) - -local function UpdateCache(spell, heal, crit) - if not spell or not heal then return end - local heal = tonumber(heal) - - if not cache[spell] or cache[spell][2] then - -- skills or equipment changed, save whatever is detected - cache[spell] = cache[spell] or {} - cache[spell][1] = crit and heal*2/3 or heal - cache[spell][2] = crit - elseif not crit and cache[spell][1] < heal then - -- safe the best heal we can get - cache[spell][1] = heal - cache[spell][2] = nil - end -end - --- Gather Data by User Actions. Hooks are installed lazily by libpredict:Enable() --- so disabling healing predictions leaves no spell hook on the hot path. -local scanner -local hooksInstalled -local useClassicSenderEvents - -local function TrackCastSpell(id, bookType) - if not libpredict.sender.enabled then return end - local effect, rank = libspell.GetSpellInfo(id, bookType) - if not effect then return end - spell_queue[1] = effect - spell_queue[2] = effect.. ( rank or "" ) - spell_queue[3] = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player") -end - -local function TrackCastSpellByName(effect, target) - if not libpredict.sender.enabled then return end - local effect, rank = libspell.GetSpellInfo(effect) - if not effect then return end - spell_queue[1] = effect - spell_queue[2] = effect.. ( rank or "" ) - spell_queue[3] = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player") -end - -local function TrackUseAction(slot, target, selfcast) - if not libpredict.sender.enabled then return end - - -- Conditional macros are resolved by the later CastSpellByName hook. For a - -- direct spell action, ClassicAPI gives us the spellID without a tooltip. - if API and API.actioninfo and API.GetActionInfo then - local actionType, actionID = API.GetActionInfo(slot) - if actionType == "macro" or actionType == "item" then return end - - if actionType == "spell" and actionID then - if not IsCurrentAction(slot) then return end - local effect, rank = API.GetSpellInfo(actionID) - if not effect then return end - spell_queue[1] = effect - spell_queue[2] = effect.. ( rank or "" ) - spell_queue[3] = selfcast and UnitName("player") or UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player") - return - elseif actionType then return end + if arg2 == "SPELL_HEAL" and arg4 == UnitGUID("player") and arg12 and tonumber(arg15) + and tonumber(arg15) > 0 and arg18 ~= true and arg18 ~= 1 then learned[arg12] = tonumber(arg15) end + elseif event == "PLAYER_ENTERING_WORLD" then + if TryShared() then return end + player = UnitName("player"); Reset() + if EquipmentChanged() then for spell in pairs(learned) do learned[spell] = nil end end + RefreshHots("player"); RefreshHots("target") + elseif event == "UNIT_INVENTORY_CHANGED" or event == "PLAYER_LEVEL_UP" or event == "CHARACTER_POINTS_CHANGED" then + if event ~= "UNIT_INVENTORY_CHANGED" or (arg1 == "player" and EquipmentChanged()) then + for spell in pairs(learned) do learned[spell] = nil end + local guid = UnitGUID("player") + if guid then hotAmounts[guid] = nil end + StopOwn() + RefreshHots("player"); RefreshHots("target") + end + elseif arg1 == "player" then + if event == "UNIT_SPELLCAST_SENT" then + sentTarget = arg2 and arg2 ~= "" and (UnitExists(arg2) and UnitName(arg2) or arg2) or player + sentGUID, sentSpell = arg3, arg4 + elseif event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_DELAYED" then + if arg2 == sentGUID then RefreshOwn() end + elseif event == "UNIT_SPELLCAST_STOP" or event == "UNIT_SPELLCAST_FAILED" + or event == "UNIT_SPELLCAST_INTERRUPTED" or event == "UNIT_SPELLCAST_SUCCEEDED" then StopOwn(arg2) end end - - if GetActionText(slot) or not IsCurrentAction(slot) then return end - scanner = scanner or libtipscan:GetScanner("prediction") - scanner:SetAction(slot) - local effect, rank = scanner:Line(1) - if not effect then return end - spell_queue[1] = effect - spell_queue[2] = effect.. ( rank or "" ) - spell_queue[3] = selfcast and UnitName("player") or UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player") end - -local function InstallSenderHooks() - if hooksInstalled then return end - hooksecurefunc("CastSpell", TrackCastSpell) - hooksecurefunc("CastSpellByName", TrackCastSpellByName) - hooksecurefunc("UseAction", TrackUseAction) - hooksInstalled = true -end - -libpredict.sender = CreateFrame("Frame", "ShaguTweaksPredictionSender", UIParent) -libpredict.sender.enabled = false -libpredict.sender.SendHealCommMsg = function(self, msg) - SendAddonMessage("HealComm", msg, "RAID") - SendAddonMessage("HealComm", msg, "BATTLEGROUND") -end -libpredict.sender.SendResCommMsg = function(self, msg) - SendAddonMessage("CTRA", msg, "RAID") - SendAddonMessage("CTRA", msg, "BATTLEGROUND") -end - -libpredict.sender:SetScript("OnEvent", function() - if event == "CHAT_MSG_SPELL_SELF_BUFF" then -- vanilla - local spell, _, heal = cmatch(arg1, HEALEDSELFOTHER) -- "Your %s heals %s for %d." - if spell and heal then - if spell == spell_queue[1] then UpdateCache(spell_queue[2], heal) end - return - end - - local spell, heal = cmatch(arg1, HEALEDSELFSELF) -- "Your %s heals you for %d." - if spell and heal then - if spell == spell_queue[1] then UpdateCache(spell_queue[2], heal) end - return - end - - local spell, heal = cmatch(arg1, HEALEDCRITSELFOTHER) -- "Your %s critically heals %s for %d." - if spell and heal then - if spell == spell_queue[1] then UpdateCache(spell_queue[2], heal, true) end - return - end - - local spell, _, heal = cmatch(arg1, HEALEDCRITSELFSELF) -- "Your %s critically heals you for %d." - if spell and heal then - if spell == spell_queue[1] then UpdateCache(spell_queue[2], heal, true) end - return - end - elseif event == "COMBAT_LOG_EVENT_UNFILTERED" and arg2 == "SPELL_HEAL" and arg4 == player then -- tbc - local spell, heal, crit = arg10, arg12, arg13 - if spell and heal and crit then - if spell == spell_queue[1] then UpdateCache(spell_queue[2], heal, true) end - elseif spell and heal then - if spell == spell_queue[1] then UpdateCache(spell_queue[2], heal) end - end - elseif event == "UNIT_SPELLCAST_SENT" then - if arg1 ~= "player" then return end - - if GetExpansion() == "vanilla" and useClassicSenderEvents then - -- ClassicAPI already resolved the actual outgoing spell and target: - -- unit, target, castGUID, spellID, spellName, rank. Use it directly so - -- prediction tracking needs no CastSpell/UseAction hooks at all. - local target = arg2 and arg2 ~= "" and arg2 or UnitName("player") - local spell = arg5 - local rank = arg6 or "" - - senttarget = target - if spell then - spell_queue[1] = spell - spell_queue[2] = spell .. rank - spell_queue[3] = target - end - else - -- Native TBC layout keeps the target in arg4. - local castTarget = arg4 - senttarget = castTarget and castTarget ~= "" and castTarget or nil - end - elseif strfind(event, "SPELLCAST_START", 1) then - local spell, time = arg1, arg2 - - if strfind(event, "UNIT_", 1) then - if arg1 ~= "player" then return end - - if GetExpansion() == "vanilla" and API and API.casts then - local spellname, _, _, _, starttime, endtime = API.GetCastInfo("player") - spell = spellname - time = starttime and endtime and endtime - starttime or nil - else - local spellname, _, _, _, starttime, endtime = UnitCastingInfo("player") - spell = spellname - time = starttime and endtime and endtime - starttime or nil - end - end - - if not spell or not time then return end - - if spell_queue[1] == spell and cache[spell_queue[2]] then - local sender = player - local target = senttarget or spell_queue[3] - local amount = cache[spell_queue[2]][1] - local casttime = time - - if spell == PRAYER_OF_HEALING then - target = sender - - for i=1,4 do - if CheckInteractDistance("party"..i, 4) then - libpredict:Heal(player, UnitName("party"..i), amount, casttime) - if GetExpansion() == "vanilla" then -- vanilla - libpredict.sender:SendHealCommMsg("Heal/" .. UnitName("party"..i) .. "/" .. amount .. "/" .. casttime .. "/") - else -- tbc - libpredict.sender:SendHealCommMsg(string.format("002%05d%s", math.min(amount, 99999), UnitName("party"..i))) - end - libpredict.sender.healing = true - end - end - end - - libpredict:Heal(player, target, amount, casttime) - if GetExpansion() == "vanilla" then -- vanilla - libpredict.sender:SendHealCommMsg("Heal/" .. target .. "/" .. amount .. "/" .. casttime .. "/") - else -- tbc - libpredict.sender:SendHealCommMsg(string.format("002%05d%s", math.min(amount, 99999), target)) - end - libpredict.sender.healing = true - - elseif spell_queue[1] == spell and L["resurrections"][spell] then - local target = senttarget or spell_queue[3] - libpredict:Ress(player, target) - libpredict.sender:SendHealCommMsg("Resurrection/" .. target .. "/start/") - libpredict.sender:SendResCommMsg("RES " .. target) - libpredict.sender.resurrecting = true - end - elseif strfind(event, "SPELLCAST_FAILED", 1) or strfind(event, "SPELLCAST_INTERRUPTED", 1) then - if strfind(event, "UNIT_", 1) and arg1 ~= "player" then return end - if libpredict.sender.healing then - libpredict:HealStop(player) - if GetExpansion() == "vanilla" then -- vanilla - libpredict.sender:SendHealCommMsg("HealStop") - else -- tbc - libpredict.sender:SendHealCommMsg("001F") - end - libpredict.sender.healing = nil - elseif libpredict.sender.resurrecting then - local target = senttarget or spell_queue[3] - libpredict:RessStop(player) - libpredict.sender:SendHealCommMsg("Resurrection/stop/") - libpredict.sender:SendResCommMsg("RESNO " .. target) - libpredict.sender.resurrecting = nil - end - senttarget = nil - elseif event == "SPELLCAST_DELAYED" then - if libpredict.sender.healing then - libpredict:HealDelay(player, arg1) - libpredict.sender:SendHealCommMsg("Healdelay/" .. arg1 .. "/") - end - elseif strfind(event, "SPELLCAST_STOP", 1) then - if strfind(event, "UNIT_", 1) and arg1 ~= "player" then return end - libpredict:HealStop(player) - senttarget = nil - end -end) - -function libpredict:Enable() +function P:Enable() if self.enabled then return end - self.enabled = true - self.sender.enabled = true - - -- incoming HealComm / resurrection tracking - self:RegisterEvent("UNIT_HEALTH") - self:RegisterEvent("CHAT_MSG_ADDON") - self:RegisterEvent("PLAYER_TARGET_CHANGED") - - -- prediction cache lifecycle - resetcache:RegisterEvent("PLAYER_ENTERING_WORLD") - resetcache:RegisterEvent("SKILL_LINES_CHANGED") - resetcache:RegisterEvent("UNIT_INVENTORY_CHANGED") - - if GetExpansion() == "vanilla" then - -- ClassicAPI supplies structured player spellcast events on 1.12. Use - -- those instead of registering both event families and processing each cast - -- twice. Keep the native SPELLCAST_DELAYED event because it carries the - -- exact delay delta consumed by HealDelay(). - useClassicSenderEvents = API and API.eventutils and _G.C_EventUtils - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_START") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_STOP") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_FAILED") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_FAILED_QUIET") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_INTERRUPTED") - and _G.C_EventUtils.IsEventValid("UNIT_SPELLCAST_SENT") - - if useClassicSenderEvents then - self.sender:RegisterEvent("UNIT_SPELLCAST_START") - self.sender:RegisterEvent("UNIT_SPELLCAST_STOP") - self.sender:RegisterEvent("UNIT_SPELLCAST_FAILED") - self.sender:RegisterEvent("UNIT_SPELLCAST_FAILED_QUIET") - self.sender:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED") - self.sender:RegisterEvent("UNIT_SPELLCAST_SENT") - else - self.sender:RegisterEvent("SPELLCAST_START") - self.sender:RegisterEvent("SPELLCAST_STOP") - self.sender:RegisterEvent("SPELLCAST_FAILED") - self.sender:RegisterEvent("SPELLCAST_INTERRUPTED") - end - - self.sender:RegisterEvent("SPELLCAST_DELAYED") - self.sender:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF") - else - -- Native TBC event layout and combat log. - self.sender:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") - self.sender:RegisterEvent("UNIT_SPELLCAST_START") - self.sender:RegisterEvent("UNIT_SPELLCAST_STOP") - self.sender:RegisterEvent("UNIT_SPELLCAST_FAILED") - self.sender:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED") - self.sender:RegisterEvent("UNIT_SPELLCAST_SENT") - end - - -- force cache updates - self.sender:RegisterEvent("UNIT_INVENTORY_CHANGED") - self.sender:RegisterEvent("SKILL_LINES_CHANGED") - - if not useClassicSenderEvents then - InstallSenderHooks() + if TryShared() then return end + self.source = "ClassicAPI + HealComm" + player = UnitName("player") + learned = {} -- Session-only: actual gear/talent/level changes invalidate estimates. + if EquipmentChanged() then + local guid = UnitGUID("player") + if guid then hotAmounts[guid] = nil end end + driver = driver or CreateFrame("Frame") + driver:SetScript("OnEvent", OnEvent) + driver:RegisterEvent("PLAYER_ENTERING_WORLD") + driver:RegisterEvent("ADDON_LOADED") + driver:RegisterEvent("PLAYER_TARGET_CHANGED") + API.RegisterUnitEvent(driver,"UNIT_AURA","player","target") + driver:RegisterEvent("CHAT_MSG_ADDON") + driver:RegisterEvent("UNIT_INVENTORY_CHANGED") + -- Form changes can produce SPELLS_CHANGED without changing any heal amount. + -- New spell ranks have separate spell IDs and learn independently. + driver:RegisterEvent("PLAYER_LEVEL_UP") + driver:RegisterEvent("CHARACTER_POINTS_CHANGED") + local native = {"UNIT_SPELLCAST_SENT", "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_DELAYED", + "UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_FAILED", "UNIT_SPELLCAST_INTERRUPTED", + "COMBAT_LOG_EVENT_UNFILTERED"} + for _, name in ipairs(native) do if _G.C_EventUtils.IsEventValid(name) then driver:RegisterEvent(name) end end + elapsed = 0 + RefreshHots("player"); RefreshHots("target") +end +function P:Disable() + self.enabled = false + self.external = nil + if driver then driver:UnregisterAllEvents() end + Reset() end - -ShaguTweaks.libpredict = libpredict \ No newline at end of file diff --git a/main.lua b/main.lua index e5d892c..4546eae 100644 --- a/main.lua +++ b/main.lua @@ -65,6 +65,13 @@ ShaguTweaks:SetScript("OnEvent", function() -- initialize empty config if not ShaguTweaks_config then ShaguTweaks_config = {} end + local prediction = ShaguTweaks.T["Healing Predictions"] + for _, oldName in ipairs({"Enable Healing Prediction", "Enable Healing Prediction on Unit Frames"}) do + local oldPrediction = ShaguTweaks.T[oldName] + if ShaguTweaks_config[prediction] == nil then ShaguTweaks_config[prediction] = ShaguTweaks_config[oldPrediction] end + ShaguTweaks_config[oldPrediction] = nil + end + -- Retire the removed duplicate toggle; ClassicAPI owns these preferences. ShaguTweaks_config[ShaguTweaks.T["Auto Dismount"]] = nil diff --git a/mods/actionbar-grid.lua b/mods/actionbar-grid.lua index 513b3bb..c3279c9 100644 --- a/mods/actionbar-grid.lua +++ b/mods/actionbar-grid.lua @@ -1,6 +1,29 @@ -- Extend the native extra-bar preference to main/bonus action buttons. -- Own exactly one grid reference; drag-and-drop owns its separate reference. local _G = ShaguTweaks.GetGlobalEnv() +local function BonusVisible() + return _G.BonusActionBarFrame and BonusActionBarFrame:IsVisible() +end +local function HideCoveredButton(button) + if ShaguTweaks.API.IsSupportedRuntime() and BonusVisible() then button:Hide() end +end +local function RefreshBaseButtons() + if not ShaguTweaks.API.IsSupportedRuntime() then return end + local previous = this + for i = 1, 12 do + local button = _G["ActionButton" .. i] + if button then + if BonusVisible() then button:Hide() + else + -- Let the native updater restore filled/empty slot visibility using + -- the existing grid references. Never change the contents of a slot. + this = button + ActionButton_Update() + end + end + end + this = previous +end local function UpdateGrid() if not ShaguTweaks.API.IsSupportedRuntime() then return end local show = _G.ALWAYS_SHOW_MULTIBARS == 1 or _G.ALWAYS_SHOW_MULTIBARS == "1" @@ -18,8 +41,20 @@ local function UpdateGrid() end end end + RefreshBaseButtons() end ShaguTweaks.hooksecurefunc("MultiActionBar_UpdateGridVisibility", UpdateGrid) +-- Floating artwork no longer masks the caster bar below the form bar. +-- Native button refreshes and dragging can call Show again: keep covered +-- buttons hidden until the actual bonus frame closes (including animations). +for i = 1, 12 do + local button = _G["ActionButton" .. i] + if button then ShaguTweaks.hooksecurefunc(button, "Show", HideCoveredButton) end +end +if _G.BonusActionBarFrame then + ShaguTweaks.HookScript(BonusActionBarFrame, "OnShow", RefreshBaseButtons) + ShaguTweaks.HookScript(BonusActionBarFrame, "OnHide", RefreshBaseButtons) +end local events = CreateFrame("Frame") events:RegisterEvent("PLAYER_ENTERING_WORLD") events:SetScript("OnEvent", function() diff --git a/mods/actionbar-move.lua b/mods/actionbar-move.lua index f816428..6fd81d1 100644 --- a/mods/actionbar-move.lua +++ b/mods/actionbar-move.lua @@ -4,9 +4,7 @@ local API = ShaguTweaks.API local hooksecurefunc = ShaguTweaks.hooksecurefunc local S = { states = {} } -local function Unlocked() - return API.IsShiftKeyDown() and API.IsControlKeyDown() and API.IsAltKeyDown() -end +local Unlocked = ShaguTweaks.API.AreMoveModifiersDown function S.IsMoved(name) return S.positions and S.positions[name] ~= nil @@ -384,16 +382,7 @@ end local function Schedule() if S.scheduled then return end S.scheduled = true - local ready = CreateFrame("Frame") - ready:RegisterEvent("PLAYER_ENTERING_WORLD") - ready:SetScript("OnEvent", function() - this:UnregisterAllEvents() - this:SetScript("OnUpdate", function() - this:SetScript("OnUpdate", nil) - this:Hide() - if API.timer then API.Defer(Setup) else Setup() end - end) - end) + API.AfterWorldReady(Setup) end local function ReducedEnabled() diff --git a/mods/actionbar-range-color.lua b/mods/actionbar-range-color.lua index 6dd3665..514d0e7 100644 --- a/mods/actionbar-range-color.lua +++ b/mods/actionbar-range-color.lua @@ -52,7 +52,93 @@ end local RANGE_INTERVAL = TOOLTIP_UPDATE_TIME or 0.2 +-- Newer ClassicAPI owns the range sampling; Lua reacts only to slot changes. +local function EnableNativeRange() + local actionbar = _G.C_ActionBar + if not actionbar or type(actionbar.EnableActionRangeCheck) ~= "function" + or not API.eventutils or not _G.C_EventUtils.IsEventValid("ACTION_RANGE_CHECK_UPDATE") then return false end + local states, byButton, ranges = {}, {}, {} + local function Paint(state) + local button = state.button + local slot = state.slot + if not slot then return end + local icon = state.icon + if not icon then return end + local result = ranges[slot] + -- Keep invalid-faction spells neutral, as the old module did. + local kind, id = API.GetActionInfo(slot) + local spells = _G.C_Spell + if result == 0 and kind == "spell" and spells then + if (spells.IsSpellHelpful and spells.IsSpellHelpful(id) and not UnitCanAssist("player", "target")) + or (spells.IsSpellHarmful and spells.IsSpellHarmful(id) and not UnitCanAttack("player", "target")) then result = nil end + end + if result == 0 then + icon:SetVertexColor(button.r or .85, button.g or .18, button.b or .18, button.a or 1) + else + local usable, noMana = IsUsableAction(slot) + if usable and usable ~= 0 then icon:SetVertexColor(1, 1, 1, 1) + elseif noMana and noMana ~= 0 then icon:SetVertexColor(.5, .5, 1, 1) + else icon:SetVertexColor(.4, .4, .4, 1) end + end + end + local function Sync(state) + local slot = state.button:IsVisible() and ActionButton_GetPagedID(state.button) or nil + if slot and (slot < 1 or slot > 120) then slot = nil end + if slot ~= state.slot then + local old = state.slot + state.slot = slot + if old then actionbar.EnableActionRangeCheck(old, false) end + if slot then + ranges[slot] = nil + actionbar.EnableActionRangeCheck(slot, true) + end + end + Paint(state) + end + local function ButtonChanged() + local state = byButton[this] + if state then Sync(state) end + end + local function AllChanged() + for _, state in ipairs(states) do Sync(state) end + end + local listener = CreateFrame("Frame") + listener:RegisterEvent("ACTION_RANGE_CHECK_UPDATE") + listener:RegisterEvent("PLAYER_ENTERING_WORLD") + listener:RegisterEvent("PLAYER_TARGET_CHANGED") + listener:RegisterEvent("ACTIONBAR_PAGE_CHANGED") + listener:RegisterEvent("UPDATE_BONUS_ACTIONBAR") + listener:RegisterEvent("ACTIONBAR_SLOT_CHANGED") + listener:SetScript("OnEvent", function() + if event == "ACTION_RANGE_CHECK_UPDATE" then + ranges[arg1] = arg3 and NormalizeRange(arg2) or nil + for _, state in ipairs(states) do if state.slot == arg1 then Paint(state) end end + else + -- Unchanged native results do not fire again: retain their cached state. + AllChanged() + end + end) + for _, prefix in ipairs({"ActionButton", "BonusActionButton", "MultiBarBottomLeftButton", + "MultiBarBottomRightButton", "MultiBarLeftButton", "MultiBarRightButton"}) do + for i = 1, 12 do + local button = _G[prefix .. i] + if button then + local state = {button = button, icon = _G[prefix .. i .. "Icon"]} + byButton[button] = state + table.insert(states, state) + ShaguTweaks.HookScript(button, "OnShow", ButtonChanged) + ShaguTweaks.HookScript(button, "OnHide", ButtonChanged) + end + end + end + hooksecurefunc("ActionButton_Update", ButtonChanged) + hooksecurefunc("ActionButton_UpdateUsable", ButtonChanged) + AllChanged() + return true +end + module.enable = function(self) + if EnableNativeRange() then return end hooksecurefunc("ActionButton_OnUpdate", function(elapsed) -- Blizzard sets rangeTimer only for actions that have a range. Use our -- own countdown instead of sharing it: decrementing rangeTimer here and diff --git a/mods/auto-stance.lua b/mods/auto-stance.lua index e3fb84b..0dc7f43 100644 --- a/mods/auto-stance.lua +++ b/mods/auto-stance.lua @@ -3,7 +3,7 @@ local cmatch = ShaguTweaks.cmatch local module = ShaguTweaks:register({ title = T["Auto Stance"], - description = T["Automatically switch to the required warrior or druid stance on spell cast."], + description = T["Automatically switch to the warrior stance or druid form required by a spell. To leave a form for spells such as Moonfire, enable ClassicAPI Auto Cancel Form."], expansions = { ["vanilla"] = true }, category = T["Action Bar"], enabled = true, diff --git a/mods/castbar-move.lua b/mods/castbar-move.lua index c09b6cf..1a068de 100644 --- a/mods/castbar-move.lua +++ b/mods/castbar-move.lua @@ -13,13 +13,7 @@ local POSITION_KEY = "CastBarPosition" -- One table for state: Lua 5.0 allows only 32 upvalues per function. local S = { preview = false } -local function ModifiersDown() - local API = ShaguTweaks.API or {} - local shift = API.IsShiftKeyDown or IsShiftKeyDown - local ctrl = API.IsControlKeyDown or IsControlKeyDown - local alt = API.IsAltKeyDown or IsAltKeyDown - return shift() and ctrl() and alt() and true or false -end +local ModifiersDown = ShaguTweaks.API.AreMoveModifiersDown local function SavedPosition() local pos = ShaguTweaks_config and ShaguTweaks_config[POSITION_KEY] diff --git a/mods/equip-compare.lua b/mods/equip-compare.lua index 0c653c6..f0a42dc 100644 --- a/mods/equip-compare.lua +++ b/mods/equip-compare.lua @@ -10,11 +10,175 @@ local module = ShaguTweaks:register({ enabled = true, }) +local function EnableNativeCompare(self) + if not API.tooltipsetitem or not API.modifierstate or not API.timer + or not ShoppingTooltip1 or not ShoppingTooltip2 + or type(ShoppingTooltip1.SetHyperlinkCompareItem) ~= "function" + or type(ShoppingTooltip2.SetHyperlinkCompareItem) ~= "function" then return false end + local active, activeLink, activeOwner, activeRevision + local firstShown, secondShown + local revision, building = 0, false + local tracked, order = {}, {} + local function Hide(tooltip) + if tooltip and tooltip ~= active then return end + active = nil + activeLink, activeOwner, activeRevision = nil, nil, nil + firstShown, secondShown = nil, nil + ShoppingTooltip1:Hide(); ShoppingTooltip2:Hide() + end + local function Restore(tooltip, link, owner) + if not API.IsShiftKeyDown() or active ~= tooltip or activeLink ~= link + or activeOwner ~= owner or activeRevision ~= revision then return false end + -- Hide clears native ownership. Show alone can briefly display the cached + -- text but leave an unowned tooltip that the client then discards/fades. + -- Rebind to the actual hovered button (not the source tooltip) and refill + -- in the same frame only when the native owner/content was lost. + building = true + for i = 1, 2 do + local compare = i == 1 and ShoppingTooltip1 or ShoppingTooltip2 + if (i == 1 and firstShown) or (i == 2 and secondShown) then + if not compare:IsShown() or compare:GetOwner() ~= (owner or UIParent) then + local point, relative, relativePoint, x, y = compare:GetPoint() + compare:SetOwner(owner or UIParent, "ANCHOR_NONE") + if point then compare:SetPoint(point, relative, relativePoint, x, y) end + if compare:SetHyperlinkCompareItem(link, i, true, tooltip) then compare:Show() end + end + end + end + building = false + return true + end + local function Show(tooltip) + if building then return end + if not tooltip:IsShown() then Hide(tooltip); return end + if not API.IsShiftKeyDown() then Hide(); return end + local _, link = tooltip:GetItem() + if not link then Hide(tooltip); return end + local owner = tooltip:GetOwner() + -- Native/tooltips addons can rebuild the hovered item repeatedly. Its + -- comparison does not change unless the item, owner or equipped data did. + if Restore(tooltip, link, owner) then return end + building = true + Hide() + local x = GetCursorPosition() / UIParent:GetEffectiveScale() + local left = x >= GetScreenWidth() / 2 + local point, parent = tooltip:GetPoint() + if parent == UIParent and point == "TOPRIGHT" then left = true end + local anchor, relative = left and "TOPRIGHT" or "TOPLEFT", left and "TOPLEFT" or "TOPRIGHT" + local previous = tooltip + for i = 1, 2 do + local compare = i == 1 and ShoppingTooltip1 or ShoppingTooltip2 + compare:SetOwner(owner or UIParent, "ANCHOR_NONE") + compare:ClearAllPoints() + compare:SetPoint(anchor, previous, relative, 0, 0) + -- Preserve the full item link (enchants/random suffixes). Native code + -- selects filled ring/weapon slots and supplies the equipped header. + if compare:SetHyperlinkCompareItem(link, i, true, tooltip) then + compare:Show() + previous = compare + active = tooltip + else compare:Hide() end + end + if active then + activeLink, activeOwner, activeRevision = link, owner, revision + firstShown, secondShown = ShoppingTooltip1:IsShown(), ShoppingTooltip2:IsShown() + end + building = false + end + local function Schedule(tooltip) + local state = tracked[tooltip] + if building or not state or state.pending then return end + state.pending = true + API.Defer(state.refresh) + end + -- A comparison may be hidden by native/foreign tooltip code independently + -- of its source. Repair on that event; no recurring polling driver. + local function ComparisonHidden() + if active and not building then Schedule(active) end + end + ShoppingTooltip1:HookScript("OnHide", ComparisonHidden) + ShoppingTooltip2:HookScript("OnHide", ComparisonHidden) + local function SourceUpdated(tooltip) + if building then return end + if tooltip:IsShown() then + local _, link = tooltip:GetItem() + if link then Restore(tooltip, link, tooltip:GetOwner()) end + end + Schedule(tooltip) + end + local function Track(tooltip) + if not tooltip or tracked[tooltip] or type(tooltip.GetItem) ~= "function" then return end + local state = {} + tracked[tooltip] = state + table.insert(order, tooltip) + state.refresh = function() + state.pending = nil + Show(tooltip) + end + local updated = function() SourceUpdated(tooltip) end + local settled = function() Schedule(tooltip) end + tooltip:HookScript("OnTooltipSetItem", updated) + -- Clearing is part of a normal item rebuild. Inspect the settled content + -- next frame instead of hiding a still-valid comparison immediately. + tooltip:HookScript("OnTooltipCleared", settled) + tooltip:HookScript("OnHide", settled) + tooltip:HookScript("OnShow", updated) + end + ShoppingTooltip1:SetClampedToScreen(true) + ShoppingTooltip2:SetClampedToScreen(true) + Track(GameTooltip) + Track(ItemRefTooltip) + ShaguTweaks.HookAddonOrVariable("AtlasLoot", function() + Track(AtlasLootTooltip); Track(AtlasLootTooltip2) + end) + local function VisibleItemTooltip() + for _, tooltip in ipairs(order) do + if tooltip:IsShown() then + local _, link = tooltip:GetItem() + if link then return tooltip end + end + end + end + local modifier = CreateFrame("Frame") + self.nativeModifier = modifier + modifier:RegisterEvent("MODIFIER_STATE_CHANGED") + local utils = _G.C_EventUtils + if utils and utils.IsEventValid and utils.IsEventValid("PLAYER_EQUIPMENT_CHANGED") then + modifier:RegisterEvent("PLAYER_EQUIPMENT_CHANGED") + else modifier:RegisterEvent("UNIT_INVENTORY_CHANGED") end + if utils and utils.IsEventValid and utils.IsEventValid("GET_ITEM_INFO_RECEIVED") then + modifier:RegisterEvent("GET_ITEM_INFO_RECEIVED") + end + modifier:SetScript("OnEvent", function() + if event == "PLAYER_EQUIPMENT_CHANGED" or event == "UNIT_INVENTORY_CHANGED" + or event == "GET_ITEM_INFO_RECEIVED" then + if event == "UNIT_INVENTORY_CHANGED" and arg1 ~= "player" then return end + revision = revision + 1 + if not API.IsShiftKeyDown() then return end + if active then Schedule(active) + else + local tooltip = VisibleItemTooltip() + if tooltip then Schedule(tooltip) end + end + return + end + if arg1 ~= "LSHIFT" and arg1 ~= "RSHIFT" then return end + if not API.IsShiftKeyDown() then Hide(); return end + if active and active:IsShown() then Show(active); return end + local tooltip = VisibleItemTooltip() + if tooltip then Show(tooltip) end + end) + return true +end + module.enable = function(self) + if self.installed then return end + if EnableNativeCompare(self) then self.installed = true; return end if not API.tooltipsetitem or not API.modifierstate or not API.timer or not API.iteminventorytype or not API.iteminventoryslotkey then return end + self.installed = true local sides = { "Left", "Right" } diff --git a/mods/health-numbers.lua b/mods/health-numbers.lua index 7e16ed9..a5900a1 100644 --- a/mods/health-numbers.lua +++ b/mods/health-numbers.lua @@ -300,6 +300,10 @@ module.enable = function(self) self.valueEvents = self.valueEvents or CreateFrame("Frame") local events = self.valueEvents + local healthTexts = { player = self.valueTexts.playerHealth, + target = self.valueTexts.targetHealth, pet = self.valueTexts.petHealth } + local powerTexts = { player = self.valueTexts.playerMana, + target = self.valueTexts.targetMana, pet = self.valueTexts.petMana } events:RegisterEvent("PLAYER_ENTERING_WORLD") events:RegisterEvent("PLAYER_TARGET_CHANGED") @@ -329,13 +333,14 @@ module.enable = function(self) UpdateTarget() elseif event == "PET_UI_UPDATE" then UpdatePet() - elseif arg1 == "player" then - UpdatePlayer() - UpdateAlternatePower() - elseif arg1 == "target" then - UpdateTarget() - elseif arg1 == "pet" then - UpdatePet() + elseif arg1 == "player" or arg1 == "target" or arg1 == "pet" then + local unit = arg1 + if event == "UNIT_HEALTH" or event == "UNIT_MAXHEALTH" then + UpdateHealth(healthTexts[unit], unit, unit == "target") + else + UpdatePower(powerTexts[unit], unit) + if unit == "player" then UpdateAlternatePower() end + end end end) diff --git a/mods/loss-of-control.lua b/mods/loss-of-control.lua index dfb448f..0333a7b 100644 --- a/mods/loss-of-control.lua +++ b/mods/loss-of-control.lua @@ -174,13 +174,7 @@ local function RestorePosition() end end -local function ModifiersDown() - local API = ShaguTweaks.API or {} - local shift = API.IsShiftKeyDown or IsShiftKeyDown - local ctrl = API.IsControlKeyDown or IsControlKeyDown - local alt = API.IsAltKeyDown or IsAltKeyDown - return shift() and ctrl() and alt() and true or false -end +local ModifiersDown = ShaguTweaks.API.AreMoveModifiersDown -- Hold Ctrl+Shift+Alt to show a preview and drag the alert. local function SetUnlocked(on) diff --git a/mods/macro-icons.lua b/mods/macro-icons.lua index 4eb3ca6..d90bc0b 100755 --- a/mods/macro-icons.lua +++ b/mods/macro-icons.lua @@ -5,14 +5,113 @@ local libspell = ShaguTweaks.libspell local module = ShaguTweaks:register({ title = T["Macro Icons"], - description = T["Detect showtooltip and spells in macros to use them on action buttons."], + description = T["Adds legacy --showtooltip and /pfcast icon hints. Current ClassicAPI handles ordinary macro icons and preserves manually chosen icons."], expansions = { ["vanilla"] = true, ["tbc"] = false }, maintainer = "@shagu (GitHub)", category = T["Macro"], enabled = true, }) +local function EnableNativeIcons(self) + if not API.macrodisplay then return false end + -- Another macro parser owns its custom syntax and display decisions. + if _G.CleveRoids or (type(_G.IsAddOnLoaded) == "function" + and _G.IsAddOnLoaded("SuperCleveRoidMacros")) then return true end + local entries, owned = {}, {} + local driver = CreateFrame("Frame", "ShaguTweaksMacroIcons", UIParent) + self.nativeDriver = driver + local ticker + local customEvents = { "MODIFIER_STATE_CHANGED", "PLAYER_TARGET_CHANGED", + "UPDATE_MOUSEOVER_UNIT", "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED" } + local function Release() + if ticker then ticker:Cancel(); ticker = nil end + for id in pairs(owned) do _G.C_Macro.SetMacroDisplay(id, nil); owned[id] = nil end + entries = {} + end + local function Refresh() + for id, entry in pairs(entries) do + local value, unit = entry.value, nil + if entry.conditional and type(_G.SecureCmdOptionParse) == "function" then + value, unit = _G.SecureCmdOptionParse(value, true) + value = value or false + end + if not entry.published or entry.lastValue ~= value or entry.lastUnit ~= unit then + local resolved = _G.C_Macro.SetMacroDisplay(id, value, unit) + entry.lastValue, entry.lastUnit = value, unit + entry.published = value == false or resolved + end + end + end + local function Rebuild() + if driver.rebuilding then return end + driver.rebuilding = true + Release() + local maximum = math.max(_G.MAX_MACROS or 36, + (_G.MAX_ACCOUNT_MACROS or 0) + (_G.MAX_CHARACTER_MACROS or 0)) + local conditional = false + for id = 1, maximum do + local name, _, body = GetMacroInfo(id) + if name and body then + local explicit, hint, pfcast + for line in string.gfind(body, "[^\n]+") do + local _, _, directive = string.find(line, "^%s*(#[^%s]+)") + if directive == "#showtooltip" or directive == "#show" then explicit = true end + local _, _, value = string.find(line, "%-%-showtooltip%s+(.+)") + hint = hint or value + if not pfcast then _, _, pfcast = string.find(line, "^%s*/pfcast%s+(.+)") end + end + -- Native directives always win. Normal macros need no Lua icon writes, + -- action-bar scans or spellbook lookups at all. + local value = not explicit and (hint or pfcast) + if value then + value = string.gsub(value, "%s+$", "") + local hasConditions = string.find(value, "[", 1, true) ~= nil + entries[id] = { value = value, conditional = hasConditions } + owned[id] = true + conditional = conditional or hasConditions + end + end + end + Refresh() + -- Only conditional custom hints need periodic re-evaluation (e.g. stance + -- or cooldown conditions). Ordinary native macros have no Lua poller. + if conditional and _G.C_Timer and type(_G.C_Timer.NewTicker) == "function" then + ticker = _G.C_Timer.NewTicker(.2, Refresh) + end + for _, name in ipairs(customEvents) do + if conditional then driver:RegisterEvent(name) else driver:UnregisterEvent(name) end + end + if next(entries) then + driver:RegisterEvent("SPELLS_CHANGED") + if _G.C_EventUtils and type(_G.C_EventUtils.IsEventValid) == "function" + and _G.C_EventUtils.IsEventValid("GET_ITEM_INFO_RECEIVED") then + driver:RegisterEvent("GET_ITEM_INFO_RECEIVED") + end + else + driver:UnregisterEvent("SPELLS_CHANGED") + driver:UnregisterEvent("GET_ITEM_INFO_RECEIVED") + end + driver.rebuilding = nil + end + driver:RegisterEvent("PLAYER_ENTERING_WORLD") + driver:RegisterEvent("UPDATE_MACROS") + driver:RegisterEvent("PLAYER_LOGOUT") + driver:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" or event == "UPDATE_MACROS" then Rebuild() + elseif event == "PLAYER_LOGOUT" then driver:UnregisterAllEvents(); Release() + elseif event == "SPELLS_CHANGED" then + for _, entry in pairs(entries) do entry.published = nil end + Refresh() + else Refresh() end + end) + Rebuild() + return true +end + module.enable = function(self) + if self.installed then return end + self.installed = true + if EnableNativeIcons(self) then return end local gfind = string.gmatch or string.gfind local function BuildMacroCache() diff --git a/mods/macro-tweaks.lua b/mods/macro-tweaks.lua index fee3257..6dc439f 100755 --- a/mods/macro-tweaks.lua +++ b/mods/macro-tweaks.lua @@ -4,7 +4,7 @@ local API = ShaguTweaks.API local module = ShaguTweaks:register({ title = T["Macro Tweaks"], - description = T["Add /equip, /use and modern ClassicAPI macro commands, remove #showtooltip from chat and hide macro commands from history."], + description = T["Adds missing interactive /equip, /use, attack and focus aliases using ClassicAPI's native parser when available, and keeps executed macro commands out of chat history."], expansions = { ["vanilla"] = true, ["tbc"] = false }, maintainer = "@shagu (GitHub)", category = T["Macro"], @@ -12,22 +12,25 @@ local module = ShaguTweaks:register({ }) module.enable = function(self) - -- This interception is intentional: #showtooltip is macro metadata and must - -- not be sent as chat text by the Vanilla macro parser. - local hookSendChatMessage = _G.SendChatMessage - local function IsShowTooltipMetadata(msg) - if not msg then return false end + if self.installed then return end + self.installed = true + if not API.nativemacrocommands then + -- Only older APIs need interception; current ClassicAPI owns metadata. + local hookSendChatMessage = _G.SendChatMessage + local function IsShowTooltipMetadata(msg) + if not msg then return false end - local line = string.gsub(msg, "^%s+", "") - line = string.gsub(line, "%s+$", "") + local line = string.gsub(msg, "^%s+", "") + line = string.gsub(line, "%s+$", "") - return line == "#showtooltip" - or string.find(line, "^#showtooltip%s+") ~= nil - end + return line == "#showtooltip" + or string.find(line, "^#showtooltip%s+") ~= nil + end - function _G.SendChatMessage(msg, a1, a2, a3, a4, a5, a6, a7, a8) - if IsShowTooltipMetadata(msg) then return end - return hookSendChatMessage(msg, a1, a2, a3, a4, a5, a6, a7, a8) + function _G.SendChatMessage(msg, a1, a2, a3, a4, a5, a6, a7, a8) + if IsShowTooltipMetadata(msg) then return end + return hookSendChatMessage(msg, a1, a2, a3, a4, a5, a6, a7, a8) + end end -- do not write executed macro commands into chat input history @@ -108,7 +111,15 @@ module.enable = function(self) local function RegisterSlashAlias(id, command, handler) if IsSlashRegistered(command) then return end _G["SLASH_" .. id .. "1"] = command - _G.SlashCmdList[id] = handler + if API.nativemacrocommands then + -- These commands are native macro primitives even if no interactive + -- chat alias exists. Preserve conditions, item links and native targeting. + _G.SlashCmdList[id] = function(msg) + _G.RunMacroText(command .. " " .. (msg or "")) + end + else + _G.SlashCmdList[id] = handler + end end RegisterSlashAlias("SHAGUTWEAKS_STARTATTACK", "/startattack", function(msg) diff --git a/mods/move-unitframes.lua b/mods/move-unitframes.lua index 7ddafdd..dc685a3 100644 --- a/mods/move-unitframes.lua +++ b/mods/move-unitframes.lua @@ -632,7 +632,7 @@ module.enable = function(self) end local function UpdateLockState() - if API.IsShiftKeyDown() and API.IsControlKeyDown() and API.IsAltKeyDown() then + if API.AreMoveModifiersDown() then UnlockAll() else LockAll() diff --git a/mods/superwow.lua b/mods/superwow.lua index 5537794..fafd471 100644 --- a/mods/superwow.lua +++ b/mods/superwow.lua @@ -9,6 +9,7 @@ if not GetPlayerBuffID or not CombatLogAdd or not SpellInfo then return end local T = ShaguTweaks.T +local API = ShaguTweaks.API local module = ShaguTweaks:register({ title = T["Super WoW Compatibility"], @@ -19,30 +20,59 @@ local module = ShaguTweaks:register({ }) module.enable = function(self) + if self.listener then return end + local enemyTitle = T["Enemy Castbars"] + local enabled = ShaguTweaks_config[enemyTitle] + if enabled == nil then + local consumer = ShaguTweaks.mods[enemyTitle] + enabled = consumer and consumer.enabled and 1 or 0 + end + if enabled ~= 1 then return end local libcast = ShaguTweaks.libcast + local playerName = UnitName("player") + local lastPrune = -1 - local function ClearCast(cast) - cast.cast = nil - cast.rank = nil - cast.start = nil - cast.casttime = nil - cast.icon = nil - cast.channel = nil - cast.spellID = nil + local function Changed(guid) + if libcast.OnRemoteChanged then libcast.OnRemoteChanged(guid) end end local unitcast = CreateFrame("Frame") + self.listener = unitcast unitcast:RegisterEvent("UNIT_CASTEVENT") unitcast:SetScript("OnEvent", function() local guid = arg1 local event_type = arg3 local spellID = arg4 - if not guid then return end + if type(guid) ~= "string" or type(spellID) ~= "number" then return end + -- Player timing belongs to ClassicAPI (or Hunter-only local emulation). + if guid == API.UnitGUID("player") then return end + local now = GetTime() + if now - lastPrune >= .5 then libcast:Prune(now); lastPrune = now end if event_type == "START" or event_type == "CAST" or event_type == "CHANNEL" then -- human readable argument list local timer = arg5 + if type(timer) ~= "number" or timer ~= timer or timer <= 0 or timer > 600000 then + local cast = libcast.db[guid] + if cast and cast.spellID == spellID then libcast.db[guid] = nil; Changed(guid) end + return + end + + -- A complete native record needs no second Lua copy. Keep SuperWoW + -- timing only where the native unit-token query cannot provide it. + local token = API.UnitTokenFromGUID(guid) + if token then + local name, finish + if event_type == "CHANNEL" then + local a, b, c, d, e, f = API.GetChannelInfo(token) + name, finish = a, f + else + local a, b, c, d, e, f = API.GetCastInfo(token) + name, finish = a, f + end + if name and finish and finish > now * 1000 then libcast.db[guid] = nil; return end + end -- get spell info from spell id local spell, icon, _ @@ -55,13 +85,24 @@ module.enable = function(self) -- add cast action to the database local cast = libcast.db[guid] if not cast then + -- Cache only a bounded number of live remote records, even in long + -- sessions with unique NPC GUIDs. No permanent cleanup OnUpdate. + local count, earliest, expiry = 0, nil, nil + for key, entry in pairs(libcast.db) do + if key ~= playerName then + count = count + 1 + local ending = entry.start + entry.casttime/1000 + if not expiry or ending < expiry then earliest, expiry = key, ending end + end + end + if count >= 128 and earliest then libcast.db[earliest] = nil end cast = {} libcast.db[guid] = cast end cast.cast = spell cast.rank = nil - cast.start = GetTime() + cast.start = now cast.casttime = timer cast.icon = icon cast.channel = event_type == "CHANNEL" or false @@ -69,13 +110,15 @@ module.enable = function(self) -- write state variable ShaguTweaks.superwow_active = true + Changed(guid) elseif event_type == "FAIL" then local cast = libcast.db[guid] -- Ignore a delayed FAIL for an older spell. Otherwise it can erase a -- newer cast from the same GUID that already replaced it in the cache. if cast and cast.spellID == spellID then - ClearCast(cast) + libcast.db[guid] = nil + Changed(guid) end end end) diff --git a/mods/swing-timer.lua b/mods/swing-timer.lua index 822abbf..244e176 100644 --- a/mods/swing-timer.lua +++ b/mods/swing-timer.lua @@ -131,12 +131,7 @@ local function Supported() return true end -local function ModifiersDown() - local shift = API.IsShiftKeyDown or IsShiftKeyDown - local ctrl = API.IsControlKeyDown or IsControlKeyDown - local alt = API.IsAltKeyDown or IsAltKeyDown - return shift() and ctrl() and alt() -end +local ModifiersDown = ShaguTweaks.API.AreMoveModifiersDown local function HasOffhandWeapon() local items = _G.C_Item @@ -207,14 +202,47 @@ local function SetProgress(bar, progress, r, g, b) end local function StopBar(bar) + if bar.wake then bar.wake:Cancel(); bar.wake = nil end + if bar.windupWake then bar.windupWake:Cancel(); bar.windupWake = nil end + if bar.binding then bar.binding:Disable() end bar.active = false + bar.inWindup = false bar.timer = 0 bar.graceAt = nil if not S.preview then bar:Hide() end end +local function Running(bar) + return bar.key == "ranged" and S.autorepeat + or (bar.key ~= "ranged" and S.autoattack and S.combat) +end + +local function UpdateColors() + if S.preview then return end + ReconcileQueue() + for i = 1, 3 do + local bar = S.bars[ORDER[i]] + local color = COLORS[bar.key] + if bar.key == "mainhand" and S.queued then color = COLORS[S.queued] end + if bar.key == "ranged" and S.hunter and bar.active and bar.inWindup then color = COLORS.windup end + if S.far[bar.swingType] then color = COLORS.far end + bar:SetStatusBarColor(unpack(color)) + end +end + +local function StopWaitingBars() + if not S.native then return end + local changed = false + for i = 1, 3 do + local bar = S.bars[ORDER[i]] + if bar.active and bar.graced and not Running(bar) then StopBar(bar); changed = true end + end + if changed then Layout() end +end + local function StartBar(bar, remaining) if not Wanted(bar) or S.preview then return end + if type(remaining) ~= "number" or remaining ~= remaining or remaining < 0 then return end UpdateSpeeds() if bar.speed <= 0 then return end @@ -224,13 +252,29 @@ local function StartBar(bar, remaining) bar.timer = remaining bar.graceAt = nil bar.active = true + bar.graced = nil + bar.inWindup = bar.key == "ranged" and S.hunter and remaining > 0 and remaining <= AUTOSHOT_WINDUP UpdateIcon(bar) + if S.native then + if bar.wake then bar.wake:Cancel() end + if bar.windupWake then bar.windupWake:Cancel(); bar.windupWake = nil end + bar.duration:SetTimeFromEnd(GetTime() + remaining, bar.max) + bar:SetTimerDuration(bar.duration, _G.Enum.StatusBarInterpolation.Immediate, + _G.Enum.StatusBarTimerDirection.ElapsedTime) + bar.binding:Enable() + bar.wake = _G.C_Timer.NewTimer(remaining, bar.due) + if bar.key == "ranged" and S.hunter and remaining > AUTOSHOT_WINDUP then + bar.windupWake = _G.C_Timer.NewTimer(remaining - AUTOSHOT_WINDUP, bar.windup) + end + UpdateColors() + end + if not bar:IsShown() then bar:Show() Layout() end - S.driver:Show() + if not S.native then S.driver:Show() end end local function ResetAll() @@ -270,10 +314,10 @@ local function Tick(bar, delta, now) SetProgress(bar, 1 - bar.timer / bar.max, r, g, b) - local text = string.format("%.1f", math.floor(bar.timer * 10) / 10) - if bar.lastText ~= text then - bar.lastText = text - bar.text:SetText(text) + local tenth = math.floor(bar.timer * 10) + if bar.lastTenth ~= tenth then + bar.lastTenth = tenth + bar.text:SetText(string.format("%.1f", tenth / 10)) end end @@ -325,6 +369,39 @@ local function CreateBar(key) bar.text = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlight") bar.text:SetPoint("CENTER", bar, "CENTER", 0, 3) + -- As in pfUI, native duration objects own the fill and countdown. The + -- spark follows the native fill texture without a Lua position updater. + if S.native and type(bar.SetTimerDuration) ~= "function" then S.native = false end + if S.native then + bar.duration = _G.C_DurationUtil.CreateDuration() + bar.binding = _G.C_DurationUtil.CreateDurationTextBinding() + bar.binding:SetFontString(bar.text) + bar.binding:SetDuration(bar.duration) + bar.binding:SetFormatter(S.formatter) + bar.binding:SetExpiredText("0.0") + bar.binding:Disable() + bar.spark:ClearAllPoints() + bar.spark:SetPoint("CENTER", bar:GetStatusBarTexture(), "RIGHT", 0, 2) + -- Allocate callbacks once per bar, not on every swing reset. + bar.grace = function() + bar.wake = nil + bar.graced = true + if not Running(bar) then StopBar(bar); Layout() end + end + bar.due = function() + bar.inWindup = false + bar.binding:Disable() + if not S.preview then bar.text:SetText("0.0") end + bar.wake = _G.C_Timer.NewTimer(GRACE, bar.grace) + UpdateColors() + end + bar.windup = function() + bar.windupWake = nil + bar.inWindup = true + UpdateColors() + end + end + bar.icon = CreateFrame("Frame", nil, bar) bar.icon:SetPoint("RIGHT", bar, "LEFT", -10, 2) bar.icon:SetWidth(28) @@ -381,18 +458,27 @@ local function SetPreview(on) bar:EnableMouse(on) if not on then bar:StopMovingOrSizing() end if on and Wanted(bar) then + if bar.binding then bar.binding:Disable(); bar:SetMinMaxValues(0, 1) end UpdateIcon(bar) local r, g, b = unpack(COLORS[bar.key]) SetProgress(bar, 1, r, g, b) bar.text:SetText(PREVIEW[bar.key]) - bar.lastText = nil + bar.lastTenth = nil bar:Show() + elseif S.native and bar.active then + bar.spark:ClearAllPoints() + bar.spark:SetPoint("CENTER", bar:GetStatusBarTexture(), "RIGHT", 0, 2) + bar:SetTimerDuration(bar.duration, _G.Enum.StatusBarInterpolation.Immediate, + _G.Enum.StatusBarTimerDirection.ElapsedTime) + if bar.duration:GetRemainingDuration() > 0 then bar.binding:Enable() + else bar.text:SetText("0.0") end elseif not bar.active then bar:Hide() end end Layout() + if S.native then UpdateColors() end end -- Keep the bars where they are when something they were stacked on (the cast @@ -431,11 +517,20 @@ local function CreateCore() S.container:SetHeight(HEIGHT) S.container:EnableMouse(false) + S.native = API.durationtimers and true or false + if S.native then + S.formatter = _G.C_StringUtil.CreateNumericRuleFormatter() + S.formatter:AddBreakpoint({ threshold = 0, step = .1, + rounding = _G.Enum.NumericRuleFormatRounding.Down, format = "%.1f" }) + end + for i = 1, 3 do S.bars[ORDER[i]] = CreateBar(ORDER[i]) end - S.driver = CreateFrame("Frame", nil, UIParent) - S.driver:Hide() - S.driver:SetScript("OnUpdate", OnUpdate) + if not S.native then + S.driver = CreateFrame("Frame", nil, UIParent) + S.driver:Hide() + S.driver:SetScript("OnUpdate", OnUpdate) + end S.events = CreateFrame("Frame") S.events:SetScript("OnEvent", function() @@ -492,6 +587,11 @@ local function CreateCore() elseif event == "ACTIONBAR_SLOT_CHANGED" then if S.queue then ScanQueueSlots() end + elseif event == "ACTIONBAR_UPDATE_STATE" then + -- Native timers do not poll action buttons. This event covers cancelling + -- a queued attack by pressing it again or pressing Escape. + ReconcileQueue() + elseif event == "PLAYER_EQUIPMENT_CHANGED" or event == "UNIT_INVENTORY_CHANGED" then if event == "PLAYER_EQUIPMENT_CHANGED" and arg1 ~= 16 and arg1 ~= 17 and arg1 ~= 18 then return end UpdateSpeeds() @@ -515,6 +615,7 @@ local function CreateCore() UpdateSpeeds() RestorePosition() end + if S.native then StopWaitingBars(); UpdateColors() end end) local events = { @@ -581,6 +682,7 @@ local function EnableQueueColors() end end events:RegisterEvent("ACTIONBAR_SLOT_CHANGED") + events:RegisterEvent("ACTIONBAR_UPDATE_STATE") end swing.enable = function(self) diff --git a/mods/target-castbar.lua b/mods/target-castbar.lua index 36b1237..696e608 100644 --- a/mods/target-castbar.lua +++ b/mods/target-castbar.lua @@ -346,12 +346,7 @@ local function SetPreview(on) end end -local function ModifiersDown() - local shift = API.IsShiftKeyDown or IsShiftKeyDown - local ctrl = API.IsControlKeyDown or IsControlKeyDown - local alt = API.IsAltKeyDown or IsAltKeyDown - return shift() and ctrl() and alt() and true or false -end +local ModifiersDown = ShaguTweaks.API.AreMoveModifiersDown module.enable = function(self) local castEvents = { @@ -408,5 +403,8 @@ module.enable = function(self) end end) + ShaguTweaks.libcast.OnRemoteChanged = function(guid) + if guid == API.UnitGUID("target") then RefreshCast() end + end RefreshCast() end diff --git a/mods/totem-timers.lua b/mods/totem-timers.lua index 6c9f37f..6e20d2f 100644 --- a/mods/totem-timers.lua +++ b/mods/totem-timers.lua @@ -139,13 +139,7 @@ local function RestorePosition() end end -local function ModifiersDown() - local API = ShaguTweaks.API or {} - local shift = API.IsShiftKeyDown or IsShiftKeyDown - local ctrl = API.IsControlKeyDown or IsControlKeyDown - local alt = API.IsAltKeyDown or IsAltKeyDown - return shift() and ctrl() and alt() and true or false -end +local ModifiersDown = ShaguTweaks.API.AreMoveModifiersDown -- Hold Ctrl+Shift+Alt to show four preview icons and drag them. local function SetUnlocked(on) diff --git a/mods/unitframes-energy-tick.lua b/mods/unitframes-energy-tick.lua index 9fe061d..2cf3773 100644 --- a/mods/unitframes-energy-tick.lua +++ b/mods/unitframes-energy-tick.lua @@ -1,92 +1,143 @@ +local _G = ShaguTweaks.GetGlobalEnv() local T = ShaguTweaks.T - +local API = ShaguTweaks.API local module = ShaguTweaks:register({ title = T["Unit Frame Energy & Mana Tick"], description = T["Adds an energy & mana tick to the player frame."], - expansions = { ["vanilla"] = true }, - category = T["Unit Frames"], - enabled = nil, + expansions = { ["vanilla"] = true }, category = T["Unit Frames"], enabled = nil, }) - module.enable = function(self) - local energytick = CreateFrame("Frame", nil, PlayerFrameManaBar) - energytick:SetAllPoints(PlayerFrameManaBar) - energytick:RegisterEvent("PLAYER_ENTERING_WORLD") - energytick:RegisterEvent("UNIT_DISPLAYPOWER") - energytick:RegisterEvent("UNIT_ENERGY") - energytick:RegisterEvent("UNIT_MANA") - energytick:SetScript("OnEvent", function() - -- This module only tracks the player. Ignore unrelated unit events before - -- doing any power queries or visibility work. - if arg1 and arg1 ~= "player" then return end - - if UnitPowerType("player") == 0 then - this.mode = "MANA" - -- hide if full mana and not in combat - if (UnitMana("player") == UnitManaMax("player")) and (not UnitAffectingCombat("player")) then - this:Hide() - else - this:Show() + if self.tick then return end + local tick = CreateFrame("StatusBar", nil, PlayerFrameManaBar) + self.tick = tick + tick:SetAllPoints(PlayerFrameManaBar) + tick:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") + tick:SetStatusBarColor(1, 1, 1, 0) + tick:SetMinMaxValues(0, 1) + tick:Hide() + tick.spark = tick:CreateTexture(nil, "OVERLAY") + tick.spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark") + tick.spark:SetBlendMode("ADD") + tick.spark:SetPoint("CENTER", tick:GetStatusBarTexture(), "RIGHT", 0, 0) + tick.spark:Hide() + local native = API.durationtimers and type(tick.SetTimerDuration) == "function" + if native then tick.duration = _G.C_DurationUtil.CreateDuration() end + local function CancelWake() + if tick.wake then tick.wake:Cancel(); tick.wake = nil end + end + local function SizeSpark() + local height = PlayerFrameManaBar:GetHeight() + tick.spark:SetHeight(height + 10) + tick.spark:SetWidth(height + 4) + end + -- Observe the regen phase separately from the five-second-rule display. + -- Mid-period gains (potions/procs) do not move an established clock. + local function LockTick(now) + if tick.phase then + local error = math.mod(now - tick.phase, 2) + if error > 1 then error = error - 2 end + if math.abs(error) <= .25 then + if error > .08 then tick.phase = tick.phase + error - .08 + elseif error < -.08 then tick.phase = tick.phase + error + .08 end + tick.rejected = nil + return end - elseif UnitPowerType("player") == 3 then - this.mode = "ENERGY" - this:Show() - else - this:Hide() - end - - if event == "PLAYER_ENTERING_WORLD" then - this.lastMana = UnitMana("player") - end - - if (this.mode == "ENERGY") or ((event == "UNIT_MANA" or event == "UNIT_ENERGY") and arg1 == "player") then - this.currentMana = UnitMana("player") - local diff = 0 - if this.lastMana then - diff = this.currentMana - this.lastMana + if not tick.rejected or math.abs(now - tick.rejected - 2) > .25 then + tick.rejected = now + return end - - if this.mode == "MANA" and diff < 0 then - this.target = 5 - elseif this.mode == "MANA" and diff > 0 then - if this.max ~= 5 and diff > (this.badtick and this.badtick*1.2 or 5) then - this.target = 2 - else - this.badtick = diff + end + tick.phase, tick.rejected = now, nil + end + local Sweep + local function Wrap() tick.wake = nil; Sweep() end + function Sweep() + CancelWake() + if not tick:IsShown() then return end + local now = GetTime() + local start, duration + if tick.mode == 0 and tick.ruleEnd and tick.ruleEnd > now then + -- Retain the five-second spark appearance for mana-costing casts. + start, duration = tick.ruleEnd - 5, 5 + elseif tick.phase then + start = tick.phase + math.floor((now - tick.phase) / 2) * 2 + duration = 2 + end + if not start then tick.start = nil; tick.spark:Hide(); return end + tick.start, tick.period = start, duration + tick.spark:Show() + if native then + tick.duration:SetTimeFromStart(start, duration) + tick:SetTimerDuration(tick.duration, _G.Enum.StatusBarInterpolation.Immediate, + _G.Enum.StatusBarTimerDirection.ElapsedTime) + tick.wake = _G.C_Timer.NewTimer(math.max(.001, start + duration - now), Wrap) + end + end + local function Visible() + if tick.mode == 3 or (tick.mode == 0 and + (UnitMana("player") < UnitManaMax("player") or UnitAffectingCombat("player"))) then + tick:Show() + Sweep() + else tick:Hide() end + end + tick:SetScript("OnHide", CancelWake) + if not native then + tick:SetScript("OnUpdate", function() + if not tick.start then return end + local now = GetTime() + if now >= tick.start + tick.period then Sweep() end + if tick.start and tick.spark:IsShown() then + tick:SetValue(math.max(0, math.min(1, (now - tick.start) / tick.period))) + end + end) + end + ShaguTweaks.HookScript(PlayerFrameManaBar, "OnSizeChanged", SizeSpark) + ShaguTweaks.HookScript(tick, "OnShow", Sweep) + local function ArmRule(spellID) + if not spellID then return end + if API.GetSpellInfo then + local name, rank, icon, cost, funnel, powerType = API.GetSpellInfo(spellID) + if name then + if powerType == 0 and cost and cost > 0 then + tick.ruleSpell, tick.ruleEnd = spellID, GetTime() + 5 end - elseif this.mode == "ENERGY" and diff >= 0 then - this.target = 2 + return end - this.lastMana = this.currentMana end + local spells = _G.C_Spell + if not spells or type(spells.GetSpellPowerCost) ~= "function" then return end + local costs = spells.GetSpellPowerCost(spellID) + local cost = costs and costs[1] + if cost and cost.type == 0 and cost.cost and cost.cost > 0 then + tick.ruleSpell, tick.ruleEnd = spellID, GetTime() + 5 + end + end + tick:SetScript("OnEvent", function() + local world = event == "PLAYER_ENTERING_WORLD" + local display = event == "UNIT_DISPLAYPOWER" + -- ClassicAPI world-entry payloads are booleans, not unit tokens. + if not world and string.find(event, "^UNIT_") and arg1 ~= "player" then return end + local mode = UnitPowerType("player") + if world or display or mode ~= tick.mode then + tick.mode, tick.lastPower = mode, UnitMana("player") + tick.phase, tick.rejected, tick.start = nil, nil, nil + tick.spark:Hide() + end + if event == "UNIT_SPELLCAST_SUCCEEDED" then ArmRule(arg3) + elseif event == "UNIT_SPELLCAST_CHANNEL_STOP" then + if tick.ruleSpell == arg3 then tick.ruleEnd = GetTime() + 5 end + elseif (event == "UNIT_MANA" and mode == 0) or (event == "UNIT_ENERGY" and mode == 3) then + local power = UnitMana("player") + if tick.lastPower and power > tick.lastPower then LockTick(GetTime()) end + tick.lastPower = power + end + Visible() end) - - local pheight, pwidth = PlayerFrameManaBar:GetHeight(), PlayerFrameManaBar:GetWidth() - energytick:SetScript("OnUpdate", function() - if this.target then - this.max = this.target - this.current = 0 - this.target = nil - end - - if not this.max then return end - - -- OnUpdate already provides the exact elapsed frame time. Accumulating arg1 - -- keeps the spark just as smooth without querying GetTime() every frame. - this.current = (this.current or 0) + arg1 - - if this.current > this.max then - this.max, this.current = 2, 0 - end - - local pos = (pwidth ~= "-1" and pwidth or width) * (this.current / this.max) - if not pheight then return end - this.spark:SetPoint("LEFT", pos-((pheight+5)/2), 0) - end) - - energytick.spark = energytick:CreateTexture(nil, 'OVERLAY') - energytick.spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark") - energytick.spark:SetHeight(pheight + 10) - energytick.spark:SetWidth(pheight + 4) - energytick.spark:SetBlendMode('ADD') + tick:RegisterEvent("PLAYER_ENTERING_WORLD") + tick:RegisterEvent("PLAYER_REGEN_DISABLED") + tick:RegisterEvent("PLAYER_REGEN_ENABLED") + local events = { "UNIT_DISPLAYPOWER", "UNIT_MANA", "UNIT_ENERGY", + "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_STOP" } + for _, name in ipairs(events) do API.RegisterUnitEvent(tick, name, "player") end + SizeSpark() end diff --git a/mods/unitframes-healing-prediction.lua b/mods/unitframes-healing-prediction.lua new file mode 100644 index 0000000..275a48a --- /dev/null +++ b/mods/unitframes-healing-prediction.lua @@ -0,0 +1,72 @@ +local T = ShaguTweaks.T +local P = ShaguTweaks.libpredict +local module = ShaguTweaks:register({ + title = T["Healing Predictions"], + description = T["Shows estimated direct heals and upcoming HoT ticks on player and target health bars. Reuses compatible Puppeteer prediction data when available; otherwise uses ClassicAPI events, observed heal amounts and vanilla HealComm. Unknown amounts are not shown."], + expansions = { ["vanilla"] = true }, category = T["Unit Frames"], enabled = nil, +}) +local states, events, enabled +local function Refresh(state) + local bar, unit, fill = state.bar, state.unit, state.fill + if not enabled or not bar:IsVisible() or not UnitExists(unit) or UnitIsDeadOrGhost(unit) + or not UnitCanAssist("player", unit) then fill:Hide(); return end + local current, maximum = UnitHealth(unit), UnitHealthMax(unit) + -- A percentage-only target cannot be combined with an absolute heal amount. + if not current or not maximum or maximum <= 0 or current >= maximum + or (unit == "target" and maximum == 100 and not UnitIsUnit(unit, "player")) then fill:Hide(); return end + local amount = math.min(P:UnitGetIncomingHeals(unit), maximum-current) + local width, height = bar:GetWidth(), bar:GetHeight() + if amount <= 0 or width <= 0 or height <= 0 then fill:Hide(); return end + local texture = bar:GetStatusBarTexture() + fill:SetTexture(texture and texture:GetTexture() or "Interface\\TargetingFrame\\UI-StatusBar") + fill:SetVertexColor(0, .85, .4, .6) + fill:ClearAllPoints() + fill:SetPoint("TOPLEFT", bar, "TOPLEFT", width * current/maximum, 0) + fill:SetWidth(width * amount/maximum) + fill:SetHeight(height) + fill:SetTexCoord(current/maximum, (current+amount)/maximum, 0, 1) + fill:Show() +end +local function RefreshAll() + for _, state in ipairs(states) do Refresh(state) end +end +module.enable = function() + if enabled then return end + enabled = true + if not states then + states = {} + for _, definition in ipairs({{"player",PlayerFrameHealthBar},{"target",TargetFrameHealthBar}}) do + local bar = definition[2] + if bar then + local state = { unit = definition[1], bar = bar, fill = bar:CreateTexture(nil,"ARTWORK") } + table.insert(states,state) + state.fill:Hide() + local function Changed() Refresh(state) end + ShaguTweaks.hooksecurefunc(bar,"SetValue",Changed) + ShaguTweaks.hooksecurefunc(bar,"SetMinMaxValues",Changed) + ShaguTweaks.hooksecurefunc(bar,"SetStatusBarTexture",Changed) + ShaguTweaks.HookScript(bar,"OnShow",Changed) + ShaguTweaks.HookScript(bar,"OnSizeChanged",Changed) + ShaguTweaks.HookScript(bar,"OnHide",function() state.fill:Hide() end) + end + end + P.changed = function(name) + for _, state in ipairs(states) do if UnitName(state.unit)==name then Refresh(state) end end + end + events = CreateFrame("Frame") + events:SetScript("OnEvent",RefreshAll) + end + P:Enable() + ShaguTweaks.API.RegisterUnitEvent(events,"UNIT_HEALTH","player","target") + ShaguTweaks.API.RegisterUnitEvent(events,"UNIT_MAXHEALTH","player","target") + events:RegisterEvent("PLAYER_TARGET_CHANGED") + events:RegisterEvent("PLAYER_ENTERING_WORLD") + RefreshAll() +end +module.disable = function() + if not enabled then return end + enabled = false + events:UnregisterAllEvents() + P:Disable() + RefreshAll() +end diff --git a/mods/unitframes-standalone-player.lua b/mods/unitframes-standalone-player.lua index 5231d1a..e4ad8e7 100644 --- a/mods/unitframes-standalone-player.lua +++ b/mods/unitframes-standalone-player.lua @@ -10,9 +10,7 @@ local module = ShaguTweaks:register({ enabled = nil, }) -local function Unlocked() - return API.IsShiftKeyDown() and API.IsControlKeyDown() and API.IsAltKeyDown() -end +local Unlocked = ShaguTweaks.API.AreMoveModifiersDown local function Position(holder, point) holder:ClearAllPoints() @@ -349,23 +347,5 @@ module.enable = function(self) -- Wait until all modules and their one-shot OnUpdates have initialized. -- This also captures the player's restored layout-cache position. - local ready = CreateFrame("Frame") - ready:RegisterEvent("PLAYER_ENTERING_WORLD") - ready:SetScript("OnEvent", function() - this:UnregisterAllEvents() - this:SetScript("OnUpdate", function() - this:SetScript("OnUpdate", nil) - this:Hide() - if API.timer then - API.Defer(Setup) - else - -- A second frame lets the other modules' one-shot updates finish. - this:SetScript("OnUpdate", function() - this:SetScript("OnUpdate", nil) - Setup() - end) - this:Show() - end - end) - end) + API.AfterWorldReady(Setup, true) end diff --git a/tests/README.md b/tests/README.md index afa7a61..9ad48d1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,17 @@ -# UI hotfix regression checks +# Regression checks -Run from the addon root with a Lua 5.0 interpreter: +Run every suite from the addon root (Linux/WSL with Lua 5.0): + +```sh +sh tests/run_all.sh ../tools/lua5.0/lua5.0 +``` + +The interpreter defaults to `lua5.0` if omitted. The runner discovers all +`tests/test_*.lua` files, reports every failure, prints a final count, and exits +unsuccessfully if any suite fails. It is developer tooling, is not loaded by the +addon, and has no in-game overhead. The current suite contains 25 test files. + +Individual checks can also be run from the addon root: ``` lua5.0 tests/test_ui_rarity.lua . @@ -18,6 +29,16 @@ lua5.0 tests/test_loss_of_control_style.lua . lua5.0 tests/test_health_text_alignment.lua . lua5.0 tests/test_classicapi_runtime.lua . lua5.0 tests/test_actionbar_grid.lua . +lua5.0 tests/test_range_events.lua . +lua5.0 tests/test_healing_prediction.lua . +lua5.0 tests/test_healing_shared.lua . +lua5.0 tests/test_cast_tracking.lua . +lua5.0 tests/test_runtime_helpers.lua . +lua5.0 tests/test_health_event_routing.lua . +lua5.0 tests/test_swing_native.lua . +lua5.0 tests/test_energy_tick.lua . +lua5.0 tests/test_macro_native.lua . +lua5.0 tests/test_equip_compare_native.lua . ``` These checks execute private helpers extracted from the production source with @@ -46,3 +67,41 @@ palette, font/text updates and switching between NPC and player targets. Quest/g execution, paging, stances, pet transitions and title-strip rendering need game QA. Cast-bar checks cover initialization despite an old disabled flag, checkbox removal, saved position protection and reset. + +Cast-tracking checks cover authoritative native queries without a duplicate +mirror, demand-driven SuperWoW registration, live channel identity, delayed +failures, expiry/cache limits and Hunter custom timing/hooks. See +[the cast tracking review](../Docs/CAST_TRACKING_REVIEW.md) for ownership and game checks. + +Runtime-helper checks load the production API layer and exercise all modifier +combinations, native left/right state precedence, independent deferred startup +requests, preserved fallback delays and removal of one-shot scripts. Action-bar +and standalone fixtures also use the real shared helpers. In game, verify that +Ctrl+Shift+Alt previews, drag/reset and restored positions still work after login +and reload, including the cast bar, timers and unit frames. + +Health routing checks separate health/power events for all three units and retain +identity/startup refreshes and alternate-power updates. Swing checks cover native +capability/method guards, fill/text ownership without a Lua driver, timer reset +cancellation, completion/grace, range and Hunter colors, Maul queue cancellation, +preview suspension/restoration, death and the older-API fallback. Native DLL +animation, actual action-state event delivery and weapon timing still require +game testing. See [the measurement plan](../Docs/PERFORMANCE_TEST_382.md). + +Energy/mana checks cover world-entry booleans, form samples, casts versus drains, +channel-stop ownership, proc rejection, native/fallback sweeps, size changes and +idle cancellation. Macro checks cover native ownership/chosen icons, custom +hints and conditions, item-cache retry, release on edit/logout, conditional-only +polling, foreign parser/alias ownership and compatibility paths. Comparison checks +cover full item links, native offsets, Shift, batching and hide/clear ownership, +including item-link tooltips and the old-method fallback. + +Comparison regression also simulates native callbacks during construction and +50 consecutive source clear/set refreshes plus 50 source hide/re-show cycles, +including native side effects hiding ShoppingTooltip1/2 and clearing their +owners. Hidden comparisons must rebind to the hovered item button and refill +their native contents synchronously; merely calling Show is insufficient. +An independently hidden comparison recovers without a new source event. The +unchanged visible case still avoids native rebuilds. Changed links/equipment +rebuild; a non-item or persistently hidden source removes comparisons after the +deferred lifecycle check. diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100644 index 0000000..518c078 --- /dev/null +++ b/tests/run_all.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Run from the addon root: sh tests/run_all.sh [lua5.0-binary] +LUA="${1:-lua5.0}" +if [ ! -f ShaguTweaks.toc ] || [ ! -f tests/test_classicapi_runtime.lua ]; then + printf '%s\n' 'Run this script from the ShaguTweaks addon root.' >&2 + exit 2 +fi +if ! command -v "$LUA" >/dev/null 2>&1; then + printf 'Lua interpreter not found: %s\n' "$LUA" >&2 + exit 127 +fi +total=0 +failed=0 +for test in tests/test_*.lua; do + [ -f "$test" ] || continue + total=$((total + 1)) + printf '\n== %s\n' "$test" + if ! "$LUA" "$test" .; then + failed=$((failed + 1)) + printf 'FAILED: %s\n' "$test" >&2 + fi +done +printf '\nSuites: %s; passed: %s; failed: %s\n' "$total" "$((total - failed))" "$failed" +[ "$total" -gt 0 ] && [ "$failed" -eq 0 ] diff --git a/tests/test_actionbar_grid.lua b/tests/test_actionbar_grid.lua index 8a3a1c3..5dd1d7c 100644 --- a/tests/test_actionbar_grid.lua +++ b/tests/test_actionbar_grid.lua @@ -1,16 +1,44 @@ local root=(arg[1] or '.')..'/' local update,events -ShaguTweaks={GetGlobalEnv=function() return getfenv(0) end,API={IsSupportedRuntime=function() return true end},hooksecurefunc=function(_,fn) update=fn end} +ShaguTweaks={GetGlobalEnv=function() return getfenv(0) end,API={IsSupportedRuntime=function() return true end}} +function ShaguTweaks.hooksecurefunc(object,key,fn) + if type(object)=='string' then update=key;return end + local original=object[key] + object[key]=function(self,...) original(self,unpack(arg));fn(self,unpack(arg)) end +end +function ShaguTweaks.HookScript(frame,name,fn) frame.scripts[name]=fn end +local function button(action) + return {action=action,showgrid=0,shown=false,Show=function(self) self.shown=true end,Hide=function(self) self.shown=false end} +end +BonusActionBarFrame={visible=false,scripts={},IsVisible=function(self) return self.visible end} +ActionButton1=button(true);ActionButton2=button(true);ActionButton3=button(false) +BonusActionButton1=button(false) +ActionButton_Update=function() if this.action or this.showgrid>0 then this:Show() else this:Hide() end end CreateFrame=function() events={RegisterEvent=function() end,SetScript=function(self,_,fn) self.fire=fn end};return events end -ActionButton_ShowGrid=function(b) b.showgrid=b.showgrid+1;b.shown=true end -ActionButton_HideGrid=function(b) b.showgrid=b.showgrid-1;if b.showgrid==0 and not b.action then b.shown=false end end -ActionButton1={showgrid=0,shown=false};ActionButton2={showgrid=0,shown=true,action=true} -BonusActionButton1={showgrid=0,shown=false} +ActionButton_ShowGrid=function(b) b.showgrid=b.showgrid+1;b:Show() end +ActionButton_HideGrid=function(b) b.showgrid=b.showgrid-1;if b.showgrid==0 and not b.action then b:Hide() end end +PickupAction=function() error('must not change action contents') end +PlaceAction=PickupAction dofile(root..'mods/actionbar-grid.lua') -ALWAYS_SHOW_MULTIBARS="1";events.fire();update();assert(ActionButton1.showgrid==1 and ActionButton1.shown and BonusActionButton1.shown) -ActionButton_ShowGrid(ActionButton1) -- dragging adds its own grid reference -ALWAYS_SHOW_MULTIBARS=0;update();assert(ActionButton1.showgrid==1 and ActionButton1.shown) -ActionButton_HideGrid(ActionButton1);assert(not ActionButton1.shown and ActionButton2.shown and not BonusActionButton1.shown) -update();assert(ActionButton1.showgrid==0) -ALWAYS_SHOW_MULTIBARS=1;update();assert(ActionButton1.shown and ActionButton1.showgrid==1) -print('Main/bonus action grids: native preference, idempotence, toggle off/on, drag references and filled buttons passed') +ALWAYS_SHOW_MULTIBARS='1';events.fire();update() +assert(ActionButton1.showgrid==1 and ActionButton1.shown and ActionButton3.shown and BonusActionButton1.shown) +BonusActionBarFrame.visible=true;this=BonusActionBarFrame;BonusActionBarFrame.scripts.OnShow() +assert(not ActionButton1.shown and not ActionButton2.shown and not ActionButton3.shown) +assert(this==BonusActionBarFrame and BonusActionButton1.shown) +-- Native refreshes and drag-grid events cannot re-expose caster icons below +-- a transparent, empty form slot. Show hooks leave counters untouched. +this=ActionButton1;ActionButton_Update();assert(not ActionButton1.shown) +ActionButton_ShowGrid(ActionButton1);assert(ActionButton1.showgrid==2 and not ActionButton1.shown) +ALWAYS_SHOW_MULTIBARS=0;update();assert(ActionButton1.showgrid==1 and not ActionButton1.shown) +ActionButton_HideGrid(ActionButton1);assert(ActionButton1.showgrid==0 and not ActionButton1.shown) +assert(not BonusActionButton1.shown) +BonusActionBarFrame.visible=false;this=BonusActionBarFrame;BonusActionBarFrame.scripts.OnHide() +assert(ActionButton1.shown and ActionButton2.shown and not ActionButton3.shown and this==BonusActionBarFrame) +ALWAYS_SHOW_MULTIBARS=1;update();assert(ActionButton3.shown) +for i=1,3 do + BonusActionBarFrame.visible=true;BonusActionBarFrame.scripts.OnShow();assert(not ActionButton1.shown) + BonusActionBarFrame.visible=false;BonusActionBarFrame.scripts.OnHide();assert(ActionButton1.shown) +end +update();assert(ActionButton1.showgrid==1) +assert(ActionButton1.action and not BonusActionButton1.action) +print('Action grids: native setting/drag counters, form-bar coverage, native Show suppression, caster restoration, empty slots and untouched action contents passed') diff --git a/tests/test_actionbar_movers.lua b/tests/test_actionbar_movers.lua index 2c038ed..cf69766 100644 --- a/tests/test_actionbar_movers.lua +++ b/tests/test_actionbar_movers.lua @@ -63,6 +63,8 @@ local function Check(reduced,actionOption,nativeEvents,timer) local keys=false ShaguTweaks={mods=modules,T=setmetatable({},{__index=function(t,k) return k end}),API={timer=timer,modifierstate=nativeEvents}} function ShaguTweaks.GetGlobalEnv() return _G end + dofile(root..'api.lua') + ShaguTweaks.API.timer=timer;ShaguTweaks.API.modifierstate=nativeEvents function ShaguTweaks:register(m) modules[m.title]=m;return m end ShaguTweaks.API.IsShiftKeyDown=function() return keys end ShaguTweaks.API.IsControlKeyDown=ShaguTweaks.API.IsShiftKeyDown diff --git a/tests/test_cast_tracking.lua b/tests/test_cast_tracking.lua new file mode 100644 index 0000000..8b57024 --- /dev/null +++ b/tests/test_cast_tracking.lua @@ -0,0 +1,93 @@ +local root=(arg[1] or '.')..'/' +local frames,modules,hooks={}, {}, {} +local now,class,fullCast,fullChannel,liveChannel=10,'WARLOCK',false,false,false +GetTime=function() return now end +UnitName=function(unit) return unit=='player' and 'Me' or 'Same NPC Name' end +UnitClass=function() return class,class end +UnitIsUnit=function(a,b) return a==b end +strlower=string.lower;strfind=string.find +local function frame() + local f={events={},scripts={}} + function f:RegisterEvent(name) self.events[name]=true end + function f:SetScript(k,v) self.scripts[k]=v end + table.insert(frames,f);return f +end +CreateFrame=frame +ShaguTweaks={API={casts=true},L={customcast={AIMEDSHOT='Aimed Shot',MULTISHOT='Multi-Shot'}},mods=modules, + T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return getfenv(0) end,GetExpansion=function() return 'vanilla' end} +function ShaguTweaks:register(m) modules[m.title]=m;return m end +ShaguTweaks.hooksecurefunc=function(name,fn) hooks[name]=fn end +ShaguTweaks.libspell={GetSpellInfo=function() return 'Aimed Shot','Rank 1' end} +ShaguTweaks.libtipscan={GetScanner=function() error('No tooltip scanning during native cast tracking') end} +local API=ShaguTweaks.API +API.GetCastInfo=function(unit) if unit=='target' and fullCast then return 'Fireball','','','icon',9000,15000 end end +API.GetChannelInfo=function(unit) + if unit=='target' and fullChannel then return 'Drain','','','icon',9000,15000,nil,false,202,true end + if unit=='target' and liveChannel then return nil,nil,nil,nil,nil,nil,nil,false,202,true end +end +API.UnitGUID=function(unit) return unit=='player' and 'PlayerGUID' or 'TargetGUID' end +API.UnitTokenFromGUID=function(guid) if guid=='TargetGUID' then return 'target' end end +UnitCastingInfo=function() error('Do not prefer another mod global over the normalized ClassicAPI wrapper') end +dofile(root..'libs/libcast.lua');local lib=ShaguTweaks.libcast +assert(not next(lib.events) and not next(lib.scripts) and not next(lib.db) and not next(hooks)) +fullCast=true;assert(ShaguTweaks.UnitCastingInfo('target')=='Fireball' and not next(lib.db)) +fullCast=false;lib.db['Same NPC Name']={cast='Wrong mob',start=10,casttime=5000} +assert(not ShaguTweaks.UnitCastingInfo('target'));lib.db['Same NPC Name']=nil +GetPlayerBuffID=function() end;CombatLogAdd=function() end +local metadataCalls=0 +SpellInfo=function(id) metadataCalls=metadataCalls+1;return 'Spell '..id,'rank','icon' end +UNKNOWN='Unknown';ShaguTweaks_config={['Enemy Castbars']=1};modules['Enemy Castbars']={enabled=true} +dofile(root..'mods/superwow.lua');local mod=modules['Super WoW Compatibility'] +mod:enable();local count=table.getn(frames);mod:enable();assert(table.getn(frames)==count) +local listener=mod.listener +local changes=0;lib.OnRemoteChanged=function() changes=changes+1 end +local function emit(guid,kind,id,duration) + arg1,arg3,arg4,arg5=guid,kind,id,duration;listener.scripts.OnEvent() +end +emit('PlayerGUID','START',101,3000);assert(metadataCalls==0 and not next(lib.db)) +fullCast=true;emit('TargetGUID','START',101,3000);assert(metadataCalls==0 and not next(lib.db)) +fullCast=false;fullChannel=true;emit('TargetGUID','CHANNEL',202,5000);assert(metadataCalls==0 and not next(lib.db)) +fullChannel=false;liveChannel=true;emit('TargetGUID','CHANNEL',202,5000) +assert(lib.db.TargetGUID.channel and changes==1) +local f=assert(io.open(root..'mods/target-castbar.lua'));local source=f:read('*a');f:close();assert(loadstring(source)) +local a=assert(string.find(source,'local function QuerySuperWoW',1,true));local b=assert(string.find(source,'local POSITION_KEY',a,true)) +local query=assert(loadstring('local API=ShaguTweaks.API;local SuperWoWCastingInfo=ShaguTweaks.UnitCastingInfo;local SuperWoWChannelInfo=ShaguTweaks.UnitChannelInfo;'..string.sub(source,a,b-1)..'return QueryCast'))() +local name,_,_,_,startTime,endTime,_,channel=query('target') +assert(name=='Spell 202' and startTime==10000 and endTime==15000 and channel) +liveChannel=false;assert(not query('target')) -- stale channel cannot resurrect without live native identity +liveChannel=true;lib.db.TargetGUID.spellID=999;assert(not query('target'));lib.db.TargetGUID.spellID=202 +emit('TargetGUID','FAIL',101,0);assert(lib.db.TargetGUID) +emit('TargetGUID','FAIL',202,0);assert(not lib.db.TargetGUID and changes==2) +emit('TargetGUID','CHANNEL',202,0);assert(not lib.db.TargetGUID) +emit('TargetGUID','CHANNEL',202,5000);now=16;assert(not query('target') and not lib.db.TargetGUID) +for i=1,160 do emit('Remote'..i,'CHANNEL',202,5000) end +local n=0;for key in pairs(lib.db) do n=n+1 end;assert(n==128) +assert(not listener.scripts.OnUpdate) +now=22;emit('Fresh','CHANNEL',202,1000);n=0;for key in pairs(lib.db) do n=n+1 end;assert(n==1) +ShaguTweaks_config['Enemy Castbars']=0;dofile(root..'mods/superwow.lua');count=table.getn(frames) +modules['Super WoW Compatibility']:enable();assert(table.getn(frames)==count) +-- Keep Hunter custom cast timing/stop behavior isolated from other classes. +class='HUNTER';now=30;UnitBuff=function() return nil end;GetNetStats=function() return 0,0,0 end +dofile(root..'libs/libcast.lua');lib=ShaguTweaks.libcast +assert(lib.events.SPELLCAST_STOP and not lib.events.UNIT_SPELLCAST_START) +lib.customcast['aimed shot'](true) +assert(ShaguTweaks.UnitCastingInfo('player')=='Aimed Shot') +lib.scripts.OnEvent();assert(not ShaguTweaks.UnitCastingInfo('player')) +lib.customcast['multi-shot'](true);assert(ShaguTweaks.UnitCastingInfo('player')=='Multi-Shot') +now=31;assert(not ShaguTweaks.UnitCastingInfo('player')) +GetSpellName=function(id) if id==1 then return 'Aimed Shot','Rank 1' end return 'Other Spell','Rank 1' end +GetSpellCooldown=function() return 1 end +IsCurrentAction=function() return true end +hooks.CastSpell(2,'spell');assert(not ShaguTweaks.UnitCastingInfo('player')) +hooks.CastSpell(1,'spell');assert(ShaguTweaks.UnitCastingInfo('player')=='Aimed Shot') +lib.scripts.OnEvent() +hooks.CastSpellByName('Other Spell');assert(not ShaguTweaks.UnitCastingInfo('player')) +hooks.CastSpellByName('Aimed Shot');assert(ShaguTweaks.UnitCastingInfo('player')=='Aimed Shot') +lib.scripts.OnEvent() +API.actioninfo=true;API.GetActionInfo=function() return 'spell',19434 end +API.GetSpellInfo=function() return 'Aimed Shot','Rank 1' end +hooks.UseAction(1);assert(ShaguTweaks.UnitCastingInfo('player')=='Aimed Shot') +lib.scripts.OnEvent() +API.GetActionInfo=function() return 'macro',1 end +hooks.UseAction(1);assert(not ShaguTweaks.UnitCastingInfo('player')) +print('Cast tracking: no native mirror/chat parser, authoritative units, demand/duplicate guards, live channel identity, fail/expiry/cap cleanup and Hunter custom timing passed') diff --git a/tests/test_castbar_mover.lua b/tests/test_castbar_mover.lua index a653bdc..102d0ea 100644 --- a/tests/test_castbar_mover.lua +++ b/tests/test_castbar_mover.lua @@ -11,6 +11,7 @@ function methods:ClearAllPoints() self.point=nil end UIParent=object();CastingBarFrame=object() CreateFrame=function(_,name) local f=object();if name then _G[name]=f end;return f end ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return _G end} +dofile(root..'api.lua') function ShaguTweaks:register() error('Cast-bar checkbox must not be registered') end ShaguTweaks_config={['Movable Cast Bar']=0,CastBarPosition={123,234}} local f=assert(io.open(root..'main.lua'));local source=f:read('*a');f:close();assert(loadstring(source)) diff --git a/tests/test_energy_tick.lua b/tests/test_energy_tick.lua new file mode 100644 index 0000000..575c049 --- /dev/null +++ b/tests/test_energy_tick.lua @@ -0,0 +1,80 @@ +local root=(arg[1] or '.')..'/' +local function Check(native) + local now,power,mode,combat=0,80,0,false + local timers,modules={},{} + local methods={} + local function noop() end + local function frame(kind) + return setmetatable({shown=true,scripts={},events={},height=12}, {__index=function(t,k) + return methods[k] or (string.find(k,'^[A-Z]') and noop or nil) + end}) + end + function methods:SetScript(k,v) self.scripts[k]=v end + function methods:GetScript(k) return self.scripts[k] end + function methods:RegisterEvent(k) self.events[k]=true end + function methods:GetHeight() return self.height end + function methods:SetHeight(v) self.height=v end + function methods:SetWidth(v) self.width=v end + function methods:IsShown() return self.shown end + function methods:Show() local changed=not self.shown;self.shown=true;if changed and self.scripts.OnShow then self.scripts.OnShow() end end + function methods:Hide() local changed=self.shown;self.shown=false;if changed and self.scripts.OnHide then self.scripts.OnHide() end end + function methods:CreateTexture() return frame('Texture') end + function methods:GetStatusBarTexture() self.fill=self.fill or frame('Texture');return self.fill end + function methods:SetTimerDuration(duration) self.nativeDuration=duration end + function methods:SetValue(v) self.value=v end + CreateFrame=frame;PlayerFrameManaBar=frame('StatusBar') + UnitPowerType=function() return mode end + UnitMana=function() return power end + UnitManaMax=function() return 100 end + UnitAffectingCombat=function() return combat end + GetTime=function() return now end + C_DurationUtil={CreateDuration=function() return {SetTimeFromStart=function(self,start,duration) self.start,self.duration=start,duration end} end} + C_Timer={NewTimer=function(delay,callback) + local t={at=now+delay,callback=callback};function t:Cancel() self.cancelled=true end + table.insert(timers,t);return t + end} + Enum={StatusBarInterpolation={Immediate=0},StatusBarTimerDirection={ElapsedTime=0}} + C_Spell={GetSpellPowerCost=function(id) return {{type=0,cost=id==1 and 10 or 0}} end} + ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return getfenv(0) end, + API={durationtimers=native,RegisterUnitEvent=function(f,name,unit) assert(unit=='player');f:RegisterEvent(name) end}} + if native then + ShaguTweaks.API.GetSpellInfo=function(id) return 'Spell','Rank 1','icon',id==1 and 10 or 0,false,0 end + C_Spell.GetSpellPowerCost=function() error('Use positional spell metadata without allocating a cost table') end + end + function ShaguTweaks:register(m) modules[m.title]=m;return m end + function ShaguTweaks.HookScript(f,k,callback) + local old=f:GetScript(k);f:SetScript(k,function() if old then old() end;callback() end) + end + dofile(root..'mods/unitframes-energy-tick.lua') + local module=modules['Unit Frame Energy & Mana Tick'];module:enable();local tick=module.tick + module:enable();assert(module.tick==tick) + local function emit(name,unit,id) event,arg1,arg3=name,unit,id;tick.scripts.OnEvent() end + emit('PLAYER_ENTERING_WORLD',true);assert(tick.mode==0 and tick.lastPower==80) + power=70;emit('UNIT_MANA','target');assert(tick.lastPower==80) + emit('UNIT_MANA','player');assert(not tick.ruleEnd) -- drain is not an FSR cast + now=1;power=80;emit('UNIT_MANA','player');assert(tick.phase==1 and tick.period==2) + now=1.8;power=85;emit('UNIT_MANA','player');assert(tick.phase==1) -- mid-period proc + emit('UNIT_SPELLCAST_SUCCEEDED','player',2);assert(not tick.ruleEnd) -- free cast + emit('UNIT_SPELLCAST_SUCCEEDED','player',1);assert(tick.ruleEnd==6.8 and tick.period==5) + now=2;mode=3;power=40;emit('UNIT_DISPLAYPOWER','player') + assert(tick.lastPower==40 and not tick.phase and not tick.spark:IsShown()) + now=3;power=60;emit('UNIT_ENERGY','player');assert(tick.phase==3 and tick.period==2) + mode=0;power=75;emit('UNIT_DISPLAYPOWER','player');assert(tick.lastPower==75 and not tick.phase) + now=4;local finish=tick.ruleEnd;emit('UNIT_SPELLCAST_CHANNEL_STOP','player',999);assert(tick.ruleEnd==finish) + emit('UNIT_SPELLCAST_CHANNEL_STOP','player',1);assert(tick.ruleEnd==9) + PlayerFrameManaBar.height=30;PlayerFrameManaBar.scripts.OnSizeChanged();assert(tick.spark.height==40 and tick.spark.width==34) + local old=tick.wake + power=100;combat=false;emit('UNIT_MANA','player');assert(not tick:IsShown()) + if old then assert(old.cancelled) end + if native then assert(not tick.scripts.OnUpdate and tick.nativeDuration) + else assert(tick.scripts.OnUpdate) end + mode=3;power=50;emit('UNIT_DISPLAYPOWER','player') + now=10;power=70;emit('UNIT_ENERGY','player') + now=10.5 + if native then assert(tick.nativeDuration.start==10 and tick.nativeDuration.duration==2) + else tick.scripts.OnUpdate();assert(tick.value==.25) end + -- A late wake catches up to the observed clock, not to now (no phase drift). + if native then now=14.2;tick.wake.callback();assert(tick.start==14) end +end +Check(true);Check(false) +print('Energy/mana tick: login booleans, unit filtering, form samples, cast-only FSR/channel ownership, proc phase rejection, native/fallback sweep, sizing and idle cancellation passed') diff --git a/tests/test_equip_compare_native.lua b/tests/test_equip_compare_native.lua new file mode 100644 index 0000000..242e743 --- /dev/null +++ b/tests/test_equip_compare_native.lua @@ -0,0 +1,107 @@ +local root=(arg[1] or '.')..'/' +local frames,modules,pending={},{},{} +local shift=true +local methods={} +local function noop() end +local function frame(name) + local f=setmetatable({name=name,scripts={},events={},shown=false}, {__index=function(t,k) + return methods[k] or (string.find(k,'^[A-Z]') and noop or nil) + end}) + table.insert(frames,f);return f +end +function methods:SetOwner(owner) self.owner=owner;self.nativeContent=nil end +function methods:GetOwner() return self.owner end +function methods:ClearAllPoints() self.point=nil end +function methods:SetPoint(...) self.point=arg end +function methods:GetPoint() if self.point then return unpack(self.point) end return 'TOPLEFT',UIParent end +function methods:GetItem() if self.link then return 'Item',self.link end end +function methods:HookScript(k,v) self.scripts[k]=v end +function methods:RegisterEvent(k) self.events[k]=true end +function methods:SetScript(k,v) self.scripts[k]=v end +function methods:IsShown() return self.shown end +function methods:Show() + local changed=not self.shown;self.shown=true + if changed and self.scripts.OnShow then self.scripts.OnShow() end +end +function methods:Hide() + local changed=self.shown + self.hides=(self.hides or 0)+1;self.shown=false;self.owner=nil + -- Stock source-tooltip hide scripts also hide the shared shopping tooltips. + if self==GameTooltip or self==ItemRefTooltip then ShoppingTooltip1:Hide();ShoppingTooltip2:Hide() end + if changed and self.scripts.OnHide then self.scripts.OnHide() end +end +local compared,builds={},0 +function methods:SetHyperlinkCompareItem(link,index,button,source) + builds=builds+1 + assert(source.link==link and button==true) + compared[index]=link + self.nativeContent=link + -- Simulate native/third-party callbacks during comparison construction. + source.scripts.OnTooltipCleared();source.scripts.OnTooltipSetItem() + return index==1 or source.two +end +UIParent=frame('UIParent');UIParent.GetEffectiveScale=function() return 1 end +local hover=frame('BagshuiItemButton') +GameTooltip=frame('GameTooltip');ItemRefTooltip=frame('ItemRefTooltip') +ShoppingTooltip1=frame('ShoppingTooltip1');ShoppingTooltip2=frame('ShoppingTooltip2') +CreateFrame=function() return frame() end +GetCursorPosition=function() return 100 end;GetScreenWidth=function() return 1000 end +ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return getfenv(0) end, + API={tooltipsetitem=true,modifierstate=true,timer=true,IsShiftKeyDown=function() return shift end, + Defer=function(callback) table.insert(pending,callback) end},HookAddonOrVariable=function(_,callback) callback() end} +function ShaguTweaks:register(m) modules[m.title]=m;return m end +dofile(root..'mods/equip-compare.lua');local module=modules['Equip Compare'];module:enable() +local function flush() + local queue=pending;pending={} + for _,callback in ipairs(queue) do callback() end + assert(table.getn(pending)==0,'Comparison construction scheduled a recursive refresh') +end +GameTooltip:SetOwner(hover);GameTooltip.link='item:123:enchant:randomsuffix';GameTooltip.two=true;GameTooltip:Show() +GameTooltip.scripts.OnTooltipSetItem();GameTooltip.scripts.OnTooltipSetItem();assert(table.getn(pending)==1) +flush();assert(compared[1]==GameTooltip.link and compared[2]==GameTooltip.link) +assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown()) +assert(ShoppingTooltip2.point[2]==ShoppingTooltip1) +assert(ShoppingTooltip1:GetOwner()==hover and ShoppingTooltip2:GetOwner()==hover) +local beforeBuild,beforeHide=builds,ShoppingTooltip1.hides +for i=1,50 do + GameTooltip.scripts.OnTooltipCleared();GameTooltip.scripts.OnTooltipSetItem() + assert(ShoppingTooltip1:IsShown()) + flush() +end +assert(builds==beforeBuild and ShoppingTooltip1.hides==beforeHide,'Unchanged tooltip blinked/rebuilt') +-- Bag slots can hide/re-show the source every refresh. The prior regression +-- tested Clear/Set only and therefore missed this separate lifecycle. +for i=1,50 do + GameTooltip:Hide() + assert(not ShoppingTooltip1:IsShown()) -- native hide side effect + assert(not ShoppingTooltip1:GetOwner()) -- native Hide clears ownership + GameTooltip:SetOwner(hover) + GameTooltip.scripts.OnTooltipCleared() + GameTooltip.scripts.OnTooltipSetItem() + GameTooltip:Show() + assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown(),'Cached comparison was not restored synchronously') + assert(ShoppingTooltip1:GetOwner()==hover and ShoppingTooltip1.nativeContent==GameTooltip.link) + flush() +end +assert(builds==beforeBuild+100,'Lost native ownership must refill each hidden comparison once') +-- Independently hidden comparisons also recover when the source emits no event. +beforeBuild=builds;ShoppingTooltip1:Hide();flush() +assert(ShoppingTooltip1:IsShown() and ShoppingTooltip1:GetOwner()==hover and builds==beforeBuild+1) +arg1='LSHIFT';shift=false;module.nativeModifier.scripts.OnEvent();assert(not ShoppingTooltip1:IsShown()) +shift=true;module.nativeModifier.scripts.OnEvent();assert(ShoppingTooltip1:IsShown()) +GameTooltip.two=false;event='UNIT_INVENTORY_CHANGED';arg1='player';module.nativeModifier.scripts.OnEvent();flush() +assert(ShoppingTooltip1:IsShown() and not ShoppingTooltip2:IsShown()) +beforeBuild=builds;GameTooltip.link='item:789:new-enchant';GameTooltip.scripts.OnTooltipSetItem();flush() +assert(builds==beforeBuild+2 and compared[1]==GameTooltip.link) +GameTooltip:Hide();flush();assert(not ShoppingTooltip1:IsShown()) +ItemRefTooltip.link='item:456';ItemRefTooltip:Show();ItemRefTooltip.scripts.OnTooltipSetItem();flush() +assert(ShoppingTooltip1.owner==UIParent and ShoppingTooltip1.point[2]==ItemRefTooltip) +GameTooltip.scripts.OnTooltipCleared();flush();assert(ShoppingTooltip1:IsShown()) -- another tooltip cannot clear the active owner's comparisons +ItemRefTooltip.link=nil;ItemRefTooltip.scripts.OnTooltipCleared();flush();assert(not ShoppingTooltip1:IsShown()) +local count=table.getn(frames);module:enable();assert(table.getn(frames)==count) +ItemRefTooltip.scripts.OnTooltipSetItem();ItemRefTooltip:Hide();flush();assert(not ShoppingTooltip1:IsShown()) +-- Missing newer comparison methods select the existing compatibility path. +ShoppingTooltip1.SetHyperlinkCompareItem=false;module.installed=nil;module.nativeModifier=nil +ShaguTweaks.API.iteminventorytype=true;ShaguTweaks.API.iteminventoryslotkey=true +module:enable();assert(module.installed and not module.nativeModifier) +print('Equip Compare: links/Shift/invalidation, unchanged refresh reuse, hidden-owner loss/refill, hovered-button ownership, independent comparison-hide recovery, reentrancy and fallback passed') diff --git a/tests/test_healing_prediction.lua b/tests/test_healing_prediction.lua new file mode 100644 index 0000000..9823e36 --- /dev/null +++ b/tests/test_healing_prediction.lua @@ -0,0 +1,129 @@ +local root=(arg[1] or '.')..'/' +local frames,modules={},{},{} +local methods={} +local function object() + return setmetatable({scripts={},events={},visible=true,width=100,height=12},{__index=function(t,k) return methods[k] or function() end end}) +end +function methods:SetScript(k,v) self.scripts[k]=v end +function methods:RegisterEvent(e) self.events[e]=true end +function methods:RegisterUnitEvent(e) self.events[e]=true end +function methods:UnregisterAllEvents() self.events={} end +function methods:CreateTexture() self.fill=object();return self.fill end +function methods:SetTexture(v) self.path=v end +function methods:GetTexture() return self.path end +function methods:GetStatusBarTexture() return {GetTexture=function() return 'HealthTexture' end} end +function methods:SetPoint(...) self.point=arg end +function methods:SetWidth(v) self.width=v end +function methods:SetHeight(v) self.height=v end +function methods:GetWidth() return self.width end +function methods:GetHeight() return self.height end +function methods:IsVisible() return self.visible end +function methods:Hide() self.visible=false end +function methods:Show() self.visible=true end +function methods:SetValue() end +function methods:SetMinMaxValues() end +function methods:SetStatusBarTexture() end +CreateFrame=function() local f=object();table.insert(frames,f);return f end +PlayerFrameHealthBar=object();TargetFrameHealthBar=object() +local now=10;GetTime=function() return now end +local gear='Original robe' +GetInventoryItemLink=function(_,slot) if slot==5 then return gear end end +local names={player='Me',target='Friend'} +local current,max,dead,friendly=500,1000,false,true +UnitExists=function(u) return names[u]~=nil end +UnitName=function(u) return names[u] end +UnitIsDeadOrGhost=function() return dead end +UnitCanAssist=function() return friendly end +UnitIsUnit=function(a,b) return names[a]==names[b] end +UnitHealth=function() return current end;UnitHealthMax=function() return max end +UnitGUID=function(unit) return unit=='player' and 'PlayerGUID' or unit=='target' and 'FriendGUID' or unit=='party1' and 'HealerGUID' end +GetNumRaidMembers=function() return 0 end;GetNumPartyMembers=function() return 0 end +SendAddonMessage=function() error('Solo player must not broadcast') end +C_EventUtils={IsEventValid=function() return true end} +ShaguTweaks={GetGlobalEnv=function() return getfenv(0) end,T=setmetatable({},{__index=function(t,k) return k end}),API={}} +function ShaguTweaks:register(m) modules[m.title]=m;return m end +ShaguTweaks.API.UnitCastingInfo=function() return 'Heal','Heal','icon',10000,13000 end +ShaguTweaks.API.RegisterUnitEvent=function(f,e) f:RegisterEvent(e) end +function ShaguTweaks.hooksecurefunc(o,k,fn) local original=o[k];o[k]=function(self,...) original(self,unpack(arg));fn() end end +function ShaguTweaks.HookScript(f,k,fn) f.scripts[k]=fn end +dofile(root..'libs/libpredict.lua');local P=ShaguTweaks.libpredict +dofile(root..'mods/unitframes-healing-prediction.lua');local mod=modules['Healing Predictions'] +assert(not mod.enabled and not P.enabled and table.getn(frames)==0) +mod:enable();local count=table.getn(frames) +local driver +for _,f in ipairs(frames) do if f.events.CHAT_MSG_ADDON then driver=f end end +P:ParseComm('Healer','Heal/Friend/200/3000/') +assert(P:UnitGetIncomingHeals('target')==200) +local fill=TargetFrameHealthBar.fill +assert(fill.visible and fill.width==20 and fill.point[4]==50) +P:ParseComm('Other','Heal/Friend/500/3000/');assert(fill.width==50) -- clamp overheal +P:ParseComm('Other','Healstop');assert(fill.width==20) +TargetFrameHealthBar.height=30;TargetFrameHealthBar.scripts.OnSizeChanged();assert(fill.height==30) +current=900;TargetFrameHealthBar:SetValue(900);assert(fill.width==10 and fill.point[4]==90) +current=1000;TargetFrameHealthBar:SetValue(1000);assert(not fill.visible) +current=500;max=100;TargetFrameHealthBar:SetValue(50);assert(not fill.visible) +max=1000;dead=true;TargetFrameHealthBar:SetValue(500);assert(not fill.visible) +dead=false;friendly=false;TargetFrameHealthBar:SetValue(500);assert(not fill.visible) +friendly=true;names.target='SomeoneElse';TargetFrameHealthBar:SetValue(500);assert(not fill.visible) +names.target='Friend';now=14;this=driver;arg1=.2;driver.scripts.OnUpdate();assert(not fill.visible and not driver.scripts.OnUpdate) +P:ParseComm('Healer','Heal/Friend/not-a-number/3000/');assert(not next(P.pending)) +-- Learn own direct heal from the native payload, then predict the next cast. +event='COMBAT_LOG_EVENT_UNFILTERED';arg1=nil;arg2='SPELL_HEAL';arg4='PlayerGUID';arg12=123;arg15=250;arg18=false;driver.scripts.OnEvent() +now=10;event='UNIT_SPELLCAST_SENT';arg1='player';arg2='Friend';arg3='cast-1';arg4=123;driver.scripts.OnEvent() +event='UNIT_SPELLCAST_START';arg2='cast-1';arg3=123;driver.scripts.OnEvent();assert(P:UnitGetIncomingHeals('target')==250) +event='UNIT_SPELLCAST_INTERRUPTED';arg2='old-cast';driver.scripts.OnEvent();assert(P:UnitGetIncomingHeals('target')==250) +arg2='cast-1';driver.scripts.OnEvent();assert(P:UnitGetIncomingHeals('target')==0 and not fill.visible) +assert(not driver.events.SPELLS_CHANGED) +event='UNIT_SPELLCAST_SENT';arg1='player';arg2='Friend';arg3='cast-2';arg4=123;driver.scripts.OnEvent() +event='UNIT_SPELLCAST_START';arg2='cast-2';arg3=123;driver.scripts.OnEvent() +assert(P:UnitGetIncomingHeals('target')==250) +event='SPELLS_CHANGED';driver.scripts.OnEvent() +event='UNIT_INVENTORY_CHANGED';arg1='player';driver.scripts.OnEvent() +assert(P:UnitGetIncomingHeals('target')==250 and fill.visible) -- form-related notifications retain estimates +gear='Different robe';driver.scripts.OnEvent() +assert(P:UnitGetIncomingHeals('target')==0 and not fill.visible) -- actual gear change invalidates +event='UNIT_SPELLCAST_SENT';arg2='Friend';arg3='cast-3';arg4=123;driver.scripts.OnEvent() +event='UNIT_SPELLCAST_START';arg2='cast-3';arg3=123;driver.scripts.OnEvent() +assert(P:UnitGetIncomingHeals('target')==0) -- must learn again with different gear +P:Heal('Healer','Me',100,3000);assert(PlayerFrameHealthBar.fill.visible) +mod:disable();assert(not P.enabled and not PlayerFrameHealthBar.fill.visible and not next(driver.events) and not driver.scripts.OnUpdate) +mod:enable();assert(table.getn(frames)==count and not next(P.pending)) +-- Standalone mode adds one upcoming tick for each live, identified HoT. +local auras={} +C_UnitAuras={UnitBuff=function(unit,index) + local a=unit=='target' and auras[index] + if a then return a.name,'icon',1,nil,a.duration,a.expires,a.source,nil,nil,a.spell end +end} +local function AuraChanged() event='UNIT_AURA';arg1='target';driver.scripts.OnEvent() end +local function Tick(source,spell,amount,critical) + event='COMBAT_LOG_EVENT_UNFILTERED';arg2='SPELL_PERIODIC_HEAL';arg4=source;arg8='FriendGUID';arg12=spell;arg15=amount;arg18=critical + driver.scripts.OnEvent() +end +now=10 +auras[1]={name='Rejuvenation',duration=12,expires=22,source='player',spell=774} +AuraChanged();assert(P:UnitGetIncomingHeals('target')==0) -- no guessed amount before observation +now=13;Tick('PlayerGUID',774,50,false);assert(P:UnitGetIncomingHeals('target')==50 and fill.width==5) +auras[2]={name='Regrowth',duration=21,expires=31,source='player',spell=8936} +Tick('PlayerGUID',8936,20,false);assert(P:UnitGetIncomingHeals('target')==70) +P:Heal('Me','Friend',200,3000);assert(P:UnitGetIncomingHeals('target')==270) +P:HealStop('Me');assert(P:UnitGetIncomingHeals('target')==70) -- stopping cast leaves both HoTs +auras[1]=auras[2];auras[2]=nil;AuraChanged();assert(P:UnitGetIncomingHeals('target')==20) +now=32;arg1=.2;driver.scripts.OnUpdate();assert(not fill.visible and not driver.scripts.OnUpdate) +now=40 +auras[1]={name='Rejuvenation',duration=12,expires=52,source='player',spell=774} +auras[2]={name='Rejuvenation',duration=12,expires=52,source='party1',spell=774} +AuraChanged();Tick('HealerGUID',774,80,false);assert(P:UnitGetIncomingHeals('target')==130) +AuraChanged();assert(P:UnitGetIncomingHeals('target')==130) -- refresh does not duplicate same aura +Tick('HealerGUID',774,160,true);assert(P:UnitGetIncomingHeals('target')==130) -- crit does not inflate next tick +gear='New robe';event='UNIT_INVENTORY_CHANGED';arg1='player';driver.scripts.OnEvent() +assert(P:UnitGetIncomingHeals('target')==80, tostring(P:UnitGetIncomingHeals('target'))) -- only own gear-dependent tick samples invalidated +auras={};AuraChanged();arg1=.2;if driver.scripts.OnUpdate then driver.scripts.OnUpdate() end +assert(P:UnitGetIncomingHeals('target')==0 and not fill.visible and not driver.scripts.OnUpdate) +mod:disable() +local file=assert(io.open(root..'main.lua'));local source=file:read('*a');file:close();assert(loadstring(source)) +local first=assert(string.find(source,' local prediction =',1,true)) +local last=assert(string.find(source,' -- Retire the removed duplicate toggle',first,true)) +local migrate=assert(loadstring(string.sub(source,first,last-1))) +ShaguTweaks_config={['Enable Healing Prediction']=1};migrate();assert(ShaguTweaks_config['Healing Predictions']==1 and not ShaguTweaks_config['Enable Healing Prediction']) +ShaguTweaks_config={['Enable Healing Prediction on Unit Frames']=1,['Healing Predictions']=0};migrate();assert(ShaguTweaks_config['Healing Predictions']==0) +print('Healing prediction: dormant option, clamped geometry, Big Health sizing, target/death/hostility guards, HealComm/expiry, learned casts, form/gear changes, interrupt ownership and disable/re-enable passed') diff --git a/tests/test_healing_shared.lua b/tests/test_healing_shared.lua new file mode 100644 index 0000000..b6e2f13 --- /dev/null +++ b/tests/test_healing_shared.lua @@ -0,0 +1,35 @@ +local root=(arg[1] or '.')..'/' +local frames,listeners,notified={}, {}, 0 +local compatible=true +UnitExists=function(unit) return true,compatible and (unit=='player' and 'SelfGUID' or 'TargetGUID') or 'WrongGUID' end +UnitGUID=function(unit) return unit=='player' and 'SelfGUID' or 'TargetGUID' end +UnitName=function(unit) return unit=='player' and 'Self' or 'Friend' end +UnitIsDeadOrGhost=function() return false end +UnitCanAssist=function() return true end +GetTime=function() return 10 end +CreateFrame=function() + local f={events={},scripts={}} + function f:RegisterEvent(name) self.events[name]=true end + function f:UnregisterAllEvents() self.events={} end + function f:SetScript(name,fn) self.scripts[name]=fn end + table.insert(frames,f);return f +end +ShaguTweaks={GetGlobalEnv=function() return getfenv(0) end,API={RegisterUnitEvent=function(f,e) f:RegisterEvent(e) end}} +C_EventUtils={IsEventValid=function() return true end} +local value=370 +PTHealPredict={GetIncomingHealing=function(guid) assert(guid=='SelfGUID' or guid=='TargetGUID');return value,300 end, + HookUpdates=function(fn) table.insert(listeners,fn) end} +dofile(root..'libs/libpredict.lua') +local P=ShaguTweaks.libpredict;P.changed=function() notified=notified+1 end +P:Enable();assert(P.external==PTHealPredict and P.source=='Puppeteer' and table.getn(frames)==0) +assert(P:UnitGetIncomingHeals('target')==370) -- use provider total, not total plus direct +P:Heal('Other','Friend',1000,3000);assert(not next(P.pending)) +local before=notified;listeners[1]('TargetGUID');assert(notified==before+1) +P:Disable();before=notified;listeners[1]('TargetGUID');assert(notified==before) +P:Enable();assert(table.getn(listeners)==1 and table.getn(frames)==0) +P:Disable();compatible=false;P:Enable() +assert(not P.external and P.source=='ClassicAPI + HealComm' and table.getn(frames)==1) +local driver=frames[1];assert(driver.events.COMBAT_LOG_EVENT_UNFILTERED) +compatible=true;event='PLAYER_ENTERING_WORLD';driver.scripts.OnEvent() +assert(P.external==PTHealPredict and not next(driver.events) and not driver.scripts.OnUpdate and table.getn(listeners)==1) +print('Shared Puppeteer: direct+HoT total once, no second driver, one callback, disabled guard, GUID compatibility and late activation passed') diff --git a/tests/test_health_event_routing.lua b/tests/test_health_event_routing.lua new file mode 100644 index 0000000..60fe0e3 --- /dev/null +++ b/tests/test_health_event_routing.lua @@ -0,0 +1,34 @@ +local root=(arg[1] or '.')..'/' +local f=assert(io.open(root..'mods/health-numbers.lua'));local source=f:read('*a');f:close() +local a=assert(string.find(source,' self.valueEvents =',1,true)) +local b=assert(string.find(source,' local alternatePowerBar =',a,true)) +local handler,registered,calls=nil,{},{} +local function record(kind,unit) table.insert(calls,kind..':'..(unit or '')) end +local events={} +function events:RegisterEvent(name) registered[name]=true end +function events:SetScript(name,callback) assert(name=='OnEvent');handler=callback end +CreateFrame=function() return events end +ShaguTweaks={API={RegisterUnitEvent=function(frame,name,...) registered[name]=arg end}} +local self={valueTexts={playerHealth='ph',targetHealth='th',petHealth='eh',playerMana='pp',targetMana='tp',petMana='ep'}} +local init=assert(loadstring('return function(self,UpdateHealth,UpdatePower,PositionTexts,UpdatePlayer,UpdateTarget,UpdatePet,UpdateAlternatePower) '..string.sub(source,a,b-1)..' end'))() +init(self,function(text,unit,estimated) + assert(text==self.valueTexts[unit..'Health'] and estimated==(unit=='target'));record('health',unit) +end,function(text,unit) assert(text==self.valueTexts[unit..'Mana']);record('power',unit) end, + function() record('position') end,function() record('full','player') end, + function() record('full','target') end,function() record('full','pet') end,function() record('alternate') end) +local function emit(name,unit) calls={};event,arg1=name,unit;handler() end +for _,unit in ipairs({'player','target','pet'}) do + for _,name in ipairs({'UNIT_HEALTH','UNIT_MAXHEALTH'}) do + emit(name,unit);assert(table.getn(calls)==1 and calls[1]=='health:'..unit) + end + for _,name in ipairs({'UNIT_MANA','UNIT_MAXMANA','UNIT_RAGE','UNIT_MAXRAGE','UNIT_ENERGY','UNIT_MAXENERGY','UNIT_FOCUS','UNIT_MAXFOCUS','UNIT_HAPPINESS','UNIT_MAXHAPPINESS','UNIT_DISPLAYPOWER'}) do + assert(registered[name]);emit(name,unit) + assert(calls[1]=='power:'..unit and table.getn(calls)==(unit=='player' and 2 or 1)) + if unit=='player' then assert(calls[2]=='alternate:') end + end +end +emit('UNIT_HEALTH','party1');assert(not next(calls)) +emit('PLAYER_TARGET_CHANGED');assert(table.getn(calls)==1 and calls[1]=='full:target') +emit('PET_UI_UPDATE');assert(table.getn(calls)==1 and calls[1]=='full:pet') +emit('PLAYER_ENTERING_WORLD');assert(table.getn(calls)==5 and calls[1]=='position:' and calls[5]=='alternate:') +print('Health routing: independent health/power events for player/target/pet, alternate power, unit filtering and full identity/startup refreshes passed') diff --git a/tests/test_loss_of_control_style.lua b/tests/test_loss_of_control_style.lua index 2fa7787..a14d934 100644 --- a/tests/test_loss_of_control_style.lua +++ b/tests/test_loss_of_control_style.lua @@ -27,6 +27,7 @@ UIParent=new();GameFontNormalLarge='NormalLarge';GameFontHighlightSmall='Highlig SlashCmdList={};ShaguTweaks_config={} local module ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return _G end} +dofile(root..'api.lua') function ShaguTweaks:register(m) module=m;return m end dofile(root..'mods/loss-of-control.lua') local baseline=made;module:enable();assert(made==baseline) -- unavailable API stays dormant diff --git a/tests/test_macro_native.lua b/tests/test_macro_native.lua new file mode 100644 index 0000000..163d780 --- /dev/null +++ b/tests/test_macro_native.lua @@ -0,0 +1,86 @@ +local root=(arg[1] or '.')..'/' +local modules,frames,published,calls,tickers={},{},{},{},{} +local modifier=false +local bodies={ + [1]='#showtooltip Fireball\n/cast Fireball', + [2]='--showtooltip Frostbolt\n/cast Fireball', + [3]='/pfcast Heal(Rank 1)', + [4]='#showtooltip [mod:shift] Fireball; Frostbolt\n--showtooltip Heal', + [5]='/castsequence Fireball,Frostbolt', + [6]='--showtooltip [mod:shift] Heal; Frostbolt', +} +local nativeSelectedIcon='chosen-custom-icon' +C_EventUtils={IsEventValid=function() return true end} +GetMacroInfo=function(id) if bodies[id] then return 'Macro '..id,nativeSelectedIcon,bodies[id] end end +CreateFrame=function(_,name) + local f={events={},scripts={}} + function f:RegisterEvent(name) self.events[name]=true end + function f:UnregisterEvent(name) self.events[name]=nil end + function f:UnregisterAllEvents() self.events={} end + function f:SetScript(k,v) self.scripts[k]=v end + table.insert(frames,f);return f +end +SecureCmdOptionParse=function(value) return modifier and 'Heal' or 'Frostbolt','mouseover' end +C_Macro={SetMacroDisplay=function(id,value,unit) + published[id]=value;table.insert(calls,{id,value,unit}) + return true +end,GetMacroIcon=function() return nativeSelectedIcon end} +C_Timer={NewTicker=function(interval,callback) + assert(interval==.2);local t={callback=callback};function t:Cancel() self.cancelled=true end + table.insert(tickers,t);return t +end} +local sent,executed={},{} +SendChatMessage=function(text) table.insert(sent,text) end +local originalChat=SendChatMessage +RunMacroText=function(text) table.insert(executed,text) end +ChatFrameEditBox={scripts={},AddHistoryLine=function() end,GetScript=function(self,k) return self.scripts[k] end, + SetScript=function(self,k,v) self.scripts[k]=v end} +SlashCmdList={} +SLASH_OTHERUSE1='/use';SlashCmdList.OTHERUSE=function() end +ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return getfenv(0) end, + API={macrodisplay=true,nativemacrocommands=true}, + libspell={GetSpellIndex=function() error('Native macros must not scan the spellbook') end}} +function ShaguTweaks:register(m) modules[m.title]=m;return m end +dofile(root..'mods/macro-icons.lua');dofile(root..'mods/macro-tweaks.lua') +local icons=modules['Macro Icons'];icons:enable();local driver=icons.nativeDriver +assert(not published[1] and published[2]=='Frostbolt' and published[3]=='Heal(Rank 1)' and not published[4] and not published[5]) +assert(published[6]=='Frostbolt' and C_Macro.GetMacroIcon(1)=='chosen-custom-icon') +assert(not driver.events.ACTIONBAR_SLOT_CHANGED and not driver.events.ACTIONBAR_PAGE_CHANGED and not driver.scripts.OnUpdate) +local initial=table.getn(calls);tickers[1].callback();assert(table.getn(calls)==initial) +modifier=true;event='MODIFIER_STATE_CHANGED';driver.scripts.OnEvent();assert(published[6]=='Heal') +assert(calls[table.getn(calls)][3]=='mouseover') +bodies[2]='#showtooltip Fireball';bodies[6]='/cast Fireball';event='UPDATE_MACROS';driver.scripts.OnEvent() +assert(not published[2] and not published[6] and tickers[1].cancelled) +assert(not driver.events.MODIFIER_STATE_CHANGED) +-- A custom item hint that initially lacks cached data is retried on arrival. +bodies[7]='--showtooltip item:789' +local publish=C_Macro.SetMacroDisplay +local cached=false +C_Macro.SetMacroDisplay=function(id,value,unit) + if id==7 and value and not cached then return false end + return publish(id,value,unit) +end +event='UPDATE_MACROS';driver.scripts.OnEvent();assert(not published[7] and driver.events.GET_ITEM_INFO_RECEIVED) +cached=true;event='GET_ITEM_INFO_RECEIVED';driver.scripts.OnEvent();assert(published[7]=='item:789') +local count=table.getn(frames);icons:enable();assert(table.getn(frames)==count) +modules['Macro Tweaks']:enable() +assert(SendChatMessage==originalChat and not SLASH_SHAGUTWEAKS_USE1 and SlashCmdList.OTHERUSE) +SlashCmdList.SHAGUTWEAKS_EQUIP('[combat] item:123') +SlashCmdList.SHAGUTWEAKS_FOCUS('[@mouseover]') +SlashCmdList.SHAGUTWEAKS_STARTATTACK('[harm]') +assert(executed[1]=='/equip [combat] item:123' and executed[2]=='/focus [@mouseover]' and executed[3]=='/startattack [harm]') +modules['Macro Tweaks']:enable();assert(SendChatMessage==originalChat) +SendChatMessage('#showtooltip is ordinary chat here');assert(table.getn(sent)==1) +-- Older command surface retains the metadata filter and legacy bag-slot use. +modules['Macro Tweaks'].installed=nil;ShaguTweaks.API.nativemacrocommands=false +SLASH_OTHERUSE1=nil;SLASH_SHAGUTWEAKS_EQUIP1=nil +UseContainerItem=function(bag,slot) assert(bag==0 and slot==2);table.insert(executed,'legacy-use') end +modules['Macro Tweaks']:enable();SendChatMessage('#showtooltip Fireball');assert(table.getn(sent)==1) +SendChatMessage('hello');assert(sent[2]=='hello') +SlashCmdList.SHAGUTWEAKS_EQUIP('0 2');assert(executed[4]=='legacy-use') +event='PLAYER_LOGOUT';driver.scripts.OnEvent();assert(not published[3]) +icons.installed=nil;CleveRoids={};local before=table.getn(frames);icons:enable();assert(table.getn(frames)==before);CleveRoids=nil +-- Older icon surface still initializes the existing event-driven fallback. +ShaguTweaks.API.macrodisplay=false;icons.installed=nil;icons:enable() +assert(frames[table.getn(frames)].events.ACTIONBAR_SLOT_CHANGED) +print('Macros: native ownership/chosen icons, custom hints/conditions, edit release, conditional-only ticker, idempotence, native aliases, foreign aliases and older API paths passed') diff --git a/tests/test_range_events.lua b/tests/test_range_events.lua new file mode 100644 index 0000000..50cbee9 --- /dev/null +++ b/tests/test_range_events.lua @@ -0,0 +1,44 @@ +local root=(arg[1] or '.')..'/' +local refs,frames,hooks={},{},{} +local function button(name,slot) + local b={slot=slot,visible=true,scripts={}} + function b:IsVisible() return self.visible end + function b:GetName() return name end + _G[name]=b;_G[name..'Icon']={SetVertexColor=function(self,...) self.color=arg end} + return b +end +local main=button('ActionButton1',1);local other=button('MultiBarBottomLeftButton1',1) +CreateFrame=function() local f={events={},RegisterEvent=function(self,e) self.events[e]=true end,SetScript=function(self,k,v) self[k]=v end};table.insert(frames,f);return f end +local module +ShaguTweaks={API={eventutils=true},T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return getfenv(0) end} +function ShaguTweaks:register(m) module=m;return m end +function ShaguTweaks.hooksecurefunc(name,fn) hooks[name]=fn end +function ShaguTweaks.HookScript(b,k,fn) b.scripts[k]=fn end +C_EventUtils={IsEventValid=function() return true end} +C_ActionBar={EnableActionRangeCheck=function(slot,on) refs[slot]=(refs[slot] or 0)+(on and 1 or -1);assert(refs[slot]>=0) end} +local helpful,assist,usable,mana=false,true,true,false +C_Spell={IsSpellHelpful=function() return helpful end,IsSpellHarmful=function() return false end} +UnitCanAssist=function() return assist end;UnitCanAttack=function() return true end +ShaguTweaks.API.GetActionInfo=function(slot) return slot==2 and 'macro' or 'spell',123 end +ActionButton_GetPagedID=function(b) return b.slot end +IsUsableAction=function() return usable,mana end +dofile(root..'mods/actionbar-range-color.lua') +assert(not next(hooks)) +module:enable();assert(not hooks.ActionButton_OnUpdate and refs[1]==2) +local listener=frames[1] +local function eventRange(slot,inRange,checks) event='ACTION_RANGE_CHECK_UPDATE';arg1,arg2,arg3=slot,inRange,checks;listener.OnEvent() end +eventRange(1,false,true);assert(ActionButton1Icon.color[1]==.85 and MultiBarBottomLeftButton1Icon.color[1]==.85) +this=main;hooks.ActionButton_UpdateUsable();assert(ActionButton1Icon.color[1]==.85) +helpful=true;assist=false;eventRange(1,false,true);assert(ActionButton1Icon.color[1]==1) +helpful=false;assist=true;usable=false;mana=true;eventRange(1,true,true);assert(ActionButton1Icon.color[3]==1 and ActionButton1Icon.color[1]==.5) +usable=true;mana=false +main.slot=2;this=main;hooks.ActionButton_Update();assert(refs[1]==1 and refs[2]==1) +eventRange(2,false,true);assert(ActionButton1Icon.color[1]==.85) +event='PLAYER_TARGET_CHANGED';listener.OnEvent();assert(ActionButton1Icon.color[1]==.85) -- unchanged native results need no new event +eventRange(2,false,false);assert(ActionButton1Icon.color[1]==1) +main.visible=false;this=main;main.scripts.OnHide();assert(refs[2]==0) +main.visible=true;main.scripts.OnShow();assert(refs[2]==1) +for i=1,10 do hooks.ActionButton_Update() end;assert(refs[2]==1) +-- Older supported ClassicAPI keeps the existing throttled path. +C_ActionBar=nil;hooks={};module:enable();assert(hooks.ActionButton_OnUpdate) +print('Range events: native refs, duplicate slots, page/hide/show, unchanged state, faction/mana tint and old-version fallback passed') diff --git a/tests/test_runtime_helpers.lua b/tests/test_runtime_helpers.lua new file mode 100644 index 0000000..c098f3a --- /dev/null +++ b/tests/test_runtime_helpers.lua @@ -0,0 +1,59 @@ +local root=(arg[1] or '.')..'/' +local pending,frames={},{} +CreateFrame=function() + local f={scripts={},events={},shown=true} + function f:RegisterEvent(name) self.events[name]=true end + function f:UnregisterAllEvents() self.events={} end + function f:SetScript(name,callback) self.scripts[name]=callback end + function f:Show() self.shown=true end + function f:Hide() self.shown=false end + table.insert(frames,f);return f +end +C_Timer={After=function(delay,callback) assert(delay==0);table.insert(pending,callback) end} +ShaguTweaks={GetGlobalEnv=function() return getfenv(0) end} +dofile(root..'api.lua') +local API=ShaguTweaks.API +-- All eight merged-key combinations, then left/right ClassicAPI state with +-- conflicting vanilla keys: native state must win. +local shift,control,alt=false,false,false +IsShiftKeyDown=function() return shift end +IsControlKeyDown=function() return control end +IsAltKeyDown=function() return alt end +for s=0,1 do for c=0,1 do for a=0,1 do + shift,control,alt=s==1,c==1,a==1 + assert(API.AreMoveModifiersDown()==(shift and control and alt)) +end end end +API.modifierkeys=true +local left,right={false,false,false},{false,false,false} +IsLeftShiftKeyDown=function() return left[1] end +IsRightShiftKeyDown=function() return right[1] end +IsLeftControlKeyDown=function() return left[2] end +IsRightControlKeyDown=function() return right[2] end +IsLeftAltKeyDown=function() return left[3] end +IsRightAltKeyDown=function() return right[3] end +assert(not API.AreMoveModifiersDown()) +left[1],right[2],left[3]=true,true,true +assert(API.AreMoveModifiersDown()) +left[3]=false;assert(not API.AreMoveModifiersDown()) +right[3]=true;assert(API.AreMoveModifiersDown()) +local function idle(f) + assert(not next(f.events) and not f.scripts.OnEvent and not f.scripts.OnUpdate and not f.shown) +end +local count=0 +local function callback() count=count+1 end +-- Independent requests wait through world entry and first update, then each +-- receives exactly one native deferred callback. Idle drivers retain no scripts. +local first=API.AfterWorldReady(callback) +local second=API.AfterWorldReady(callback,true) +assert(count==0 and first.events.PLAYER_ENTERING_WORLD and not first.scripts.OnUpdate) +first.scripts.OnEvent();second.scripts.OnEvent();assert(count==0) +first.scripts.OnUpdate();second.scripts.OnUpdate() +idle(first);idle(second);assert(count==0 and table.getn(pending)==2) +pending[1]();pending[2]();assert(count==2) +API.timer=false +first=API.AfterWorldReady(callback);second=API.AfterWorldReady(callback,true) +first.scripts.OnEvent();second.scripts.OnEvent() +first.scripts.OnUpdate();idle(first);assert(count==3) +second.scripts.OnUpdate();assert(count==3 and second.shown and second.scripts.OnUpdate) +second.scripts.OnUpdate();idle(second);assert(count==4) +print('Runtime helpers: modifier combinations/native left-right precedence, independent one-shot startup timing, fallback extra frame and idle cleanup passed') diff --git a/tests/test_standalone_player.lua b/tests/test_standalone_player.lua index b985248..2b19df2 100644 --- a/tests/test_standalone_player.lua +++ b/tests/test_standalone_player.lua @@ -52,6 +52,8 @@ local function Check(big,timer,hidden,hideName,maskAvailable) local keys=false ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),API={timer=timer}} function ShaguTweaks.GetGlobalEnv() return _G end + dofile(root..'api.lua') + ShaguTweaks.API.timer=timer function ShaguTweaks:register(module) modules[module.title]=module;return module end ShaguTweaks.API.IsShiftKeyDown=function() return keys end ShaguTweaks.API.IsControlKeyDown=ShaguTweaks.API.IsShiftKeyDown diff --git a/tests/test_swing_native.lua b/tests/test_swing_native.lua new file mode 100644 index 0000000..5fd57b4 --- /dev/null +++ b/tests/test_swing_native.lua @@ -0,0 +1,149 @@ +local root=(arg[1] or '.')..'/' +local function Check(native,methodAvailable,class) + class=class or 'HUNTER' + local now,keys=0,false + local frames,modules,timers={},{},{} + local methods={} + local noop=function() end + local function object(kind,name,parent) + local f=setmetatable({scripts={},events={},shown=true,parent=parent,kind=kind}, + {__index=function(t,k) return methods[k] or (string.find(k,'^[A-Z]') and noop or nil) end}) + if kind=='StatusBar' and not methodAvailable then f.SetTimerDuration=false end + if name then _G[name]=f end + table.insert(frames,f);return f + end + function methods:RegisterEvent(name) self.events[name]=true end + function methods:RegisterUnitEvent(name) self.events[name]=true end + function methods:SetScript(name,callback) self.scripts[name]=callback end + function methods:Show() self.shown=true end + function methods:Hide() self.shown=false end + function methods:IsShown() return self.shown end + function methods:CreateTexture() return object('Texture',nil,self) end + function methods:CreateFontString() return object('FontString',nil,self) end + function methods:SetText(value) self.text=value end + function methods:SetStatusBarColor(...) self.color=arg end + function methods:SetPoint(...) self.point=arg end + function methods:GetStatusBarTexture() self.fill=self.fill or object('Texture');return self.fill end + function methods:SetMinMaxValues(low,high) self.low,self.high=low,high;self.nativeTimer=nil end + function methods:SetValue(value) self.value=value end + function methods:SetTimerDuration(duration) self.nativeTimer=duration end + function methods:ClearAllPoints() self.point=nil end + function methods:GetLeft() return 20 end + function methods:GetBottom() return 40 end + CreateFrame=object + UIParent=object('Frame');CastingBarFrame=object('StatusBar') + GetTime=function() return now end + UnitClass=function() return class,class end + UnitAffectingCombat=function() return false end + UnitAttackSpeed=function() return 2,2 end + UnitRangedDamage=function() return 2 end + GetInventoryItemTexture=function() return 'weapon' end + C_Item={GetItemInventoryType=function() return 13 end} + local swingType={MainHand=1,OffHand=2,Ranged=3} + Enum={PlayerSwingType=swingType,StatusBarInterpolation={Immediate=0},StatusBarTimerDirection={ElapsedTime=0},NumericRuleFormatRounding={Down=0}} + C_SwingTimer={EnableRangeCheck=noop,IsTargetWithinSwingRange=function() return true end} + C_EventUtils={IsEventValid=function() return true end} + local queued=false + C_Spell={IsNextMeleeSpell=function(id) return id==6807 end,GetSpellName=function() return 'Maul' end} + GetActionInfo=function(slot) if slot==1 then return 'spell',6807 end end + IsCurrentAction=function(slot) return slot==1 and queued end + C_Timer={NewTimer=function(delay,callback) + local timer={at=now+delay,callback=callback,cancelled=false} + function timer:Cancel() self.cancelled=true end + table.insert(timers,timer);return timer + end} + C_DurationUtil={CreateDuration=function() + local d={} + function d:SetTimeFromEnd(finish,total) self.finish,self.total=finish,total end + function d:GetRemainingDuration() return math.max(0,(self.finish or 0)-now) end + return d + end,CreateDurationTextBinding=function() + local b={enabled=false} + function b:SetFontString(text) self.text=text end + function b:SetDuration(d) self.duration=d end + function b:SetFormatter(formatter) self.formatter=formatter end + function b:SetExpiredText(text) self.expired=text end + function b:Enable() self.enabled=true end + function b:Disable() self.enabled=false end + return b + end} + C_StringUtil={CreateNumericRuleFormatter=function() return {AddBreakpoint=function(self,v) assert(v.step==.1);self.rule=v end} end} + ShaguTweaks_config={} + ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return getfenv(0) end, + API={durationtimers=native,AreMoveModifiersDown=function() return keys end}} + if not native then C_DurationUtil.CreateDurationTextBinding=nil end + dofile(root..'api.lua') + ShaguTweaks.API.AreMoveModifiersDown=function() return keys end + assert((ShaguTweaks.API.durationtimers and true or false)==native) + function ShaguTweaks:register(m) modules[m.title]=m;return m end + dofile(root..'mods/swing-timer.lua') + modules['Swing Timer']:enable();modules['Wand Timer']:enable() + local mh,oh,ra=ShaguTweaksSwingTimer_mainhand,ShaguTweaksSwingTimer_offhand,ShaguTweaksSwingTimer_ranged + local listener,driver + for _,frame in ipairs(frames) do + if frame.events.PLAYER_SWING then listener=frame end + if frame.scripts.OnUpdate then driver=frame end + end + local useNative=native and methodAvailable + assert((driver==nil)==useNative) + local function emit(name,a,b,c,d) + event,arg1,arg2,arg3,arg4=name,a,b,c,d;listener.scripts.OnEvent() + end + local function advance(seconds) + local finish=now+seconds + while true do + local nextTimer + for _,timer in ipairs(timers) do + if not timer.cancelled and timer.at<=finish and (not nextTimer or timer.at