Compare commits

...

111 commits

Author SHA1 Message Date
Kelsi
5adb5e0e9f feat: add health bar color transitions for player and pet frames
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
Player health bar now transitions green→orange→pulsing red as HP drops
(>50%=green, 20-50%=orange, <20%=pulsing red). Pet frame gets the same
3-tier color scheme. Target and party frames already had color coding.
2026-03-12 04:06:46 -07:00
Kelsi
d14982d125 feat: add DPS/HPS meter showing real-time damage and healing output
Floating window right of the cast bar showing player's DPS and healing
per second, derived from combat text entries. Uses actual combat duration
as denominator for accurate readings at fight start. Toggle in Settings
> Network. Saves to settings.cfg.
2026-03-12 04:04:27 -07:00
Kelsi
797bb5d964 feat: add center-screen raid warning and boss emote overlay
RAID_WARNING messages show as flashing red/yellow large text.
RAID_BOSS_EMOTE and MONSTER_EMOTE show as amber text.
Each message fades in quickly, holds for 5 seconds, then fades out.
Up to 3 messages stack vertically below the target frame area.
Dark semi-transparent background box improves readability.
Messages are detected from new chat history entries each frame.
2026-03-12 03:52:54 -07:00
Kelsi
66ec35b106 feat: show decimal precision for short action bar cooldowns
Display "1.5" instead of "1s" for cooldowns under 5 seconds,
matching WoW's default cooldown text behaviour for GCDs and
short ability cooldowns where sub-second timing matters.
2026-03-12 03:48:12 -07:00
Kelsi
6068d0d68d feat: add HP% text and cast bars to nameplates
- Show health percentage centered on each nameplate health bar
- Show purple cast bar below health bar when a unit is actively casting
- Display spell name (from Spell.dbc cache) above cast bar
- Show time remaining (e.g. "1.4s") centered on cast bar fill
- All elements respect the existing nameplate alpha fade-out at distance
2026-03-12 03:44:32 -07:00
Kelsi
fa947eb9c7 Add quest objective tracker overlay on right side of screen
Shows tracked quests (or first 5 active quests if none tracked) below the
minimap with live kill/item objective counts and completion status.
2026-03-12 03:39:10 -07:00
Kelsi
63c8e82913 Move latency meter to top-center of screen
Relocate the ms indicator from below the minimap to a small centered
overlay at the top of the screen, with a semi-transparent background
for better readability during gameplay.
2026-03-12 03:31:09 -07:00
Kelsi
d70db7fa0b Reduce damage flash vignette opacity for subtler combat feedback
Peak alpha reduced from 180 to 100 (71% → 39%) so the red edge flash is
noticeable but less intrusive during combat.
2026-03-12 03:24:25 -07:00
Kelsi
6cf511aa7f Add damage flash toggle setting and fix map explored zone reveal
Persist damage_flash to settings.cfg; checkbox in Interface > Screen Effects.
Fix world map fog: trust server exploration mask unconditionally when present,
always reveal the current zone immediately regardless of server mask state.
2026-03-12 03:21:49 -07:00
Kelsi
40a98f2436 Fix talent tab crash and missing talent points at level 10
Unique child window and button IDs per tab prevent ImGui state corruption
when switching talent tabs. Parse SMSG_INSPECT_TALENT type=0 properly to
populate unspentTalentPoints_ and learnedTalents_ from the server response.
2026-03-12 03:15:56 -07:00
Kelsi
6ab9ba65f9 Store and display played time on character Stats tab
Save totalTimePlayed/levelTimePlayed from SMSG_PLAYED_TIME. Request a
fresh update whenever the character screen is opened. Show total and
level-played time in a two-column layout below the stats panel.
2026-03-12 03:09:52 -07:00
Kelsi
46eb66b77f Store and display achievement criteria progress from SMSG_CRITERIA_UPDATE
Track criteria progress (criteriaId → counter) from SMSG_CRITERIA_UPDATE
and SMSG_ALL_ACHIEVEMENT_DATA. Add a Criteria tab to the achievement window
showing live progress values alongside the existing Earned achievements tab.
2026-03-12 03:03:02 -07:00
Kelsi
920950dfbd Add threat list window showing live aggro data for current target
Store SMSG_THREAT_UPDATE/SMSG_HIGHEST_THREAT_UPDATE in a per-unit map
(sorted descending by threat) and clear on SMSG_THREAT_REMOVE/CLEAR.
Show a threat window (/threat or via target frame button) with a progress
bar per player and gold highlight for the tank, red if local player has aggro.
2026-03-12 02:59:09 -07:00
Kelsi
43de2be1f2 Add inspect window showing talent summary and gear for inspected players
Store inspect results (talent points, dual-spec state, gear entries) in a
new InspectResult struct instead of discarding them as chat messages.
Open the inspect window automatically from all Inspect menu items and /inspect.
2026-03-12 02:52:40 -07:00
Kelsi
92db25038c Parse SMSG_ARENA_TEAM_STATS and display in character screen PvP tab 2026-03-12 02:35:29 -07:00
Kelsi
2bdd024f19 Add GM Ticket window (/ticket, /gm commands and Esc menu button) 2026-03-12 02:31:12 -07:00
Kelsi
3964a33c55 Add /help slash command listing all available commands 2026-03-12 02:23:24 -07:00
Kelsi
adf8e6414e Show boot vote progress in LFG UI; fix unused screenH warning 2026-03-12 02:17:49 -07:00
Kelsi
f4754797bc Add Achievements list window (Y key toggle) with search filter 2026-03-12 02:09:35 -07:00
Kelsi
7a1f330655 Add minimap coordinate tooltip and play time warning display
Hovering over the minimap now shows a tooltip with the player's
WoW canonical coordinates (X=North, Y=West) and a hint about
Ctrl+click pinging.

SMSG_PLAY_TIME_WARNING is now parsed (type + minutes) and shown
as both a chat message and a UIError overlay rather than silently
dropped.
2026-03-12 01:57:03 -07:00
Kelsi
1bc3e6b677 Add Channels tab to social frame and reputation change toast
Social frame now has three tabs: Friends, Ignore, and Channels. The
Channels tab lists joined channels with right-click Leave and an input
to join new channels.

Also adds a slide-in reputation change toast in the lower-right corner:
shows faction name, delta (+/-), and current standing tier (Honored,
Revered, etc.) whenever SMSG_SET_FACTION_STANDING fires a rep change.
2026-03-12 01:51:18 -07:00
Kelsi
fb6630a7ae Add Ignore tab to social frame with view/unignore/add support
Social frame now has Friends and Ignore tabs. Friends tab shows online
players first, then offline with a separator, and supports right-click
Whisper/Invite/Remove. Ignore tab lists all ignored players from
ignoreCache with right-click Unignore and an inline add-ignore field.
2026-03-12 01:31:44 -07:00
Kelsi
06456faa63 Extend UIErrorsFrame to spell failures, interrupts, server shutdown warnings 2026-03-12 01:22:42 -07:00
Kelsi
25e2c60603 Add UIErrorsFrame: center-bottom spell error overlay with fade-out 2026-03-12 01:15:11 -07:00
Kelsi
955b22841e Wire SMSG_FORCE_ANIM animId to emoteAnimCallback 2026-03-12 01:04:16 -07:00
Kelsi
eb9ca8e227 Fix item cooldowns not showing on action bar item-type slots
SMSG_ITEM_COOLDOWN now resolves itemId via onlineItems_ and applies cooldown
to both SPELL-type and ITEM-type action bar slots. Classic SMSG_SPELL_COOLDOWN
also uses the embedded itemId field to update ITEM-type slots.
2026-03-12 00:59:25 -07:00
Kelsi
9e5f7c481e Wire achievement toast and ding effect callbacks
Level-up now calls triggerDing() (sound + emote + fade text) in addition to
the screen flash. Achievement earned now calls triggerAchievementToast() via
setAchievementEarnedCallback(), making the existing toast animation visible.
2026-03-12 00:56:31 -07:00
Kelsi
d8f2fedae1 Implement renderSocialFrame: compact friends panel with minimap toggle button
Shows online/AFK/DND/offline status dots, whisper/invite/remove context menus,
and inline add-friend field. Minimap gets a smiley-face button (top-left) with
a green dot badge when friends are online, toggling the panel.
2026-03-12 00:53:57 -07:00
Kelsi
c89dc50b6c Distinguish channeled spells in cast bar with blue color and draining animation
Adds castIsChannel flag set on MSG_CHANNEL_START, cleared on all cast resets.
Cast bar now drains right-to-left in blue for channels vs gold fill for casts.
2026-03-12 00:43:29 -07:00
Kelsi
c13e18cb55 Add Set Raid Mark submenu to target, party, and raid frame context menus
Implements setRaidMark() using the existing RaidTargetUpdatePacket and exposes
it via right-click on target frame, party member frames, and raid cell frames.
2026-03-12 00:39:56 -07:00
Kelsi
c0f19f5883 Add missing context menu items and nameplate right-click menus
- Focus frame: add Add Friend / Ignore items for player targets
- Guild roster: add Add Friend / Ignore items to member context menu
- Nameplates: right-click shows Target/Set Focus/Whisper/Invite/Friend/Ignore popup
2026-03-12 00:26:47 -07:00
Kelsi
d0f2916885 Add right-click context menus to bag bar slots and backpack 2026-03-12 00:21:25 -07:00
Kelsi
778363bfaf Add Add Friend/Ignore to party member context menu 2026-03-12 00:19:10 -07:00
Kelsi
e13993de9b Add 'Add to Action Bar' option to spellbook right-click context menu 2026-03-12 00:16:35 -07:00
Kelsi
928f00de41 Add Show Helm/Cloak checkboxes to Equipment tab; expose isHelmVisible/isCloakVisible 2026-03-12 00:13:48 -07:00
Kelsi
d072c852f3 Add AFK/DND toggles to player frame menu and right-click context to pet frame 2026-03-12 00:10:54 -07:00
Kelsi
5fdcb5df81 Wire up TOGGLE_QUEST_LOG keybinding (Q key) to open quest log screen 2026-03-12 00:05:55 -07:00
Kelsi
1cab2e1156 Add Abandon Quest option to quest tracker right-click menu 2026-03-12 00:04:11 -07:00
Kelsi
347e958703 Improve player frame context menu: name header, Leave Group when in group 2026-03-12 00:03:23 -07:00
Kelsi
c170216e1c Add Invite to Group to friends list right-click menu for online friends 2026-03-12 00:02:20 -07:00
Kelsi
00a66b7114 Add right-click context menu to action bar slots with Cast/Use and Clear Slot 2026-03-11 23:59:51 -07:00
Kelsi
9705904052 Add Follow option to target frame and party member context menus 2026-03-11 23:58:37 -07:00
Kelsi
7943edf252 Add Duel and Inspect to target, focus, and party member context menus 2026-03-11 23:56:57 -07:00
Kelsi
716c0c0e4c Add right-click context menu to quest log list entries 2026-03-11 23:54:19 -07:00
Kelsi
109b0a984a Add right-click context menu to focus frame
Shows Target, Clear Focus, and player-only actions (Whisper, Invite, Trade) when right-clicking the focus name.
2026-03-11 23:51:27 -07:00
Kelsi
d3221ff253 Add right-click context menu to target-of-target frame
Right-clicking the ToT name shows Target and Set Focus options; clicking the name still targets the unit.
2026-03-11 23:50:41 -07:00
Kelsi
2cd4672912 Add Invite to Group to chat message right-click menu
Allows inviting players directly from chat messages, consistent with the target frame and party frame context menus.
2026-03-11 23:49:37 -07:00
Kelsi
72e07fbe3f Add Whisper and Invite to Group to guild member context menu
Social actions appear at the top of the right-click menu when the member is online, matching WoW's guild roster behavior.
2026-03-11 23:48:07 -07:00
Kelsi
8eb451aab5 Add right-click context menu to spellbook spell rows
Allows casting or copying a spell link from the context menu, mirroring WoW's standard spellbook right-click behavior.
2026-03-11 23:46:47 -07:00
Kelsi
9b8bc2e977 Add right-click context menu to quest objective tracker
Right-clicking a quest title in the HUD tracker shows options to open
it in the Quest Log or toggle tracking (track/stop tracking).
2026-03-11 23:42:28 -07:00
Kelsi
54750d4656 Add right-click context menu to target frame name
Right-clicking the target's name now shows: Set Focus, Clear Target,
and for player targets: Whisper, Invite to Group, Trade, Add Friend, Ignore.
2026-03-11 23:41:05 -07:00
Kelsi
08bdd9eb36 Add low durability warning indicator below minimap
Shows 'Low durability' in orange when any equipped item falls below 20%
durability, and a pulsing red 'Item breaking!' warning below 5%. Uses
the same stacked indicator slot system as the latency and mail notices.
2026-03-11 23:35:51 -07:00
Kelsi
971ada6397 Add Shift+right-click destroy for inventory items with confirmation popup
Holding Shift while right-clicking any non-quest inventory item opens a
destroy confirmation popup instead of performing the normal equip/use
action. Item tooltips now show a 'Shift+RClick to destroy' hint at the
bottom (highlighted in red when Shift is held).
2026-03-11 23:32:43 -07:00
Kelsi
88436fa400 Add jump-to-bottom indicator in chat when scrolled up
Shows a "New messages" button below the chat history area when the
user has scrolled away from the latest messages. Clicking it jumps
back to the most recent messages.
2026-03-11 23:24:27 -07:00
Kelsi
c433188edb Show AFK/DND status badge on player frame
Adds a yellow <AFK> or orange <DND> label next to the player name
when those modes are active, with a tooltip explaining how to cancel.
2026-03-11 23:21:27 -07:00
Kelsi
fe61d6acce Add corpse direction indicator on minimap for ghost players
When the player is a ghost, shows a small X marker at the corpse
position on the minimap. If the corpse is off-screen, draws an edge
arrow on the minimap border pointing toward it.
2026-03-11 23:19:48 -07:00
Kelsi
b3d3814ce9 Add search bar and Active/Ready filter to quest log
Adds a name search input and All/Active/Ready radio buttons above the
quest list. Clears the filter automatically when openAndSelectQuest() is
called so the target quest is always visible.
2026-03-11 23:17:38 -07:00
Kelsi
3446fffe86 Add local time clock below minimap indicators 2026-03-11 23:13:31 -07:00
Kelsi
7e271df032 Add level-up golden burst overlay effect
When the player gains a level, a golden vignette flashes on screen edges
and a large "Level X!" text briefly appears in the center, both fading
over ~1 second. Uses the existing LevelUpCallback from GameHandler.
2026-03-11 23:10:21 -07:00
Kelsi
745768511b Show all buyback items in vendor window (not just the most recent) 2026-03-11 23:08:35 -07:00
Kelsi
682cb8d44b Add chat input history navigation with Up/Down arrows
Up arrow in the chat input field recalls previously sent messages.
Down arrow moves forward through history; going past the end clears input.
History stores up to 50 unique entries and resets position after each send.
2026-03-11 23:06:24 -07:00
Kelsi
e002266607 Add scroll wheel zoom to minimap 2026-03-11 23:01:37 -07:00
Kelsi
ae8f900410 Add Ctrl+click minimap ping sending
Ctrl+clicking on the minimap converts screen position to world coordinates
and sends MSG_MINIMAP_PING to the server. A local ping is also added
immediately so the sender sees their own ping.
2026-03-11 23:00:03 -07:00
Kelsi
97662800d5 Add /target command and screen damage flash vignette
/target <name> searches visible entities by case-insensitive prefix match.
Red vignette flashes on screen edges when player HP drops, fading over 0.5s.
2026-03-11 22:57:04 -07:00
Kelsi
6e7a32ec7f Add nameplate scale setting to Interface settings tab
Adds a 0.5x-2.0x scale slider under Nameplates in the Interface settings tab. The scale multiplies the base 80×8px nameplate health bar dimensions. Setting is persisted to settings.cfg as 'nameplate_scale'.
2026-03-11 22:49:54 -07:00
Kelsi
d31c483944 Add player position arrow to minimap
Draws a yellow directional arrow at the minimap center showing the player's facing direction, with a white dot at the player's position. The arrow rotates with the camera in rotate-with-camera mode and always points in the player's current facing direction.
2026-03-11 22:47:17 -07:00
Kelsi
823e2bcec6 Add Sell All Junk button to vendor window
Scans the backpack and all bags for grey (POOR quality) items with a sell price and shows a 'Sell All Junk (N items)' button at the top of the vendor window. Clicking it sells all matching items in one action. Button only appears when there are sellable junk items.
2026-03-11 22:44:23 -07:00
Kelsi
b2d1edc9db Show corpse distance on reclaim corpse button
When the player is a ghost, the 'Resurrect from Corpse' popup now shows how many yards away the corpse is, updating in real-time as the ghost moves. Distance is only shown when the corpse is on the same map.
2026-03-11 22:41:26 -07:00
Kelsi
355001c236 Add action bar scale setting to Interface settings tab
Adds a 0.5x-1.5x scale slider under Action Bars in the Interface settings tab. The scale multiplies the base 48px slot size for both the main bar and XP bar layout calculations. The setting is persisted to settings.cfg as 'action_bar_scale'.
2026-03-11 22:39:59 -07:00
Kelsi
69fd0b03a2 Add right-click context menu to player unit frame
Right-clicking the player name in the unit frame opens a popup with 'Open Character' (opens the character/equipment screen) and 'Toggle PvP' options, consistent with the existing right-click menus on party and raid frames.
2026-03-11 22:36:58 -07:00
Kelsi
18e42c22d4 Add HP percentage text to raid frame health bars
Each raid member cell now shows the health percentage centered on the health bar, with a drop shadow for readability. The text is omitted when no health data is available.
2026-03-11 22:31:55 -07:00
Kelsi
c9c20ce433 Display quest rewards (money and items) in quest log details pane
Parses reward money, guaranteed items, and choice items from SMSG_QUEST_QUERY_RESPONSE fixed header for both Classic/TBC (40-field) and WotLK (55-field) layouts. Rewards are shown in the quest details pane below objective progress with icons, names, and counts.
2026-03-11 22:30:16 -07:00
Kelsi
1693abffd3 Improve cooldown text format and show HP values on party health bars
Cooldowns now display as "Xs" (seconds), "XmYs" (minutes+seconds), or "Xh" (hours) instead of the previous bare number or "Xm"-only format. Party member health bars now show "current/max" HP text (abbreviated to "Xk/Yk" for large values) directly on the progress bar.
2026-03-11 22:25:15 -07:00
Kelsi
b44857dad9 Add Train All Available button to trainer window
Shows a footer button listing the count and total cost of all currently trainable spells. Clicking it sends a train request for each spell that meets level, prerequisite, and gold requirements. Button is disabled when nothing is trainable or the player cannot afford the full batch.
2026-03-11 22:23:26 -07:00
Kelsi
9a199e20b6 Add Loot All button to loot window 2026-03-11 22:16:19 -07:00
Kelsi
2f1c9eb01b Add FOV slider to settings, expand combat chat tab with skills/achievements/NPC speech 2026-03-11 22:13:22 -07:00
Kelsi
861bb3404f Improve vendor window: quantity selector, stock status colors, disable out-of-stock items 2026-03-11 22:10:43 -07:00
Kelsi
c9cfa864bf Add Achievements tab to character screen with search filter 2026-03-11 22:07:44 -07:00
Kelsi
c9ea61aba7 Fix Exalted reputation tier not displaying correctly (off-by-one in getTier loop) 2026-03-11 22:06:16 -07:00
Kelsi
a207ceef6c Add secondary stats (AP, SP, hit, crit, haste, etc.) to character stats panel 2026-03-11 22:03:33 -07:00
Kelsi
386de826af Add right-click context menu on chat messages for whisper/friend/ignore 2026-03-11 22:00:30 -07:00
Kelsi
f5de4d2031 Add shift-click spell linking to chat from spellbook 2026-03-11 21:57:13 -07:00
Kelsi
25c5d257ae Enhance Friends tab with add/remove/note/whisper and add Ignore List tab 2026-03-11 21:53:15 -07:00
Kelsi
c09ebae5af Add shift-click item linking to bank and guild bank windows 2026-03-11 21:50:07 -07:00
Kelsi
fc7cc44ef7 Add right-click context menu to raid frame cells with leader kick support 2026-03-11 21:47:10 -07:00
Kelsi
300e3ba71f Show quest status icons in gossip window based on QuestGiverStatus
Quest list in NPC gossip now shows colored status icons:
! (yellow) = available, ? (yellow) = ready to turn in,
! or ? (gray) = available low-level or in-progress incomplete.
2026-03-11 21:39:32 -07:00
Kelsi
54eae9bffc Add shift-click links and Take All to mail attachments
Mail attachment icons and names now support shift-click to insert item
links. Item names also show rich tooltips on hover. Adds a "Take All"
button when a mail has multiple attachments.
2026-03-11 21:37:15 -07:00
Kelsi
23cfb9b640 Add shift-click chat links to AH bids and owner auctions tabs 2026-03-11 21:34:28 -07:00
Kelsi
99d1f5778c Fix trade window peer tooltips; add shift-click links in trade and loot roll
Trade window now shows rich item tooltips for both sides (peer items were
missing tooltips). Both trade sides and the loot roll popup now support
shift-click to insert item links into the chat input.
2026-03-11 21:32:54 -07:00
Kelsi
7cfeed1e28 Shift-click items in quest/AH windows to insert chat links
Adds shift-click-to-link support in auction house browse results, quest
details reward items, quest offer/reward window choice and fixed items,
and quest request-items required item list.
2026-03-11 21:31:09 -07:00
Kelsi
3d40e4dee5 Shift-click items in loot and vendor windows to insert chat links 2026-03-11 21:27:16 -07:00
Kelsi
0075fdd5e1 Show item icons in quest objective tracker 2026-03-11 21:24:03 -07:00
Kelsi
43c0e9b2e8 Add spell search filter to trainer window 2026-03-11 21:21:14 -07:00
Kelsi
bbf4806fe8 Add item search filter to vendor window 2026-03-11 21:19:47 -07:00
Kelsi
7ab0b036c7 Add rich item tooltip to buyback item row in vendor window 2026-03-11 21:17:29 -07:00
Kelsi
95ac97a41c Use rich item tooltips in bank window slots 2026-03-11 21:15:41 -07:00
Kelsi
e34357a0a4 Use rich item tooltips in mail attachments and guild bank slots 2026-03-11 21:14:27 -07:00
Kelsi
4394f93a17 Use rich item tooltips in quest details window; fix shift-click chat link ordering 2026-03-11 21:11:58 -07:00
Kelsi
43c239ee2f Shift-click bag items to insert item links into chat input 2026-03-11 21:09:42 -07:00
Kelsi
e415451f89 Show item icons inline in chat item links 2026-03-11 21:03:51 -07:00
Kelsi
5bafacc372 Use full item tooltips in all auction house tabs 2026-03-11 21:02:02 -07:00
Kelsi
458c9ebe8c Show item icons for item objectives in quest log 2026-03-11 20:57:39 -07:00
Kelsi
3c0e58bff4 Show item icon and quality color in buyback table 2026-03-11 20:52:42 -07:00
Kelsi
647967cccb Show item icons in vendor window item list 2026-03-11 20:49:25 -07:00
Kelsi
764cf86e38 Show spell icons in trainer window with dimming for unavailable spells 2026-03-11 20:48:03 -07:00
Kelsi
d9a58115f9 Show item icons and rich tooltips in trade window slots 2026-03-11 20:42:26 -07:00
Kelsi
4ceb313fb2 Show item icons in quest turn-in required items list 2026-03-11 20:41:02 -07:00
Kelsi
1e8c85d850 Show item icons in mail read-view attachment list 2026-03-11 20:39:15 -07:00
Kelsi
26fab2d5d0 Show item icons in guild bank window
Replace text-only buttons with icon+draw-list rendering that matches
the style of the regular bank, loot, and vendor windows. Item icons are
looked up via inventoryScreen.getItemIcon(info->displayInfoId); falls
back to a coloured bordered square with two-letter abbreviation when
the texture is not yet cached. Stack count is overlaid in the
bottom-right corner. Withdraw still fires on left-click.
2026-03-11 20:33:46 -07:00
Kelsi
2e92ec903c Fix SMSG_ITEM_COOLDOWN missing cooldownTotal for sweep animation
SMSG_ITEM_COOLDOWN (on-use trinket/item cooldowns) was only setting
cooldownRemaining, leaving cooldownTotal=0. The action bar clock-sweep
overlay requires both fields; without cooldownTotal the fan shrinks
instantly rather than showing the correct elapsed arc.
2026-03-11 20:21:37 -07:00
Kelsi
8c2f69ca0e Rate-limit icon GPU uploads in spellbook, action bar, and inventory screens
Opening the spellbook on a new tab, logging in with many auras/action slots, or
opening a full bag all triggered synchronous BLP-decode + GPU uploads for every
uncached icon in one frame, causing a visible stall. Apply the same 4-per-frame
upload cap that was added to talent_screen, so icons load progressively.
2026-03-11 20:17:41 -07:00
14 changed files with 4104 additions and 353 deletions

View file

@ -332,10 +332,25 @@ public:
// Inspection
void inspectTarget();
struct InspectResult {
uint64_t guid = 0;
std::string playerName;
uint32_t totalTalents = 0;
uint32_t unspentTalents = 0;
uint8_t talentGroups = 0;
uint8_t activeTalentGroup = 0;
std::array<uint32_t, 19> itemEntries{}; // 0=head…18=ranged
};
const InspectResult* getInspectResult() const {
return inspectResult_.guid ? &inspectResult_ : nullptr;
}
// Server info commands
void queryServerTime();
void requestPlayedTime();
void queryWho(const std::string& playerName = "");
uint32_t getTotalTimePlayed() const { return totalTimePlayed_; }
uint32_t getLevelTimePlayed() const { return levelTimePlayed_; }
// Social commands
void addFriend(const std::string& playerName, const std::string& note = "");
@ -343,6 +358,7 @@ public:
void setFriendNote(const std::string& playerName, const std::string& note);
void addIgnore(const std::string& playerName);
void removeIgnore(const std::string& playerName);
const std::unordered_map<std::string, uint64_t>& getIgnoreCache() const { return ignoreCache; }
// Random roll
void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100);
@ -380,6 +396,8 @@ public:
// Display toggles
void toggleHelm();
void toggleCloak();
bool isHelmVisible() const { return helmVisible_; }
bool isCloakVisible() const { return cloakVisible_; }
// Follow/Assist
void followTarget();
@ -388,6 +406,9 @@ public:
// PvP
void togglePvp();
// Minimap ping (Ctrl+click on minimap; wowX/wowY in canonical WoW coords)
void sendMinimapPing(float wowX, float wowY);
// Guild commands
void requestGuildInfo();
void requestGuildRoster();
@ -403,6 +424,10 @@ public:
void setGuildOfficerNote(const std::string& name, const std::string& note);
void acceptGuildInvite();
void declineGuildInvite();
// GM Ticket
void submitGmTicket(const std::string& text);
void deleteGmTicket();
void queryGuildInfo(uint32_t guildId);
void createGuild(const std::string& guildName);
void addGuildRank(const std::string& rankName);
@ -444,6 +469,8 @@ public:
// AFK/DND status
void toggleAfk(const std::string& message = "");
void toggleDnd(const std::string& message = "");
bool isAfk() const { return afkStatus_; }
bool isDnd() const { return dndStatus_; }
void replyToLastWhisper(const std::string& message);
std::string getLastWhisperSender() const { return lastWhisperSender_; }
void setLastWhisperSender(const std::string& name) { lastWhisperSender_ = name; }
@ -489,6 +516,21 @@ public:
const std::vector<CombatTextEntry>& getCombatText() const { return combatText; }
void updateCombatText(float deltaTime);
// Threat
struct ThreatEntry {
uint64_t victimGuid = 0;
uint32_t threat = 0;
};
// Returns the current threat list for a given unit GUID (from last SMSG_THREAT_UPDATE)
const std::vector<ThreatEntry>* getThreatList(uint64_t unitGuid) const {
auto it = threatLists_.find(unitGuid);
return (it != threatLists_.end()) ? &it->second : nullptr;
}
// Returns the threat list for the player's current target, or nullptr
const std::vector<ThreatEntry>* getTargetThreatList() const {
return targetGuid ? getThreatList(targetGuid) : nullptr;
}
// ---- Phase 3: Spells ----
void castSpell(uint32_t spellId, uint64_t targetGuid = 0);
void cancelCast();
@ -546,6 +588,7 @@ public:
}
bool isCasting() const { return casting; }
bool isChanneling() const { return casting && castIsChannel; }
bool isGameObjectInteractionCasting() const {
return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0;
}
@ -888,6 +931,22 @@ public:
void cancelTalentWipe() { talentWipePending_ = false; }
/** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const;
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
float getCorpseDistance() const {
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f;
float dx = movementInfo.x - corpseX_;
float dy = movementInfo.y - corpseY_;
float dz = movementInfo.z - corpseZ_;
return std::sqrt(dx*dx + dy*dy + dz*dz);
}
/** Corpse position in canonical WoW coords (X=north, Y=west).
* Returns false if no corpse data or on a different map. */
bool getCorpseCanonicalPos(float& outX, float& outY) const {
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return false;
outX = corpseY_; // server Y = canonical X (north)
outY = corpseX_; // server X = canonical Y (west)
return true;
}
/** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */
void reclaimCorpse();
void releaseSpirit();
@ -1008,6 +1067,8 @@ public:
if (raidTargetGuids_[i] == guid) return static_cast<uint8_t>(i);
return 0xFF;
}
// Set or clear a raid mark on a guid (icon 0-7, or 0xFF to clear)
void setRaidMark(uint64_t guid, uint8_t icon);
// ---- LFG / Dungeon Finder ----
enum class LfgState : uint8_t {
@ -1034,6 +1095,22 @@ public:
uint32_t getLfgProposalId() const { return lfgProposalId_; }
int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; }
uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; }
uint32_t getLfgBootVotes() const { return lfgBootVotes_; }
uint32_t getLfgBootTotal() const { return lfgBootTotal_; }
uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; }
uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; }
// ---- Arena Team Stats ----
struct ArenaTeamStats {
uint32_t teamId = 0;
uint32_t rating = 0;
uint32_t weekGames = 0;
uint32_t weekWins = 0;
uint32_t seasonGames = 0;
uint32_t seasonWins = 0;
uint32_t rank = 0;
};
const std::vector<ArenaTeamStats>& getArenaTeamStats() const { return arenaTeamStats_; }
// ---- Phase 5: Loot ----
void lootTarget(uint64_t guid);
@ -1131,6 +1208,10 @@ public:
uint32_t required = 0;
};
std::array<ItemObjective, 6> itemObjectives{}; // zeroed by default
// Reward data parsed from SMSG_QUEST_QUERY_RESPONSE
int32_t rewardMoney = 0; // copper; positive=reward, negative=cost
std::array<QuestRewardItem, 4> rewardItems{}; // guaranteed reward items
std::array<QuestRewardItem, 6> rewardChoiceItems{}; // player picks one of these
};
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
void abandonQuest(uint32_t questId);
@ -1215,6 +1296,14 @@ public:
using AchievementEarnedCallback = std::function<void(uint32_t achievementId, const std::string& name)>;
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
const std::unordered_map<uint32_t, uint64_t>& getCriteriaProgress() const { return criteriaProgress_; }
/// Returns the name of an achievement by ID, or empty string if unknown.
const std::string& getAchievementName(uint32_t id) const {
auto it = achievementNameCache_.find(id);
if (it != achievementNameCache_.end()) return it->second;
static const std::string kEmpty;
return kEmpty;
}
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
@ -1232,6 +1321,15 @@ public:
using PlayPositionalSoundCallback = std::function<void(uint32_t soundId, uint64_t sourceGuid)>;
void setPlayPositionalSoundCallback(PlayPositionalSoundCallback cb) { playPositionalSoundCallback_ = std::move(cb); }
// UI error frame: prominent on-screen error messages (spell can't be cast, etc.)
using UIErrorCallback = std::function<void(const std::string& msg)>;
void setUIErrorCallback(UIErrorCallback cb) { uiErrorCallback_ = std::move(cb); }
void addUIError(const std::string& msg) { if (uiErrorCallback_) uiErrorCallback_(msg); }
// Reputation change toast: factionName, delta, new standing
using RepChangeCallback = std::function<void(const std::string& factionName, int32_t delta, int32_t standing)>;
void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); }
// Mount state
using MountCallback = std::function<void(uint32_t mountDisplayId)>; // 0 = dismount
void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); }
@ -1719,6 +1817,7 @@ private:
void handleArenaTeamQueryResponse(network::Packet& packet);
void handleArenaTeamInvite(network::Packet& packet);
void handleArenaTeamEvent(network::Packet& packet);
void handleArenaTeamStats(network::Packet& packet);
void handleArenaError(network::Packet& packet);
// ---- Bank handlers ----
@ -1951,6 +2050,7 @@ private:
// Inspect fallback (when visible item fields are missing/unreliable)
std::unordered_map<uint64_t, std::array<uint32_t, 19>> inspectedPlayerItemEntries_;
InspectResult inspectResult_; // most-recently received inspect response
std::unordered_set<uint64_t> pendingAutoInspect_;
float inspectRateLimit_ = 0.0f;
@ -1965,6 +2065,8 @@ private:
float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing
std::unordered_set<uint64_t> hostileAttackers_;
std::vector<CombatTextEntry> combatText;
// unitGuid → sorted threat list (descending by threat value)
std::unordered_map<uint64_t, std::vector<ThreatEntry>> threatLists_;
// ---- Phase 3: Spells ----
WorldEntryCallback worldEntryCallback_;
@ -2010,6 +2112,7 @@ private:
std::vector<MinimapPing> minimapPings_;
uint8_t castCount = 0;
bool casting = false;
bool castIsChannel = false;
uint32_t currentCastSpellId = 0;
float castTimeRemaining = 0.0f;
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
@ -2071,6 +2174,9 @@ private:
// Instance / raid lockouts
std::vector<InstanceLockout> instanceLockouts_;
// Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS)
std::vector<ArenaTeamStats> arenaTeamStats_;
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot
@ -2080,6 +2186,10 @@ private:
uint32_t lfgProposalId_ = 0; // pending proposal id (0 = none)
int32_t lfgAvgWaitSec_ = -1; // estimated wait, -1=unknown
uint32_t lfgTimeInQueueMs_= 0; // ms already in queue
uint32_t lfgBootVotes_ = 0; // current boot-yes votes
uint32_t lfgBootTotal_ = 0; // total votes cast
uint32_t lfgBootTimeLeft_ = 0; // seconds remaining
uint32_t lfgBootNeeded_ = 0; // votes needed to kick
// Ready check state
bool pendingReadyCheck_ = false;
@ -2116,6 +2226,8 @@ private:
uint64_t summonerGuid_ = 0;
std::string summonerName_;
float summonTimeoutSec_ = 0.0f;
uint32_t totalTimePlayed_ = 0;
uint32_t levelTimePlayed_ = 0;
// Trade state
TradeStatus tradeStatus_ = TradeStatus::None;
@ -2332,6 +2444,8 @@ private:
void loadAchievementNameCache();
// Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA)
std::unordered_set<uint32_t> earnedAchievements_;
// Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE)
std::unordered_map<uint32_t, uint64_t> criteriaProgress_;
void handleAllAchievementData(network::Packet& packet);
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
@ -2510,6 +2624,12 @@ private:
PlayMusicCallback playMusicCallback_;
PlaySoundCallback playSoundCallback_;
PlayPositionalSoundCallback playPositionalSoundCallback_;
// ---- UI error frame callback ----
UIErrorCallback uiErrorCallback_;
// ---- Reputation change callback ----
RepChangeCallback repChangeCallback_;
};
} // namespace game

View file

@ -50,6 +50,10 @@ private:
int lastChatType = 0; // Track chat type changes
bool chatInputMoveCursorToEnd = false;
// Chat sent-message history (Up/Down arrow recall)
std::vector<std::string> chatSentHistory_;
int chatHistoryIdx_ = -1; // -1 = not browsing history
// Chat tabs
int activeChatTab_ = 0;
struct ChatTab {
@ -65,6 +69,37 @@ private:
bool showChatWindow = true;
bool showMinimap_ = true; // M key toggles minimap
bool showNameplates_ = true; // V key toggles nameplates
float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions
uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none)
ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click
uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection
float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0)
bool damageFlashEnabled_ = true;
float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0)
uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text
// Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s)
struct RaidWarnEntry {
std::string text;
float age = 0.0f;
bool isBossEmote = false; // true = amber, false (raid warning) = red+yellow
static constexpr float LIFETIME = 5.0f;
};
std::vector<RaidWarnEntry> raidWarnEntries_;
bool raidWarnCallbackSet_ = false;
size_t raidWarnChatSeenCount_ = 0; // index into chat history for unread scan
// UIErrorsFrame: WoW-style center-bottom error messages (spell fails, out of range, etc.)
struct UIErrorEntry { std::string text; float age = 0.0f; };
std::vector<UIErrorEntry> uiErrors_;
bool uiErrorCallbackSet_ = false;
static constexpr float kUIErrorLifetime = 2.5f;
// Reputation change toast: brief colored slide-in below minimap
struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; };
std::vector<RepToastEntry> repToasts_;
bool repChangeCallbackSet_ = false;
static constexpr float kRepToastLifetime = 3.5f;
bool showPlayerInfo = false;
bool showSocialFrame_ = false; // O key toggles social/friends list
bool showGuildRoster_ = false;
@ -82,6 +117,8 @@ private:
bool showAddRankModal_ = false;
bool refocusChatInput = false;
bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session
bool chatScrolledUp_ = false; // true when user has scrolled above the latest messages
bool chatForceScrollToBottom_ = false; // set to true to jump to bottom next frame
bool chatWindowLocked = true;
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
bool chatWindowPosInit_ = false;
@ -109,6 +146,7 @@ private:
float pendingMouseSensitivity = 0.2f;
bool pendingInvertMouse = false;
bool pendingExtendedZoom = false;
float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV
int pendingUiOpacity = 65;
bool pendingMinimapRotate = false;
bool pendingMinimapSquare = false;
@ -122,6 +160,7 @@ private:
bool awaitingKeyPress = false;
bool pendingUseOriginalSoundtrack = true;
bool pendingShowActionBar2 = true; // Show second action bar above main bar
float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.51.5)
float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position
float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1)
bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35)
@ -239,8 +278,11 @@ private:
void renderCastBar(game::GameHandler& gameHandler);
void renderMirrorTimers(game::GameHandler& gameHandler);
void renderCombatText(game::GameHandler& gameHandler);
void renderRaidWarningOverlay(game::GameHandler& gameHandler);
void renderPartyFrames(game::GameHandler& gameHandler);
void renderBossFrames(game::GameHandler& gameHandler);
void renderUIErrors(game::GameHandler& gameHandler, float deltaTime);
void renderRepToasts(float deltaTime);
void renderGroupInvitePopup(game::GameHandler& gameHandler);
void renderDuelRequestPopup(game::GameHandler& gameHandler);
void renderLootRollPopup(game::GameHandler& gameHandler);
@ -282,9 +324,11 @@ private:
void renderGuildBankWindow(game::GameHandler& gameHandler);
void renderAuctionHouseWindow(game::GameHandler& gameHandler);
void renderDungeonFinderWindow(game::GameHandler& gameHandler);
void renderObjectiveTracker(game::GameHandler& gameHandler);
void renderInstanceLockouts(game::GameHandler& gameHandler);
void renderNameplates(game::GameHandler& gameHandler);
void renderBattlegroundScore(game::GameHandler& gameHandler);
void renderDPSMeter(game::GameHandler& gameHandler);
/**
* Inventory screen
@ -325,6 +369,24 @@ private:
// Dungeon Finder state
bool showDungeonFinder_ = false;
// Achievements window
bool showAchievementWindow_ = false;
char achievementSearchBuf_[128] = {};
void renderAchievementWindow(game::GameHandler& gameHandler);
// GM Ticket window
bool showGmTicketWindow_ = false;
char gmTicketBuf_[2048] = {};
void renderGmTicketWindow(game::GameHandler& gameHandler);
// Inspect window
bool showInspectWindow_ = false;
void renderInspectWindow(game::GameHandler& gameHandler);
// Threat window
bool showThreatWindow_ = false;
void renderThreatWindow(game::GameHandler& gameHandler);
uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps)
uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK)
@ -355,6 +417,8 @@ private:
};
std::vector<ChatBubble> chatBubbles_;
bool chatBubbleCallbackSet_ = false;
bool levelUpCallbackSet_ = false;
bool achievementCallbackSet_ = false;
// Mail compose state
char mailRecipientBuffer_[256] = "";
@ -362,6 +426,12 @@ private:
char mailBodyBuffer_[2048] = "";
int mailComposeMoney_[3] = {0, 0, 0}; // gold, silver, copper
// Vendor search filter
char vendorSearchFilter_[128] = "";
// Trainer search filter
char trainerSearchFilter_[128] = "";
// Auction house UI state
char auctionSearchName_[256] = "";
int auctionLevelMin_ = 0;
@ -403,6 +473,11 @@ private:
std::string lastKnownZoneName_;
void renderZoneText();
// DPS / HPS meter
bool showDPSMeter_ = false;
float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS)
bool dpsWasInCombat_ = false;
public:
void triggerDing(uint32_t newLevel);
void triggerAchievementToast(uint32_t achievementId, std::string name = {});

View file

@ -171,11 +171,21 @@ private:
void renderHeldItem();
bool bagHasAnyItems(const game::Inventory& inventory, int bagIndex) const;
// Drop confirmation
// Drop confirmation (drag-outside-window destroy)
bool dropConfirmOpen_ = false;
int dropBackpackIndex_ = -1;
std::string dropItemName_;
// Destroy confirmation (Shift+right-click destroy)
bool destroyConfirmOpen_ = false;
uint8_t destroyBag_ = 0xFF;
uint8_t destroySlot_ = 0;
uint8_t destroyCount_ = 1;
std::string destroyItemName_;
// Pending chat item link from shift-click
std::string pendingChatItemLink_;
public:
static ImVec4 getQualityColor(game::ItemQuality quality);
@ -190,6 +200,13 @@ public:
/// Drop the currently held item into a specific equipment slot.
/// Returns true if the drop was accepted and consumed.
bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot);
/// Returns a WoW item link string if the user shift-clicked a bag item, then clears it.
std::string getAndClearPendingChatLink() {
std::string out = std::move(pendingChatItemLink_);
pendingChatItemLink_.clear();
return out;
}
/// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM.
void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot);
/// Pick up an item from main bank slot (click-and-hold from bank window).

View file

@ -30,6 +30,7 @@ public:
TOGGLE_NAMEPLATES,
TOGGLE_RAID_FRAMES,
TOGGLE_QUEST_LOG,
TOGGLE_ACHIEVEMENTS,
ACTION_COUNT
};

View file

@ -7,9 +7,11 @@
namespace wowee { namespace ui {
class InventoryScreen;
class QuestLogScreen {
public:
void render(game::GameHandler& gameHandler);
void render(game::GameHandler& gameHandler, InventoryScreen& invScreen);
bool isOpen() const { return open; }
void toggle() { open = !open; }
void setOpen(bool o) { open = o; }
@ -29,6 +31,10 @@ private:
uint32_t lastDetailRequestQuestId_ = 0;
double lastDetailRequestAt_ = 0.0;
std::unordered_set<uint32_t> questDetailQueryNoResponse_;
// Search / filter
char questSearchFilter_[64] = {};
// 0=all, 1=active only, 2=complete only
int questFilterMode_ = 0;
};
}} // namespace wowee::ui

View file

@ -54,6 +54,13 @@ public:
uint32_t getDragSpellId() const { return dragSpellId_; }
void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; }
/// Returns a WoW spell link string if the user shift-clicked a spell, then clears it.
std::string getAndClearPendingChatLink() {
std::string out = std::move(pendingChatSpellLink_);
pendingChatSpellLink_.clear();
return out;
}
private:
bool open = false;
bool pKeyWasDown = false;
@ -87,6 +94,9 @@ private:
uint32_t dragSpellId_ = 0;
VkDescriptorSet dragSpellIconTex_ = VK_NULL_HANDLE;
// Pending chat spell link from shift-click
std::string pendingChatSpellLink_;
void loadSpellDBC(pipeline::AssetManager* assetManager);
void loadSpellIconDBC(pipeline::AssetManager* assetManager);
void loadSkillLineDBCs(pipeline::AssetManager* assetManager);

View file

@ -438,6 +438,49 @@ QuestQueryObjectives extractQuestQueryObjectives(const std::vector<uint8_t>& dat
}
}
// Parse quest reward fields from SMSG_QUEST_QUERY_RESPONSE fixed header.
// Classic/TBC: 40 fixed fields; WotLK: 55 fixed fields.
struct QuestQueryRewards {
int32_t rewardMoney = 0;
std::array<uint32_t, 4> itemId{};
std::array<uint32_t, 4> itemCount{};
std::array<uint32_t, 6> choiceItemId{};
std::array<uint32_t, 6> choiceItemCount{};
bool valid = false;
};
static QuestQueryRewards tryParseQuestRewards(const std::vector<uint8_t>& data,
bool classicLayout) {
const size_t base = 8; // after questId(4) + questMethod(4)
const size_t fieldCount = classicLayout ? 40u : 55u;
const size_t headerEnd = base + fieldCount * 4u;
if (data.size() < headerEnd) return {};
// Field indices (0-based) for each expansion:
// Classic/TBC: rewardMoney=[14], rewardItemId[4]=[20..23], rewardItemCount[4]=[24..27],
// rewardChoiceItemId[6]=[28..33], rewardChoiceItemCount[6]=[34..39]
// WotLK: rewardMoney=[17], rewardItemId[4]=[30..33], rewardItemCount[4]=[34..37],
// rewardChoiceItemId[6]=[38..43], rewardChoiceItemCount[6]=[44..49]
const size_t moneyField = classicLayout ? 14u : 17u;
const size_t itemIdField = classicLayout ? 20u : 30u;
const size_t itemCountField = classicLayout ? 24u : 34u;
const size_t choiceIdField = classicLayout ? 28u : 38u;
const size_t choiceCntField = classicLayout ? 34u : 44u;
QuestQueryRewards out;
out.rewardMoney = static_cast<int32_t>(readU32At(data, base + moneyField * 4u));
for (size_t i = 0; i < 4; ++i) {
out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u);
out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u);
}
for (size_t i = 0; i < 6; ++i) {
out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u);
out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u);
}
out.valid = true;
return out;
}
} // namespace
@ -861,8 +904,10 @@ void GameHandler::update(float deltaTime) {
(autoAttacking || autoAttackRequested_)) {
pendingGameObjectInteractGuid_ = 0;
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
addUIError("Interrupted.");
addSystemChatMessage("Interrupted.");
}
if (casting && castTimeRemaining > 0.0f) {
@ -874,6 +919,7 @@ void GameHandler::update(float deltaTime) {
performGameObjectInteractionNow(interactGuid);
}
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
@ -1079,6 +1125,7 @@ void GameHandler::update(float deltaTime) {
autoAttackOutOfRangeTime_ += deltaTime;
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
addSystemChatMessage("Target is too far away.");
addUIError("Target is too far away.");
autoAttackRangeWarnCooldown_ = 1.25f;
}
// Stop chasing stale swings when the target remains out of range.
@ -1904,6 +1951,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) {
if (castResult != 0) {
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
@ -1913,11 +1961,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
playerPowerType = static_cast<int>(pu->getPowerType());
}
const char* reason = getSpellCastResultString(castResult, playerPowerType);
std::string errMsg = reason ? reason
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
addUIError(errMsg);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = reason ? reason
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
msg.message = errMsg;
addLocalChatMessage(msg);
}
}
@ -2163,9 +2213,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_FORCE_ANIM: {
// packed_guid + uint32 animId — force entity to play animation
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t animId =*/ packet.readUInt32();
uint32_t animId = packet.readUInt32();
if (emoteAnimCallback_)
emoteAnimCallback_(animGuid, animId);
}
}
break;
@ -2226,6 +2278,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
break;
case Opcode::SMSG_FEIGN_DEATH_RESISTED:
addUIError("Your Feign Death was resisted.");
addSystemChatMessage("Your Feign Death attempt was resisted.");
LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED");
break;
@ -2278,24 +2331,51 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
case Opcode::SMSG_THREAT_CLEAR:
// All threat dropped on the local player (e.g. Vanish, Feign Death)
// No local state to clear — informational
threatLists_.clear();
LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped");
break;
case Opcode::SMSG_THREAT_REMOVE: {
// packed_guid (unit) + packed_guid (victim whose threat was removed)
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
}
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet);
auto it = threatLists_.find(unitGuid);
if (it != threatLists_.end()) {
auto& list = it->second;
list.erase(std::remove_if(list.begin(), list.end(),
[victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }),
list.end());
if (list.empty()) threatLists_.erase(it);
}
break;
}
case Opcode::SMSG_HIGHEST_THREAT_UPDATE: {
// packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count
// + count × (packed_guid victim + uint32 threat)
// Informational — no threat UI yet; consume to suppress warnings
packet.setReadPos(packet.getSize());
case Opcode::SMSG_HIGHEST_THREAT_UPDATE:
case Opcode::SMSG_THREAT_UPDATE: {
// Both packets share the same format:
// packed_guid (unit) + packed_guid (highest-threat target or target, unused here)
// + uint32 count + count × (packed_guid victim + uint32 threat)
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 1) break;
(void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target
if (packet.getSize() - packet.getReadPos() < 4) break;
uint32_t cnt = packet.readUInt32();
if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity
std::vector<ThreatEntry> list;
list.reserve(cnt);
for (uint32_t i = 0; i < cnt; ++i) {
if (packet.getSize() - packet.getReadPos() < 1) break;
ThreatEntry entry;
entry.victimGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break;
entry.threat = packet.readUInt32();
list.push_back(entry);
}
// Sort descending by threat so highest is first
std::sort(list.begin(), list.end(),
[](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; });
threatLists_[unitGuid] = std::move(list);
break;
}
@ -2776,13 +2856,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
// Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table
uint8_t failReason = isClassic ? static_cast<uint8_t>(rawFailReason + 1) : rawFailReason;
if (failGuid == playerGuid && failReason != 0) {
// Show interruption/failure reason in chat for player
// Show interruption/failure reason in chat and error overlay for player
int pt = -1;
if (auto pe = entityManager.getEntity(playerGuid))
if (auto pu = std::dynamic_pointer_cast<Unit>(pe))
pt = static_cast<int>(pu->getPowerType());
const char* reason = getSpellCastResultString(failReason, pt);
if (reason) {
addUIError(reason);
MessageChatData emsg;
emsg.type = ChatType::SYSTEM;
emsg.language = ChatLanguage::UNIVERSAL;
@ -2794,6 +2875,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (failGuid == playerGuid || failGuid == 0) {
// Player's own cast failed
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
@ -2862,18 +2944,26 @@ void GameHandler::handlePacket(network::Packet& packet) {
// uint64 itemGuid + uint32 spellId + uint32 cooldownMs
size_t rem = packet.getSize() - packet.getReadPos();
if (rem >= 16) {
/*uint64_t itemGuid =*/ packet.readUInt64();
uint32_t spellId = packet.readUInt32();
uint32_t cdMs = packet.readUInt32();
uint64_t itemGuid = packet.readUInt64();
uint32_t spellId = packet.readUInt32();
uint32_t cdMs = packet.readUInt32();
float cdSec = cdMs / 1000.0f;
if (spellId != 0 && cdSec > 0.0f) {
spellCooldowns[spellId] = cdSec;
if (cdSec > 0.0f) {
if (spellId != 0) spellCooldowns[spellId] = cdSec;
// Resolve itemId from the GUID so item-type slots are also updated
uint32_t itemId = 0;
auto iit = onlineItems_.find(itemGuid);
if (iit != onlineItems_.end()) itemId = iit->second.entry;
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|| (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId);
if (match) {
slot.cooldownTotal = cdSec;
slot.cooldownRemaining = cdSec;
}
}
LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s");
LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec,
" spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s");
}
}
break;
@ -3116,6 +3206,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleDuelWinner(packet);
break;
case Opcode::SMSG_DUEL_OUTOFBOUNDS:
addUIError("You are out of the duel area!");
addSystemChatMessage("You are out of the duel area!");
break;
case Opcode::SMSG_DUEL_INBOUNDS:
@ -3453,6 +3544,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
delta > 0 ? "increased" : "decreased",
std::abs(delta));
addSystemChatMessage(buf);
if (repChangeCallback_) repChangeCallback_(name, delta, standing);
}
LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing);
}
@ -3728,11 +3820,22 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
case Opcode::SMSG_SERVER_MESSAGE: {
// uint32 type, string message
// uint32 type + string message
// Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t msgType =*/ packet.readUInt32();
uint32_t msgType = packet.readUInt32();
std::string msg = packet.readString();
if (!msg.empty()) addSystemChatMessage("[Server] " + msg);
if (!msg.empty()) {
std::string prefix;
switch (msgType) {
case 1: prefix = "[Shutdown] "; addUIError("Server shutdown: " + msg); break;
case 2: prefix = "[Restart] "; addUIError("Server restart: " + msg); break;
case 4: prefix = "[Shutdown cancelled] "; break;
case 5: prefix = "[Restart cancelled] "; break;
default: prefix = "[Server] "; break;
}
addSystemChatMessage(prefix + msg);
}
}
break;
}
@ -4050,12 +4153,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
case Opcode::SMSG_CRITERIA_UPDATE: {
// uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime
// Achievement criteria progress (informational — no criteria UI yet).
if (packet.getSize() - packet.getReadPos() >= 20) {
uint32_t criteriaId = packet.readUInt32();
uint64_t progress = packet.readUInt64();
/*uint32_t elapsedTime =*/ packet.readUInt32();
/*uint32_t createTime =*/ packet.readUInt32();
packet.readUInt32(); // elapsedTime
packet.readUInt32(); // creationTime
criteriaProgress_[criteriaId] = progress;
LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress);
}
break;
@ -4528,6 +4631,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4;
const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout);
const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout);
const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout);
for (auto& q : questLog_) {
if (q.questId != questId) continue;
@ -4583,6 +4687,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ",
objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]");
}
// Store reward data and pre-fetch item info for icons.
if (rwds.valid) {
q.rewardMoney = rwds.rewardMoney;
for (int i = 0; i < 4; ++i) {
q.rewardItems[i].itemId = rwds.itemId[i];
q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0;
if (rwds.itemId[i] != 0) queryItemInfo(rwds.itemId[i], 0);
}
for (int i = 0; i < 6; ++i) {
q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i];
q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0;
if (rwds.choiceItemId[i] != 0) queryItemInfo(rwds.choiceItemId[i], 0);
}
}
break;
}
@ -4832,7 +4951,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleArenaTeamEvent(packet);
break;
case Opcode::SMSG_ARENA_TEAM_STATS:
LOG_INFO("Received SMSG_ARENA_TEAM_STATS");
handleArenaTeamStats(packet);
break;
case Opcode::SMSG_ARENA_ERROR:
handleArenaError(packet);
@ -4909,10 +5028,34 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::MSG_QUERY_NEXT_MAIL_TIME:
handleQueryNextMailTime(packet);
break;
case Opcode::SMSG_CHANNEL_LIST:
// Channel member listing currently not rendered in UI.
packet.setReadPos(packet.getSize());
case Opcode::SMSG_CHANNEL_LIST: {
// string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags)
std::string chanName = packet.readString();
if (packet.getSize() - packet.getReadPos() < 5) break;
/*uint8_t chanFlags =*/ packet.readUInt8();
uint32_t memberCount = packet.readUInt32();
memberCount = std::min(memberCount, 200u);
addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):");
for (uint32_t i = 0; i < memberCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 9) break;
uint64_t memberGuid = packet.readUInt64();
uint8_t memberFlags = packet.readUInt8();
// Look up the name from our entity manager
auto entity = entityManager.getEntity(memberGuid);
std::string name = "(unknown)";
if (entity) {
auto player = std::dynamic_pointer_cast<Player>(entity);
if (player && !player->getName().empty()) name = player->getName();
}
std::string entry = " " + name;
if (memberFlags & 0x01) entry += " [Moderator]";
if (memberFlags & 0x02) entry += " [Muted]";
addSystemChatMessage(entry);
LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec,
" flags=", (int)memberFlags, " name=", name);
}
break;
}
case Opcode::SMSG_INSPECT_RESULTS_UPDATE:
handleInspectResults(packet);
break;
@ -5468,6 +5611,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (totalMs > 0) {
if (caster == playerGuid) {
casting = true;
castIsChannel = false;
currentCastSpellId = spellId;
castTimeTotal = totalMs / 1000.0f;
castTimeRemaining = remainMs / 1000.0f;
@ -5496,6 +5640,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (chanTotalMs > 0 && chanCaster != 0) {
if (chanCaster == playerGuid) {
casting = true;
castIsChannel = true;
currentCastSpellId = chanSpellId;
castTimeTotal = chanTotalMs / 1000.0f;
castTimeRemaining = castTimeTotal;
@ -5523,6 +5668,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
castTimeRemaining = chanRemainMs / 1000.0f;
if (chanRemainMs == 0) {
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
}
} else if (chanCaster2 != 0) {
@ -5537,22 +5683,6 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_THREAT_UPDATE: {
// packed_guid (unit) + packed_guid (target) + uint32 count
// + count × (packed_guid victim + uint32 threat) — consume to suppress warnings
if (packet.getSize() - packet.getReadPos() < 1) break;
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 1) break;
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break;
uint32_t cnt = packet.readUInt32();
for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) {
(void)UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() >= 4)
packet.readUInt32();
}
break;
}
case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: {
// uint32 slot + packed_guid unit (0 packed = clear slot)
if (packet.getSize() - packet.getReadPos() < 5) {
@ -6030,10 +6160,33 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA:
case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER:
case Opcode::SMSG_PROFILEDATA_RESPONSE:
case Opcode::SMSG_PLAY_TIME_WARNING:
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_PLAY_TIME_WARNING: {
// uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t warnType = packet.readUInt32();
uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4)
? packet.readUInt32() : 0;
const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] ";
char buf[128];
if (minutesPlayed > 0) {
uint32_t h = minutesPlayed / 60;
uint32_t m = minutesPlayed % 60;
if (h > 0)
std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m);
else
std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m);
} else {
std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity);
}
addSystemChatMessage(buf);
addUIError(buf);
}
break;
}
// ---- Item query multiple (same format as single, re-use handler) ----
case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE:
handleItemQueryResponse(packet);
@ -6525,6 +6678,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
autoAttacking = false;
autoAttackTarget = 0;
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
pendingGameObjectInteractGuid_ = 0;
castTimeRemaining = 0.0f;
@ -7650,6 +7804,29 @@ void GameHandler::sendPing() {
socket->send(packet);
}
void GameHandler::sendMinimapPing(float wowX, float wowY) {
if (state != WorldState::IN_WORLD) return;
// MSG_MINIMAP_PING (CMSG direction): float posX + float posY
// Server convention: posX = east/west axis = canonical Y (west)
// posY = north/south axis = canonical X (north)
const float serverX = wowY; // canonical Y (west) → server posX
const float serverY = wowX; // canonical X (north) → server posY
network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING));
pkt.writeFloat(serverX);
pkt.writeFloat(serverY);
socket->send(pkt);
// Add ping locally so the sender sees their own ping immediately
MinimapPing localPing;
localPing.senderGuid = activeCharacterGuid_;
localPing.wowX = wowX;
localPing.wowY = wowY;
localPing.age = 0.0f;
minimapPings_.push_back(localPing);
}
void GameHandler::handlePong(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_PONG");
@ -10463,6 +10640,29 @@ void GameHandler::clearMainAssist() {
LOG_INFO("Cleared main assist");
}
void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) {
if (state != WorldState::IN_WORLD || !socket) return;
static const char* kMarkNames[] = {
"Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull"
};
if (icon == 0xFF) {
// Clear mark: find which slot this guid holds and send 0 GUID
for (int i = 0; i < 8; ++i) {
if (raidTargetGuids_[i] == guid) {
auto packet = RaidTargetUpdatePacket::build(static_cast<uint8_t>(i), 0);
socket->send(packet);
break;
}
}
} else if (icon < 8) {
auto packet = RaidTargetUpdatePacket::build(icon, guid);
socket->send(packet);
LOG_INFO("Set raid mark %s on guid %llu", kMarkNames[icon], (unsigned long long)guid);
}
}
void GameHandler::requestRaidInfo() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot request raid info: not in world or not connected");
@ -10527,6 +10727,7 @@ void GameHandler::stopCasting() {
// Reset casting state
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
pendingGameObjectInteractGuid_ = 0;
castTimeRemaining = 0.0f;
@ -10991,8 +11192,51 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
uint8_t talentType = packet.readUInt8();
if (talentType == 0) {
// Own talent info — silently consume (sent on login, talent changes, respecs)
LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring");
// Own talent info (type 0): uint32 unspentTalents, uint8 groupCount, uint8 activeGroup
// Per group: uint8 talentCount, [talentId(4)+rank(1)]..., uint8 glyphCount, [glyphId(2)]...
if (packet.getSize() - packet.getReadPos() < 6) {
LOG_DEBUG("SMSG_TALENTS_INFO type=0: too short");
return;
}
uint32_t unspentTalents = packet.readUInt32();
uint8_t talentGroupCount = packet.readUInt8();
uint8_t activeTalentGroup = packet.readUInt8();
if (activeTalentGroup > 1) activeTalentGroup = 0;
activeTalentSpec_ = activeTalentGroup;
for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) {
if (packet.getSize() - packet.getReadPos() < 1) break;
uint8_t talentCount = packet.readUInt8();
learnedTalents_[g].clear();
for (uint8_t t = 0; t < talentCount; ++t) {
if (packet.getSize() - packet.getReadPos() < 5) break;
uint32_t talentId = packet.readUInt32();
uint8_t rank = packet.readUInt8();
learnedTalents_[g][talentId] = rank;
}
if (packet.getSize() - packet.getReadPos() < 1) break;
uint8_t glyphCount = packet.readUInt8();
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
if (packet.getSize() - packet.getReadPos() < 2) break;
packet.readUInt16(); // glyphId (skip)
}
}
unspentTalentPoints_[activeTalentGroup] = static_cast<uint8_t>(
unspentTalents > 255 ? 255 : unspentTalents);
if (!talentsInitialized_) {
talentsInitialized_ = true;
if (unspentTalents > 0) {
addSystemChatMessage("You have " + std::to_string(unspentTalents)
+ " unspent talent point" + (unspentTalents != 1 ? "s" : "") + ".");
}
}
LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents,
" groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup,
" learned=", learnedTalents_[activeTalentGroup].size());
return;
}
@ -11068,16 +11312,21 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
}
}
// Display inspect results
std::string msg = "Inspect: " + playerName;
msg += " - " + std::to_string(totalTalents) + " talent points spent";
if (unspentTalents > 0) {
msg += ", " + std::to_string(unspentTalents) + " unspent";
// Store inspect result for UI display
inspectResult_.guid = guid;
inspectResult_.playerName = playerName;
inspectResult_.totalTalents = totalTalents;
inspectResult_.unspentTalents = unspentTalents;
inspectResult_.talentGroups = talentGroupCount;
inspectResult_.activeTalentGroup = activeTalentGroup;
// Merge any gear we already have from a prior inspect request
auto gearIt = inspectedPlayerItemEntries_.find(guid);
if (gearIt != inspectedPlayerItemEntries_.end()) {
inspectResult_.itemEntries = gearIt->second;
} else {
inspectResult_.itemEntries = {};
}
if (talentGroupCount > 1) {
msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")";
}
addSystemChatMessage(msg);
LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ",
unspentTalents, " unspent, ", (int)talentGroupCount, " specs");
@ -12805,15 +13054,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) {
uint32_t timeLeft = packet.readUInt32();
uint32_t votesNeeded = packet.readUInt32();
(void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded;
(void)myVote;
lfgBootVotes_ = bootVotes;
lfgBootTotal_ = totalVotes;
lfgBootTimeLeft_ = timeLeft;
lfgBootNeeded_ = votesNeeded;
if (inProgress) {
lfgState_ = LfgState::Boot;
addSystemChatMessage(
std::string("Dungeon Finder: Vote to kick in progress (") +
std::to_string(timeLeft) + "s remaining).");
} else {
// Boot vote ended — return to InDungeon state regardless of outcome
lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0;
lfgState_ = LfgState::InDungeon;
if (myAnswer) {
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
@ -13083,6 +13335,35 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) {
LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2);
}
void GameHandler::handleArenaTeamStats(network::Packet& packet) {
// SMSG_ARENA_TEAM_STATS (WotLK 3.3.5a):
// uint32 teamId, uint32 rating, uint32 weekGames, uint32 weekWins,
// uint32 seasonGames, uint32 seasonWins, uint32 rank
if (packet.getSize() - packet.getReadPos() < 28) return;
ArenaTeamStats stats;
stats.teamId = packet.readUInt32();
stats.rating = packet.readUInt32();
stats.weekGames = packet.readUInt32();
stats.weekWins = packet.readUInt32();
stats.seasonGames = packet.readUInt32();
stats.seasonWins = packet.readUInt32();
stats.rank = packet.readUInt32();
// Update or insert for this team
for (auto& s : arenaTeamStats_) {
if (s.teamId == stats.teamId) {
s = stats;
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
" rating=", stats.rating, " rank=", stats.rank);
return;
}
}
arenaTeamStats_.push_back(stats);
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
" rating=", stats.rating, " rank=", stats.rank);
}
void GameHandler::handleArenaError(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t error = packet.readUInt32();
@ -13831,6 +14112,7 @@ void GameHandler::cancelCast() {
}
pendingGameObjectInteractGuid_ = 0;
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
}
@ -13969,6 +14251,7 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
if (!ok) return;
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
@ -14027,6 +14310,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
// If this is the player's own cast, start cast bar
if (data.casterUnit == playerGuid && data.castTime > 0) {
casting = true;
castIsChannel = false;
currentCastSpellId = data.spellId;
castTimeTotal = data.castTime / 1000.0f;
castTimeRemaining = castTimeTotal;
@ -14097,6 +14381,7 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
}
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
@ -14167,14 +14452,17 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) {
const size_t entrySize = isClassicFormat ? 12u : 8u;
while (packet.getSize() - packet.getReadPos() >= entrySize) {
uint32_t spellId = packet.readUInt32();
if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used
uint32_t cdItemId = 0;
if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format
uint32_t cooldownMs = packet.readUInt32();
float seconds = cooldownMs / 1000.0f;
spellCooldowns[spellId] = seconds;
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownTotal = seconds;
bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|| (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId);
if (match) {
slot.cooldownTotal = seconds;
slot.cooldownRemaining = seconds;
}
}
@ -14768,6 +15056,34 @@ void GameHandler::declineGuildInvite() {
LOG_INFO("Declined guild invite");
}
void GameHandler::submitGmTicket(const std::string& text) {
if (state != WorldState::IN_WORLD || !socket) return;
// CMSG_GMTICKET_CREATE (WotLK 3.3.5a):
// string ticket_text
// float[3] position (server coords)
// float facing
// uint32 mapId
// uint8 need_response (1 = yes)
network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE));
pkt.writeString(text);
pkt.writeFloat(movementInfo.x);
pkt.writeFloat(movementInfo.y);
pkt.writeFloat(movementInfo.z);
pkt.writeFloat(movementInfo.orientation);
pkt.writeUInt32(currentMapId_);
pkt.writeUInt8(1); // need_response = yes
socket->send(pkt);
LOG_INFO("Submitted GM ticket: '", text, "'");
}
void GameHandler::deleteGmTicket() {
if (state != WorldState::IN_WORLD || !socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET));
socket->send(pkt);
LOG_INFO("Deleting GM ticket");
}
void GameHandler::queryGuildInfo(uint32_t guildId) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildQueryPacket::build(guildId);
@ -17100,6 +17416,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire
stopAutoAttack();
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
pendingGameObjectInteractGuid_ = 0;
castTimeRemaining = 0.0f;
@ -17875,6 +18192,9 @@ void GameHandler::handlePlayedTime(network::Packet& packet) {
return;
}
totalTimePlayed_ = data.totalTimePlayed;
levelTimePlayed_ = data.levelTimePlayed;
if (data.triggerMessage) {
// Format total time played
uint32_t totalDays = data.totalTimePlayed / 86400;
@ -19806,18 +20126,21 @@ void GameHandler::handleAllAchievementData(network::Packet& packet) {
earnedAchievements_.insert(id);
}
// Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF)
// Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF
criteriaProgress_.clear();
while (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t id = packet.readUInt32();
if (id == 0xFFFFFFFF) break;
// counter(8) + date(4) + unknown(4) = 16 bytes
if (packet.getSize() - packet.getReadPos() < 16) break;
packet.readUInt64(); // counter
uint64_t counter = packet.readUInt64();
packet.readUInt32(); // date
packet.readUInt32(); // unknown / flags
criteriaProgress_[id] = counter;
}
LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements");
LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(),
" achievements, ", criteriaProgress_.size(), " criteria");
}
// ---------------------------------------------------------------------------

View file

@ -752,7 +752,6 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) {
return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0;
};
bool markedAny = false;
if (hasServerExplorationMask) {
exploredZones.clear();
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
@ -761,15 +760,19 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) {
for (uint32_t bit : z.exploreBits) {
if (isBitSet(bit)) {
exploredZones.insert(i);
markedAny = true;
break;
}
}
}
// Always trust the server mask when available — even if empty (unexplored character).
// Also reveal the zone the player is currently standing in so the map isn't pitch-black
// the moment they first enter a new zone (the server bit arrives on the next update).
int curZone = findZoneForPlayer(playerRenderPos);
if (curZone >= 0) exploredZones.insert(curZone);
return;
}
if (markedAny) return;
// Server mask unavailable or empty — fall back to locally-accumulated position tracking.
// Server mask unavailable — fall back to locally-accumulated position tracking.
// Add the zone the player is currently in to the local set and display that.
float wowX = playerRenderPos.y;
float wowY = playerRenderPos.x;

File diff suppressed because it is too large Load diff

View file

@ -101,6 +101,14 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) {
auto it = iconCache_.find(displayInfoId);
if (it != iconCache_.end()) return it->second;
// Rate-limit GPU uploads per frame to avoid stalling when many items appear at once
// (e.g., opening a full bag, vendor window, or loot from a boss with many drops).
static int iiLoadsThisFrame = 0;
static int iiLastImGuiFrame = -1;
int iiCurFrame = ImGui::GetFrameCount();
if (iiCurFrame != iiLastImGuiFrame) { iiLoadsThisFrame = 0; iiLastImGuiFrame = iiCurFrame; }
if (iiLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
// Load ItemDisplayInfo.dbc
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
if (!displayInfoDbc) {
@ -143,6 +151,7 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) {
return VK_NULL_HANDLE;
}
++iiLoadsThisFrame;
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
iconCache_[displayInfoId] = ds;
return ds;
@ -721,6 +730,9 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN, false);
if (characterDown && !cKeyWasDown) {
characterOpen = !characterOpen;
if (characterOpen && gameHandler_) {
gameHandler_->requestPlayedTime();
}
}
cKeyWasDown = characterDown;
@ -825,6 +837,33 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
ImGui::EndPopup();
}
// Shift+right-click destroy confirmation popup
if (destroyConfirmOpen_) {
ImVec2 mousePos = ImGui::GetIO().MousePos;
ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always);
ImGui::OpenPopup("##DestroyItem");
destroyConfirmOpen_ = false;
}
if (ImGui::BeginPopup("##DestroyItem", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Destroy");
ImGui::TextUnformatted(destroyItemName_.c_str());
ImGui::Spacing();
if (ImGui::Button("Yes, Destroy", ImVec2(110, 0))) {
if (gameHandler_) {
gameHandler_->destroyItem(destroyBag_, destroySlot_, destroyCount_);
}
destroyItemName_.clear();
inventoryDirty = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(70, 0))) {
destroyItemName_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Draw held item at cursor
renderHeldItem();
}
@ -1085,6 +1124,18 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
if (ImGui::BeginTabBar("##CharacterTabs")) {
if (ImGui::BeginTabItem("Equipment")) {
renderEquipmentPanel(inventory);
ImGui::Spacing();
ImGui::Separator();
// Appearance visibility toggles
bool helmVis = gameHandler.isHelmVisible();
bool cloakVis = gameHandler.isCloakVisible();
if (ImGui::Checkbox("Show Helm", &helmVis)) {
gameHandler.toggleHelm();
}
ImGui::SameLine();
if (ImGui::Checkbox("Show Cloak", &cloakVis)) {
gameHandler.toggleCloak();
}
ImGui::EndTabItem();
}
@ -1094,6 +1145,30 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i);
const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr;
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats);
// Played time (shown if available, fetched on character screen open)
uint32_t totalSec = gameHandler.getTotalTimePlayed();
uint32_t levelSec = gameHandler.getLevelTimePlayed();
if (totalSec > 0 || levelSec > 0) {
ImGui::Separator();
// Helper lambda to format seconds as "Xd Xh Xm"
auto fmtTime = [](uint32_t sec) -> std::string {
uint32_t d = sec / 86400, h = (sec % 86400) / 3600, m = (sec % 3600) / 60;
char buf[48];
if (d > 0) snprintf(buf, sizeof(buf), "%ud %uh %um", d, h, m);
else if (h > 0) snprintf(buf, sizeof(buf), "%uh %um", h, m);
else snprintf(buf, sizeof(buf), "%um", m);
return buf;
};
ImGui::TextDisabled("Time Played");
ImGui::Columns(2, "##playtime", false);
ImGui::SetColumnWidth(0, 130);
ImGui::Text("Total:"); ImGui::NextColumn();
ImGui::Text("%s", fmtTime(totalSec).c_str()); ImGui::NextColumn();
ImGui::Text("This level:"); ImGui::NextColumn();
ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn();
ImGui::Columns(1);
}
ImGui::EndTabItem();
}
@ -1171,6 +1246,85 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Achievements")) {
const auto& earned = gameHandler.getEarnedAchievements();
if (earned.empty()) {
ImGui::Spacing();
ImGui::TextDisabled("No achievements earned yet.");
} else {
static char achieveFilter[128] = {};
ImGui::SetNextItemWidth(-1.0f);
ImGui::InputTextWithHint("##achsearch", "Search achievements...",
achieveFilter, sizeof(achieveFilter));
ImGui::Separator();
char filterLower[128];
for (size_t i = 0; i < sizeof(achieveFilter); ++i)
filterLower[i] = static_cast<char>(tolower(static_cast<unsigned char>(achieveFilter[i])));
ImGui::BeginChild("##AchList", ImVec2(0, 0), false);
// Sort by ID for stable ordering
std::vector<uint32_t> sortedIds(earned.begin(), earned.end());
std::sort(sortedIds.begin(), sortedIds.end());
int shown = 0;
for (uint32_t id : sortedIds) {
const std::string& name = gameHandler.getAchievementName(id);
const char* displayName = name.empty() ? nullptr : name.c_str();
if (displayName == nullptr) continue; // skip unknown achievements
// Apply filter
if (filterLower[0] != '\0') {
// simple case-insensitive substring match
std::string lower;
lower.reserve(name.size());
for (char c : name) lower += static_cast<char>(tolower(static_cast<unsigned char>(c)));
if (lower.find(filterLower) == std::string::npos) continue;
}
ImGui::PushID(static_cast<int>(id));
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "[Achievement]");
ImGui::SameLine();
ImGui::Text("%s", displayName);
ImGui::PopID();
++shown;
}
if (shown == 0 && filterLower[0] != '\0') {
ImGui::TextDisabled("No achievements match the filter.");
}
ImGui::Text("Total: %d", static_cast<int>(earned.size()));
ImGui::EndChild();
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("PvP")) {
const auto& arenaStats = gameHandler.getArenaTeamStats();
if (arenaStats.empty()) {
ImGui::Spacing();
ImGui::TextDisabled("Not a member of any Arena team.");
} else {
for (const auto& team : arenaStats) {
ImGui::PushID(static_cast<int>(team.teamId));
char header[64];
snprintf(header, sizeof(header), "Team ID %u (Rating: %u)", team.teamId, team.rating);
if (ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Columns(2, "##arenacols", false);
ImGui::Text("Rating:"); ImGui::NextColumn();
ImGui::Text("%u", team.rating); ImGui::NextColumn();
ImGui::Text("Rank:"); ImGui::NextColumn();
ImGui::Text("#%u", team.rank); ImGui::NextColumn();
ImGui::Text("This week:"); ImGui::NextColumn();
ImGui::Text("%u / %u (W/G)", team.weekWins, team.weekGames); ImGui::NextColumn();
ImGui::Text("Season:"); ImGui::NextColumn();
ImGui::Text("%u / %u (W/G)", team.seasonWins, team.seasonGames); ImGui::NextColumn();
ImGui::Columns(1);
}
ImGui::PopID();
}
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
@ -1211,8 +1365,9 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
{ "Exalted", 42000, 42000, ImVec4(1.0f, 0.84f, 0.0f, 1.0f) },
};
constexpr int kNumTiers = static_cast<int>(sizeof(tiers) / sizeof(tiers[0]));
auto getTier = [&](int32_t val) -> const RepTier& {
for (int i = 6; i >= 0; --i) {
for (int i = kNumTiers - 1; i >= 0; --i) {
if (val >= tiers[i].floor) return tiers[i];
}
return tiers[0];
@ -1390,6 +1545,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
int32_t serverArmor, const int32_t* serverStats) {
// Sum equipment stats for item-query bonus display
int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0;
// Secondary stat sums from extraStats
int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0;
int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (slot.empty()) continue;
@ -1398,6 +1556,20 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
itemSta += slot.item.stamina;
itemInt += slot.item.intellect;
itemSpi += slot.item.spirit;
for (const auto& es : slot.item.extraStats) {
switch (es.statType) {
case 16: case 17: case 18: case 31: itemHit += es.statValue; break;
case 19: case 20: case 21: case 32: itemCrit += es.statValue; break;
case 28: case 29: case 30: case 36: itemHaste += es.statValue; break;
case 35: itemResil += es.statValue; break;
case 37: itemExpertise += es.statValue; break;
case 38: case 39: itemAP += es.statValue; break;
case 41: case 42: case 45: itemSP += es.statValue; break;
case 43: itemMp5 += es.statValue; break;
case 46: itemHp5 += es.statValue; break;
default: break;
}
}
}
// Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available.
@ -1456,6 +1628,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
renderStat("Intellect", itemInt);
renderStat("Spirit", itemSpi);
}
// Secondary stats from equipped items
bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste ||
itemResil || itemExpertise || itemMp5 || itemHp5;
if (hasSecondary) {
ImGui::Spacing();
ImGui::Separator();
auto renderSecondary = [&](const char* name, int32_t val) {
if (val > 0) {
ImGui::TextColored(green, "+%d %s", val, name);
}
};
renderSecondary("Attack Power", itemAP);
renderSecondary("Spell Power", itemSP);
renderSecondary("Hit Rating", itemHit);
renderSecondary("Crit Rating", itemCrit);
renderSecondary("Haste Rating", itemHaste);
renderSecondary("Resilience", itemResil);
renderSecondary("Expertise", itemExpertise);
renderSecondary("Mana per 5 sec", itemMp5);
renderSecondary("Health per 5 sec",itemHp5);
}
}
void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {
@ -1683,9 +1877,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
}
// Shift+right-click: open destroy confirmation for non-quest items
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) &&
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) {
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
}
}
// Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use
// Note: InvisibleButton only tracks left-click by default, so use IsItemHovered+IsMouseClicked
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) {
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && !ImGui::GetIO().KeyShift && gameHandler_) {
LOG_WARNING("Right-click slot: kind=", (int)kind,
" backpackIndex=", backpackIndex,
" bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex,
@ -1728,6 +1941,28 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
}
// Shift+left-click: insert item link into chat input
if (ImGui::IsItemHovered() && !holdingItem &&
ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::GetIO().KeyShift &&
item.itemId != 0 && !item.name.empty()) {
// Build WoW item link: |cff<qualHex>|Hitem:<id>:0:0:0:0:0:0:0:0|h[<name>]|h|r
const char* qualHex = "9d9d9d";
switch (item.quality) {
case game::ItemQuality::COMMON: qualHex = "ffffff"; break;
case game::ItemQuality::UNCOMMON: qualHex = "1eff00"; break;
case game::ItemQuality::RARE: qualHex = "0070dd"; break;
case game::ItemQuality::EPIC: qualHex = "a335ee"; break;
case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break;
default: break;
}
char linkBuf[512];
snprintf(linkBuf, sizeof(linkBuf),
"|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r",
qualHex, item.itemId, item.name.c_str());
pendingChatItemLink_ = linkBuf;
}
if (ImGui::IsItemHovered() && !holdingItem) {
// Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise
const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory;
@ -2070,6 +2305,16 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
}
// Destroy hint (not shown for quest items)
if (item.itemId != 0 && item.bindType != 4) {
ImGui::Spacing();
if (ImGui::GetIO().KeyShift) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.45f, 0.9f), "Shift+RClick to destroy");
} else {
ImGui::TextDisabled("Shift+RClick to destroy");
}
}
ImGui::EndTooltip();
}

View file

@ -31,6 +31,7 @@ void KeybindingManager::initializeDefaults() {
bindings_[static_cast<int>(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V;
bindings_[static_cast<int>(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset)
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q;
bindings_[static_cast<int>(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail)
}
bool KeybindingManager::isActionPressed(Action action, bool repeat) {
@ -71,6 +72,7 @@ const char* KeybindingManager::getActionName(Action action) {
case Action::TOGGLE_NAMEPLATES: return "Nameplates";
case Action::TOGGLE_RAID_FRAMES: return "Raid Frames";
case Action::TOGGLE_QUEST_LOG: return "Quest Log";
case Action::TOGGLE_ACHIEVEMENTS: return "Achievements";
case Action::ACTION_COUNT: break;
}
return "Unknown";
@ -135,6 +137,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) {
else if (action == "toggle_nameplates") actionIdx = static_cast<int>(Action::TOGGLE_NAMEPLATES);
else if (action == "toggle_raid_frames") actionIdx = static_cast<int>(Action::TOGGLE_RAID_FRAMES);
else if (action == "toggle_quest_log") actionIdx = static_cast<int>(Action::TOGGLE_QUEST_LOG);
else if (action == "toggle_achievements") actionIdx = static_cast<int>(Action::TOGGLE_ACHIEVEMENTS);
if (actionIdx < 0) continue;
@ -226,6 +229,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const {
{Action::TOGGLE_NAMEPLATES, "toggle_nameplates"},
{Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"},
{Action::TOGGLE_QUEST_LOG, "toggle_quest_log"},
{Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"},
};
for (const auto& [action, nameStr] : actionMap) {

View file

@ -1,4 +1,5 @@
#include "ui/quest_log_screen.hpp"
#include "ui/inventory_screen.hpp"
#include "ui/keybinding_manager.hpp"
#include "core/application.hpp"
#include "core/input.hpp"
@ -206,7 +207,7 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) {
}
} // anonymous namespace
void QuestLogScreen::render(game::GameHandler& gameHandler) {
void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) {
// Quests toggle via keybinding (edge-triggered)
// Customizable key (default: L) from KeybindingManager
bool questsDown = KeybindingManager::getInstance().isActionPressed(
@ -247,6 +248,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
else activeCount++;
}
// Search bar + filter buttons on one row
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 210.0f);
ImGui::InputTextWithHint("##qsearch", "Search quests...", questSearchFilter_, sizeof(questSearchFilter_));
ImGui::SameLine();
if (ImGui::RadioButton("All", questFilterMode_ == 0)) questFilterMode_ = 0;
ImGui::SameLine();
if (ImGui::RadioButton("Active", questFilterMode_ == 1)) questFilterMode_ = 1;
ImGui::SameLine();
if (ImGui::RadioButton("Ready", questFilterMode_ == 2)) questFilterMode_ = 2;
// Summary counts
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount);
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount);
@ -269,14 +281,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
for (size_t i = 0; i < quests.size(); i++) {
if (quests[i].questId == pendingSelectQuestId_) {
selectedIndex = static_cast<int>(i);
// Clear filter so the target quest is visible
questSearchFilter_[0] = '\0';
questFilterMode_ = 0;
break;
}
}
pendingSelectQuestId_ = 0;
}
// Build a case-insensitive lowercase copy of the search filter once
char filterLower[64] = {};
for (size_t fi = 0; fi < sizeof(questSearchFilter_) && questSearchFilter_[fi]; ++fi)
filterLower[fi] = static_cast<char>(std::tolower(static_cast<unsigned char>(questSearchFilter_[fi])));
int visibleQuestCount = 0;
for (size_t i = 0; i < quests.size(); i++) {
const auto& q = quests[i];
// Apply mode filter
if (questFilterMode_ == 1 && q.complete) continue;
if (questFilterMode_ == 2 && !q.complete) continue;
// Apply name search filter
if (filterLower[0]) {
std::string titleLower = cleanQuestTitleForUi(q.title, q.questId);
for (char& c : titleLower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (titleLower.find(filterLower) == std::string::npos) continue;
}
visibleQuestCount++;
ImGui::PushID(static_cast<int>(i));
bool selected = (selectedIndex == static_cast<int>(i));
@ -318,8 +352,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
questDetailQueryNoResponse_.erase(q.questId);
}
}
// Right-click context menu on quest row
if (ImGui::BeginPopupContextItem("QuestRowCtx")) {
selectedIndex = static_cast<int>(i); // select on right-click too
ImGui::TextDisabled("%s", displayTitle.c_str());
ImGui::Separator();
bool tracked = gameHandler.isQuestTracked(q.questId);
if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) {
gameHandler.setQuestTracked(q.questId, !tracked);
}
if (!q.complete) {
ImGui::Separator();
if (ImGui::MenuItem("Abandon Quest")) {
gameHandler.abandonQuest(q.questId);
gameHandler.setQuestTracked(q.questId, false);
selectedIndex = -1;
}
}
ImGui::EndPopup();
}
ImGui::PopID();
}
if (visibleQuestCount == 0) {
ImGui::Spacing();
if (filterLower[0] || questFilterMode_ != 0)
ImGui::TextDisabled("No quests match the filter.");
else
ImGui::TextDisabled("No active quests.");
}
ImGui::EndChild();
ImGui::SameLine();
@ -392,13 +454,98 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
}
for (const auto& [itemId, count] : sel.itemCounts) {
std::string itemLabel = "Item " + std::to_string(itemId);
uint32_t dispId = 0;
if (const auto* info = gameHandler.getItemInfo(itemId)) {
if (!info->name.empty()) itemLabel = info->name;
dispId = info->displayInfoId;
} else {
gameHandler.ensureItemInfo(itemId);
}
uint32_t required = 1;
auto reqIt = sel.requiredItemCounts.find(itemId);
if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second;
ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required);
VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14));
ImGui::SameLine();
ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required);
} else {
ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required);
}
}
}
// Reward summary
bool hasAnyReward = (sel.rewardMoney != 0);
for (const auto& ri : sel.rewardItems) if (ri.itemId) hasAnyReward = true;
for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) hasAnyReward = true;
if (hasAnyReward) {
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "Rewards");
// Money reward
if (sel.rewardMoney > 0) {
uint32_t rg = static_cast<uint32_t>(sel.rewardMoney) / 10000;
uint32_t rs = static_cast<uint32_t>(sel.rewardMoney % 10000) / 100;
uint32_t rc = static_cast<uint32_t>(sel.rewardMoney % 100);
if (rg > 0)
ImGui::Text("%ug %us %uc", rg, rs, rc);
else if (rs > 0)
ImGui::Text("%us %uc", rs, rc);
else
ImGui::Text("%uc", rc);
}
// Guaranteed reward items
bool anyFixed = false;
for (const auto& ri : sel.rewardItems) if (ri.itemId) { anyFixed = true; break; }
if (anyFixed) {
ImGui::TextDisabled("You will receive:");
for (const auto& ri : sel.rewardItems) {
if (!ri.itemId) continue;
std::string name = "Item " + std::to_string(ri.itemId);
uint32_t dispId = 0;
const auto* info = gameHandler.getItemInfo(ri.itemId);
if (info && info->valid) {
if (!info->name.empty()) name = info->name;
dispId = info->displayInfoId;
}
VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
if (icon) {
ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16));
ImGui::SameLine();
}
if (ri.count > 1)
ImGui::Text("%s x%u", name.c_str(), ri.count);
else
ImGui::Text("%s", name.c_str());
}
}
// Choice reward items
bool anyChoice = false;
for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) { anyChoice = true; break; }
if (anyChoice) {
ImGui::TextDisabled("Choose one of:");
for (const auto& ri : sel.rewardChoiceItems) {
if (!ri.itemId) continue;
std::string name = "Item " + std::to_string(ri.itemId);
uint32_t dispId = 0;
const auto* info = gameHandler.getItemInfo(ri.itemId);
if (info && info->valid) {
if (!info->name.empty()) name = info->name;
dispId = info->displayInfoId;
}
VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
if (icon) {
ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16));
ImGui::SameLine();
}
if (ri.count > 1)
ImGui::Text("%s x%u", name.c_str(), ri.count);
else
ImGui::Text("%s", name.c_str());
}
}
}

View file

@ -411,6 +411,14 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa
auto cit = spellIconCache.find(iconId);
if (cit != spellIconCache.end()) return cit->second;
// Rate-limit GPU uploads to avoid a multi-frame stall when switching tabs.
// Icons not loaded this frame will be retried next frame (progressive load).
static int loadsThisFrame = 0;
static int lastImGuiFrame = -1;
int curFrame = ImGui::GetFrameCount();
if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; }
if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
auto pit = spellIconPaths.find(iconId);
if (pit == spellIconPaths.end()) {
spellIconCache[iconId] = VK_NULL_HANDLE;
@ -437,6 +445,7 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa
return VK_NULL_HANDLE;
}
++loadsThisFrame;
VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
spellIconCache[iconId] = ds;
return ds;
@ -657,9 +666,49 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
// Row selectable
ImGui::Selectable("##row", false,
ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight));
ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_DontClosePopups,
ImVec2(0, rowHeight));
bool rowHovered = ImGui::IsItemHovered();
bool rowClicked = ImGui::IsItemClicked(0);
// Right-click context menu
if (ImGui::BeginPopupContextItem("##SpellCtx")) {
ImGui::TextDisabled("%s", info->name.c_str());
if (!info->rank.empty()) {
ImGui::SameLine();
ImGui::TextDisabled("(%s)", info->rank.c_str());
}
ImGui::Separator();
if (!isPassive) {
if (onCooldown) ImGui::BeginDisabled();
if (ImGui::MenuItem("Cast")) {
uint64_t tgt = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0;
gameHandler.castSpell(info->spellId, tgt);
}
if (onCooldown) ImGui::EndDisabled();
}
if (!isPassive) {
if (ImGui::MenuItem("Add to Action Bar")) {
const auto& bar = gameHandler.getActionBar();
int firstEmpty = -1;
for (int si = 0; si < game::GameHandler::SLOTS_PER_BAR; ++si) {
if (bar[si].isEmpty()) { firstEmpty = si; break; }
}
if (firstEmpty >= 0) {
gameHandler.setActionBarSlot(firstEmpty,
game::ActionBarSlot::SPELL, info->spellId);
}
}
}
if (ImGui::MenuItem("Copy Spell Link")) {
char linkBuf[256];
snprintf(linkBuf, sizeof(linkBuf),
"|cffffd000|Hspell:%u|h[%s]|h|r",
info->spellId, info->name.c_str());
pendingChatSpellLink_ = linkBuf;
}
ImGui::EndPopup();
}
ImVec2 rMin = ImGui::GetItemRectMin();
ImVec2 rMax = ImGui::GetItemRectMax();
auto* dl = ImGui::GetWindowDrawList();
@ -748,15 +797,25 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana
// Interaction
if (rowHovered) {
// Start drag on click (not passive)
if (rowClicked && !isPassive) {
// Shift-click to insert spell link into chat
if (rowClicked && ImGui::GetIO().KeyShift && !info->name.empty()) {
// WoW spell link format: |cffffd000|Hspell:<spellId>|h[Name]|h|r
char linkBuf[256];
snprintf(linkBuf, sizeof(linkBuf),
"|cffffd000|Hspell:%u|h[%s]|h|r",
info->spellId, info->name.c_str());
pendingChatSpellLink_ = linkBuf;
}
// Start drag on click (not passive, not shift-click)
else if (rowClicked && !isPassive && !ImGui::GetIO().KeyShift) {
draggingSpell_ = true;
dragSpellId_ = info->spellId;
dragSpellIconTex_ = iconTex;
}
// Double-click to cast
if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) {
if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown
&& !ImGui::GetIO().KeyShift) {
draggingSpell_ = false;
dragSpellId_ = 0;
dragSpellIconTex_ = VK_NULL_HANDLE;

View file

@ -216,7 +216,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
float availW = ImGui::GetContentRegionAvail().x;
float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f);
ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false);
char childId[32];
snprintf(childId, sizeof(childId), "TalentGrid_%u", tabId);
ImGui::BeginChild(childId, ImVec2(0, 0), false);
ImVec2 gridOrigin = ImGui::GetCursorScreenPos();
gridOrigin.x += offsetX;
@ -326,8 +328,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
renderTalent(gameHandler, *talent, pointsInTree);
} else {
// Empty cell — invisible placeholder
ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(),
ImVec2(iconSize, iconSize));
char emptyId[32];
snprintf(emptyId, sizeof(emptyId), "e_%u_%u_%u", tabId, row, col);
ImGui::InvisibleButton(emptyId, ImVec2(iconSize, iconSize));
}
}
}