Compare commits

...

90 commits

Author SHA1 Message Date
Kelsi
e51b215f85 feat: add DISPEL and INTERRUPT combat log entries for dispel/spellsteal/interrupt events
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
2026-03-13 12:03:07 -07:00
Kelsi
ffef3dda7e fix: pass actual GUIDs in SMSG_SPELL_CHANCE_PROC_LOG combat log entries 2026-03-13 11:57:45 -07:00
Kelsi
38111fe8c0 fix: pass actual GUIDs in SMSG_SPELLLOGEXECUTE power drain and health leech combat log entries 2026-03-13 11:55:23 -07:00
Kelsi
d48ead939b fix: pass actual GUIDs in SMSG_SPELLINSTAKILLLOG combat log entries 2026-03-13 11:54:14 -07:00
Kelsi
0982f557d2 fix: pass actual GUIDs for environmental damage and energize combat log entries
SMSG_ENVIRONMENTALDAMAGELOG and SMSG_ENVIRONMENTAL_DAMAGE_LOG now pass
dstGuid=victimGuid with srcGuid=0 (no caster for env damage), ensuring
the combat log shows an empty source name rather than the player's
current target.

SMSG_SPELLENERGIZERLOG now passes casterGuid/victimGuid so the log
correctly attributes mana/energy restoration to the actual caster
rather than the player's current target.
2026-03-13 11:52:31 -07:00
Kelsi
3bdd3f1d3f fix: pass actual GUIDs and spellId to SPELLDAMAGESHIELD and SPELLORDAMAGE_IMMUNE combat log
These handlers already had casterGuid/victimGuid available but were
discarding the packet spellId and not passing GUIDs to addCombatText.
Now the combat log entries show the correct attacker/victim names and
the spell that caused the reflect/immune event.
2026-03-13 11:51:07 -07:00
Kelsi
8213de1d0f fix: pass actual GUIDs to combat log in SPELLLOGMISS and PROCRESIST handlers
SMSG_SPELLLOGMISS and SMSG_PROCRESIST already parsed casterGuid /
victimGuid from the packet but discarded them when calling addCombatText.
Now pass those GUIDs so combat log entries record the actual
attacker/victim names rather than falling back to current target.
2026-03-13 11:50:00 -07:00
Kelsi
d40e8f1618 fix: combat log uses actual attacker/victim GUIDs instead of current target
addCombatText now accepts optional srcGuid/dstGuid parameters. When
provided, the persistent combat log resolves names from the actual
packet GUIDs rather than always falling back to playerGuid/targetGuid.

Updated handleAttackerStateUpdate, handleSpellDamageLog,
handleSpellHealLog, and SMSG_PERIODICAURALOG to pass data.attackerGuid
/ data.targetGuid (or casterGuid/victimGuid), so the combat log
correctly records the attacker name when being hit by enemies the
player has not selected as their current target.

All 48 existing call sites use the 0/0 default and are unaffected.
2026-03-13 11:48:42 -07:00
Kelsi
a05abc8881 feat: include spell names in MISS/DODGE/PARRY/BLOCK/IMMUNE/ABSORB/RESIST combat log entries 2026-03-13 11:39:22 -07:00
Kelsi
04768f41de fix: targetEnemy uses faction isHostile() instead of targeting all non-player units 2026-03-13 11:37:13 -07:00
Kelsi
84d5a1125f feat: show gem sockets and item set bonuses in ItemDef tooltip path 2026-03-13 11:34:48 -07:00
Kelsi
ef7494700e feat: parse and display Heroic/Unique/Unique-Equipped item flags in tooltips 2026-03-13 11:32:32 -07:00
Kelsi
03f8642fad feat: parse and display elemental resistances and race restrictions in item tooltips
- Store holyRes/fireRes/natureRes/frostRes/shadowRes/arcaneRes in ItemQueryResponseData
- Parse resistance fields in WotLK, TBC, and Classic parsers (previously discarded)
- Display non-zero resistances (e.g. "+40 Fire Resistance") in both tooltip paths
- Add getPlayerRace() accessor to GameHandler
- Show race restriction line (e.g. "Races: Blood Elf, Draenei") in both tooltip paths,
  highlighted red when player's race is not allowed
- Useful for fire/nature/frost resist gear (Onyxia, AQ40, Naxx encounters)
2026-03-13 11:23:55 -07:00
Kelsi
0741b4d9e3 feat: show class restriction in item tooltip (e.g. "Classes: Paladin")
Display the allowableClass bitmask parsed from SMSG_ITEM_QUERY as a
human-readable "Classes: X, Y" line. Text is highlighted red when the
player's own class is not in the allowed set. Hidden when all classes
can use the item (no restriction).
2026-03-13 11:15:01 -07:00
Kelsi
6e8704c520 feat: show skill/reputation requirements in ItemDef tooltip path
Extend the inventory item (ItemDef) tooltip to also display
skill and reputation requirements by consulting the item query
cache (ItemQueryResponseData) when available, matching the
behavior already added to the ItemQueryResponseData tooltip path.
2026-03-13 11:13:10 -07:00
Kelsi
b0b47c354a feat: parse and display item skill/reputation requirements in tooltips
- Store requiredSkill, requiredSkillRank, allowableClass, allowableRace,
  requiredReputationFaction, and requiredReputationRank from
  SMSG_ITEM_QUERY_SINGLE_RESPONSE in ItemQueryResponseData (was discarded)
- Show "Requires <Skill> (<rank>)" in item tooltip, highlighted red when
  the player doesn't have sufficient skill level
- Show "Requires <Rank> with <Faction>" for reputation-gated items
- Skill names resolved from SkillLine.dbc; faction names from Faction.dbc
- Also fix loot window tooltip suppressing items with names starting with 'I'
2026-03-13 11:11:33 -07:00
Kelsi
a6c4f6d2e9 feat: show effective skill value with bonus indicator in skills panel
Read the third update field (bonusTemp/bonusPerm) for each skill slot so the
skills tab displays the actual buffed value rather than just the base value.
Skills buffed by food/potions/items now show "value / max (+N)" with a cyan
name, and maxed-out skills show a gold bar and name for quick identification.
2026-03-13 10:58:05 -07:00
Kelsi
c48065473f fix: show specific zone name in hearthstone inventory tooltip
The inventory screen item tooltip showed only the continent name
(Eastern Kingdoms, Kalimdor, etc.) for the hearthstone home location.
Apply the same zone-name lookup already used by the action bar tooltip:
prefer the zone name from homeBindZoneId_ via getWhoAreaName(), falling
back to the continent name if the zone is unavailable.
2026-03-13 10:47:10 -07:00
Kelsi
6bca3dd6c5 fix: correct item spell trigger type labels in tooltips
- Add trigger 4 (soulstone), 5 (no-delay use), 6 (learn/recipe) as "Use:"
  — all show as "Use:" in WoW, matching client behavior
- Fix trigger 6 which was incorrectly labeled "Soulstone" (trigger 4 is
  soulstone; trigger 6 is LEARN_SPELL_ID used by recipe/pattern items)
- Both ItemDef tooltip and ItemSlot inline tooltip are now consistent
2026-03-13 10:36:28 -07:00
Kelsi
fa9017c6dc fix: update homeBindZoneId on SMSG_PLAYERBOUND so hearthstone tooltip stays accurate
SMSG_PLAYERBOUND fires when the player sets a new hearthstone location.
Previously homeBindMapId_ and homeBindZoneId_ were only set by
SMSG_BINDPOINTUPDATE (login), so the tooltip would show the old zone
until next login. Now both are updated on SMSG_PLAYERBOUND as well.
2026-03-13 10:33:44 -07:00
Kelsi
605d046838 fix: extend quality color bounds for Artifact/Heirloom in chat links and loot roll
Handle quality indices 6 (Artifact) and 7 (Heirloom) in all remaining
quality-color lookup tables: chat link hex colors and loot roll tooltip.
2026-03-13 10:30:54 -07:00
Kelsi
8da5e5c029 feat: extend quality colors for Artifact/Heirloom and add guild bank repair
- Add light gold (e6cc80) color for quality 6 (Artifact) and 7 (Heirloom)
  in the loot roll window and loot toast notification displays
- Add "Repair (Guild)" button next to "Repair All" in vendor window when
  player is in a guild, using guild bank funds for the repair cost
2026-03-13 10:29:56 -07:00
Kelsi
bbbc4efced feat: add Heirloom and Artifact item quality tiers with light gold color
Extends ItemQuality enum with ARTIFACT (6) and HEIRLOOM (7) to match
WotLK 3.3.5a quality values, with light gold color (e6cc80) and
display name support in inventory UI and tooltips.
2026-03-13 10:22:34 -07:00
Kelsi
cc24597983 feat: show hearthstone bind zone name in tooltip instead of continent 2026-03-13 10:18:31 -07:00
Kelsi
b03c326bcd feat: show logout countdown overlay with cancel button 2026-03-13 10:13:54 -07:00
Kelsi
792d8e1cf5 feat: show estimated BG wait time in queue indicator 2026-03-13 10:10:04 -07:00
Kelsi
c31ab8c8b6 feat: play error sound when UI error messages are triggered 2026-03-13 10:05:10 -07:00
Kelsi
44ff2dd4ee feat: show rain particles and audio during thunderstorm weather
Storm weather (wType==3 from SMSG_WEATHER) previously rendered no
visual particles and no audio. Map it to RAIN in the weather system so
thunderstorms produce rain particles at the server-sent intensity level,
and the ambient sound manager picks up rain_heavy/medium/light audio
from the same intensity logic already used for plain rain.

This pairs with the lightning commit — storms now have both rain
particles and lightning flashes for a complete thunderstorm experience.
2026-03-13 09:59:58 -07:00
Kelsi
727dfa5c6c feat: integrate lightning system for storm weather and heavy rain
The lightning system (lightning.hpp/cpp) was fully implemented but never
wired into the renderer. Connect it now:
- Enable lightning during server storm weather (wType==3, intensity>0.1)
  and heavy rain (wType==1, intensity>0.7) as a bonus visual
- Scale lightning intensity proportionally to weather intensity
- Render in both parallel (SEC_POST) and fallback rendering paths
- Update and shutdown alongside the weather system
- Show active lightning info in the performance HUD weather section
2026-03-13 09:52:23 -07:00
Kelsi
d58c2f4269 feat: show taxi flight destination indicator below minimap
Stores the destination node name when activateTaxi() is called and
displays a "✈ → <Destination>" indicator in the minimap indicator
stack while isOnTaxiFlight() is true. Falls back to "✈ In Flight"
when the destination name is unavailable.
2026-03-13 09:44:27 -07:00
Kelsi
f5a834b543 feat: add /clear chat command and movement speed display in stats
- /clear slash command empties the chat history (was listed in
  autocomplete but never handled)
- Stats panel shows run/flight/swim speed as percentage of base only
  when non-default (e.g. mounted or speed-buffed), under a new
  Movement section
2026-03-13 09:38:39 -07:00
Kelsi
01ec830555 feat: show calendar pending invites indicator below minimap (WotLK)
Add a pulsing purple "Calendar: N Invite(s)" notification below the
minimap indicator stack when the server reports unacknowledged calendar
invites (SMSG_CALENDAR_SEND_NUM_PENDING / EVENT_INVITE_ALERT).

Only rendered when the WotLK expansion is active since the calendar
system is WotLK-exclusive. Consistent with the existing New Mail, talent
point, BG queue, and LFG queue indicator stack.
2026-03-13 09:25:23 -07:00
Kelsi
fdd6ca30c3 feat: display active player title in gold below the player frame name 2026-03-13 09:10:03 -07:00
Kelsi
736d266c7e feat: add target-of-target display in target frame with click-to-target 2026-03-13 09:06:48 -07:00
Kelsi
dd412af093 feat: show spell/ranged hit and haste ratings separately when they differ from melee 2026-03-13 09:03:13 -07:00
Kelsi
850e4e798d feat: add right-click context menu to reputation rows to set tracked faction 2026-03-13 09:01:30 -07:00
Kelsi
84f9d2e493 feat: color class names by WoW class color in character selection screen 2026-03-13 08:57:44 -07:00
Kelsi
a9f21b2820 feat: show percentage conversions for WotLK combat ratings
Convert raw combat rating values to meaningful percentages using
level-scaled divisors based on known WotLK level-80 constants
(from gtCombatRatings.dbc):

  Hit Rating   : 26.23 per 1% at level 80
  Expertise    : 8.19 per expertise pt (0.25% dodge/parry each)
  Haste        : 32.79 per 1% at level 80
  Armor Pen    : 13.99 per 1% at level 80
  Resilience   : 94.27 per 1% at level 80

Each stat now displays as "Hit Rating: 120 (4.58%)" instead of
just "Hit Rating: 120". The divisor scales by pow(level/80, 0.93)
for characters below level 80.
2026-03-13 08:54:15 -07:00
Kelsi
3e85c78790 feat: show zone and map names in character selection screen
Replace raw zone/map IDs with human-readable names via the existing
getWhoAreaName() and getMapName() DBC caches. The Zone column is also
widened from fixed 55px to a stretch column so names fit properly.
Falls back to numeric IDs gracefully when DBC data is unavailable.
2026-03-13 08:43:11 -07:00
Kelsi
2b79f9d121 feat: add spell power and healing bonus to WotLK character stats
Tracks PLAYER_FIELD_MOD_DAMAGE_DONE_POS (7 schools at field 1171) and
PLAYER_FIELD_MOD_HEALING_DONE_POS (field 1192) from server update fields.

getSpellPower() returns the max damage bonus across magic schools 1-6.
getHealingPower() returns the raw healing bonus.

Both values displayed in the character screen Combat section alongside
the previously added attack power, dodge, parry, crit, and rating fields.
2026-03-13 08:37:55 -07:00
Kelsi
c0ffca68f2 feat: track and display WotLK server-authoritative combat stats
Adds update field tracking for WotLK secondary combat statistics:
- UNIT_FIELD_ATTACK_POWER / RANGED_ATTACK_POWER (fields 123, 126)
- PLAYER_DODGE/PARRY/BLOCK/CRIT_PERCENTAGE (fields 1025-1029)
- PLAYER_RANGED_CRIT_PERCENTAGE, PLAYER_SPELL_CRIT_PERCENTAGE1 (1030, 1032)
- PLAYER_FIELD_COMBAT_RATING_1 (25 slots at 1231, hit/expertise/haste/etc.)

Both CREATE_OBJECT and VALUES update paths now populate these fields.
The Character screen Stats tab shows them when received from the server,
with graceful fallback when not available (Classic/TBC expansions).

Field indices verified against AzerothCore 3.3.5a UpdateFields.h.
2026-03-13 08:35:18 -07:00
Kelsi
ea7b276125 refactor: use gameHandler.getMapName in instance lockout window
Replaces the static local Map.dbc cache in renderInstanceLockoutsWindow()
with the existing GameHandler::getMapName() accessor, eliminating duplicate
DBC loading. Moves getMapName declaration to public interface.
2026-03-13 08:23:43 -07:00
Kelsi
75139aca77 feat: display LFG dungeon name in DungeonFinder UI status banner
Extend the LFGDungeons.dbc name lookup to the Dungeon Finder window UI:
- Queued state: "In queue for Culling of Stratholme (1:23)"
- Proposal state: "Group found for Halls of Lightning!"
- InDungeon state: "In dungeon (Utgarde Pinnacle)"
- FinishedDungeon state: "Culling of Stratholme complete"
- Proposal accept banner: "A group has been found for <dungeon>!"
All states fall back gracefully when DBC name is unavailable.
2026-03-13 08:16:59 -07:00
Kelsi
ed02f5872a feat: show LFG dungeon name in Dungeon Finder queue messages
Add LFGDungeons.dbc cache (loadLfgDungeonDbc / getLfgDungeonName) and
use it to enrich three LFG chat messages in WotLK:
- handleLfgJoinResult: "Joined the queue for Culling of Stratholme."
- handleLfgProposalUpdate case 1: "Group found for Halls of Lightning!"
- handleLfgProposalUpdate case 2: "A group has been found for ... Accept or decline."
Falls back to generic text when DBC is unavailable or dungeon ID unknown.
2026-03-13 08:14:47 -07:00
Kelsi
59e29e2988 feat: show taxi destination name in flight messages
Replace generic "Taxi: requesting flight..." and "Flight started." with
"Requesting flight to [node name]..." and "Flight to [node name] started."
using the already-loaded taxiNodes_ map. Falls back to generic text when
the node name is unavailable.
2026-03-13 08:05:16 -07:00
Kelsi
9fe2ef381c feat: use zone name in battlefield entry invite message
Replace raw bfZoneId integer with getAreaName() lookup in
SMSG_BATTLEFIELD_MGR_ENTRY_INVITE so players see "Wintergrasp"
instead of "zone 4197" in the invitation prompt.
2026-03-13 08:00:46 -07:00
Kelsi
a9835f6873 feat: show force-kill notification and resolve Meeting Stone zone name 2026-03-13 07:54:02 -07:00
Kelsi
8cac557f86 feat: notify player when dungeon difficulty changes 2026-03-13 07:42:40 -07:00
Kelsi
ee3c12b2c0 feat: announce weather changes in chat (rain/snow/storm/clear) 2026-03-13 07:39:41 -07:00
Kelsi
67e4497945 feat: improve arena team event messages and add vote kick feedback 2026-03-13 07:37:40 -07:00
Kelsi
90c88d7ecd feat: show equipment set name in save confirmation
SMSG_EQUIPMENT_SET_SAVED: parse the set index and GUID, look up the
matching set name from equipmentSets_, and show
"Equipment set \"<name>\" saved." instead of the generic message.
Falls back to "Equipment set saved." when the set is not yet in the
local cache (e.g. first save before SMSG_EQUIPMENT_SET_LIST arrives).
2026-03-13 07:31:44 -07:00
Kelsi
d2f2d6db72 fix: distinguish auction owner notification action types
SMSG_AUCTION_OWNER_NOTIFICATION action field was ignored — all events
showed "has sold!" regardless. Now:
  - action 0 (won/sold): "Your auction of <item> has sold!"
  - action 1 (expired):  "Your auction of <item> has expired."
  - action 2 (bid placed): "A bid has been placed on your auction of <item>."
2026-03-13 07:27:01 -07:00
Kelsi
0a41ef7285 feat: improve player feedback for purchases, friend status, and instance entry
- SMSG_BUY_ITEM: show "Purchased: <item name> x<count>" confirmation
  using pendingBuyItemId_ set at buy time (fallback to "item #N")
- handleFriendStatus: look up name from contacts_ (populated by
  SMSG_FRIEND_LIST) before playerNameCache, reducing "Unknown" fallbacks
  for online/offline/removed notifications
- Channel member list: also check playerNameCache when entity manager
  has no name, reducing "(unknown)" placeholders
- setFocus: use Unit::getName() (covers NPCs too) + playerNameCache
  fallback instead of Player-only cast
- SMSG_INSTANCE_LOCK_WARNING_QUERY: show dungeon name + difficulty +
  remaining time when auto-accepting a saved instance re-entry
2026-03-13 07:20:58 -07:00
Kelsi
2be793cfba feat: improve calendar lockout and char rename messages
- SMSG_CALENDAR_RAID_LOCKOUT_ADDED: show dungeon name (from Map.dbc)
  and difficulty label (Normal/Heroic/25-Man/25-Man Heroic)
- SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: show dungeon name instead of raw
  map ID; now emits a chat message (was silent/LOG_DEBUG only)
- SMSG_CHAR_RENAME: map result codes 1-7 to human-readable strings
  ("Name already in use.", "Name too short.", etc.) instead of
  "Character rename failed (error N)."
2026-03-13 07:14:40 -07:00
Kelsi
2c72d8462d feat: add map name lookups and improve instance/RAF/misc feedback
- Add getMapName() helper backed by Map.dbc (parallel to getAreaName)
- SMSG_RAID_INSTANCE_MESSAGE: show dungeon name instead of raw map ID
- SMSG_INSTANCE_RESET: show dungeon name instead of raw map ID
- SMSG_INSTANCE_RESET_FAILED: show dungeon name instead of raw map ID
- SMSG_EQUIPMENT_SET_SAVED: show "Equipment set saved." confirmation
- SMSG_PROPOSE_LEVEL_GRANT: show mentor name offering a level grant (RAF)
- SMSG_REFER_A_FRIEND_EXPIRED: show link-expired message
- SMSG_REFER_A_FRIEND_FAILURE: show reason-mapped error message
- SMSG_REPORT_PVP_AFK_RESULT: show success/failure feedback
- SMSG_QUEST_CONFIRM_ACCEPT: add playerNameCache fallback for sharer name
2026-03-13 07:10:10 -07:00
Kelsi
28ce441214 feat: add missing player feedback for level-up, pet spells, and pet name errors
- SMSG_LEVELUP_INFO: show \"You have reached level N!\" chat message on level-up
  (was only calling the UI ding callback without any chat notification)
- SMSG_PET_LEARNED_SPELL: show \"Your pet has learned X.\" with spell name lookup
  (was LOG_DEBUG only)
- SMSG_PET_NAME_INVALID: show \"That pet name is invalid.\"
  (was silently consumed)
2026-03-13 07:02:20 -07:00
Kelsi
8e67a41983 fix: show battleground names instead of IDs in SMSG_BATTLEFIELD_STATUS messages
Replace raw \"Battleground #2\" with proper names (Warsong Gulch, Arathi Basin,
Eye of the Storm, Strand of the Ancients, Isle of Conquest, arena names, etc.)
for all three expansions' BG type ID space.
2026-03-13 06:59:02 -07:00
Kelsi
20b59c9d63 fix: correct LFG vote kick result logic and show item names in dungeon rewards
- handleLfgBootProposalUpdate: was using myAnswer (player's own vote) to determine
  if the boot passed — should use bootVotes >= votesNeeded instead. Player who voted
  yes would see "passed" even if the vote failed, and vice versa.
- handleLfgPlayerReward: look up item name from item cache instead of showing
  raw "item #12345" for dungeon reward items
2026-03-13 06:56:37 -07:00
Kelsi
cf88a960f4 feat: add missing SMSG_CHANNEL_NOTIFY feedback for 12 unhandled notification types
Previously, PLAYER_NOT_FOUND, ANNOUNCEMENTS_ON/OFF, MODERATION_ON/OFF,
PLAYER_BANNED, PLAYER_UNBANNED, PLAYER_NOT_BANNED, INVITE, WRONG_FACTION,
INVITE_WRONG_FACTION, NOT_MODERATED, PLAYER_INVITED, and PLAYER_INVITE_BANNED
all fell silently to the default log-only path. Now each shows an appropriate
system message in chat.
2026-03-13 06:48:12 -07:00
Kelsi
9216a6da28 fix: show zone names in hearthstone bind messages, add playerNameCache to duel challenger
- SMSG_BINDPOINTUPDATE: show zone name in \"Your home has been set to X.\" (was just \"Your home has been set.\")
- SMSG_PLAYERBOUND: replace \"map N, zone N\" raw IDs with zone name lookup
- SMSG_BINDER_CONFIRM: suppress redundant \"This innkeeper is now your home location.\" since SMSG_PLAYERBOUND fires immediately after with zone context
- SMSG_DUEL_REQUESTED: add playerNameCache fallback before hex GUID for challenger name
2026-03-13 06:46:56 -07:00
Kelsi
ecc02595de fix: improve guild command result messages and suppress repeated guild name announcements
- Map GuildCommandError codes to human-readable strings instead of showing raw
  error numbers (e.g. \"error 4\" → \"No player named X is online.\")
- Handle errorCode==0 for QUIT command: show \"You have left the guild.\" and
  clear guild state (name, ranks, roster) — previously silent
- Handle errorCode==0 for CREATE and INVITE commands with appropriate messages
- Substitute %s-style error messages with the player name from data.name
- Suppress repeated \"Guild: <Name>\" chat message on every SMSG_GUILD_QUERY_RESPONSE;
  only announce once when the guild name is first learned at login
2026-03-13 06:43:11 -07:00
Kelsi
e882110e7f fix: suppress repeated group-join chat spam on every SMSG_GROUP_LIST update
Previously "You are now in a group with N members." was shown on every
GROUP_LIST packet, which fires for each party stat update. Now only show
a message on actual state transitions: joining, leaving the group.
2026-03-13 06:38:50 -07:00
Kelsi
3a143b9b5b fix: use playerNameCache fallback and show zone name in summon/trade requests
- SMSG_SUMMON_REQUEST: fall back to playerNameCache when entity not in
  range; include zone name from getAreaName() in the summon message
  (e.g. "Bob is summoning you to Stormwind.")
- SMSG_TRADE_STATUS BEGIN_TRADE: fall back to playerNameCache when the
  trade initiator's entity is not visible
2026-03-13 06:37:26 -07:00
Kelsi
8a81ffa29c fix: show quest name in QUESTGIVER_QUEST_FAILED, use playerNameCache for achievements
- SMSG_QUESTGIVER_QUEST_FAILED: look up quest title from questLog_ and
  include it in the failure message (same pattern as QUESTUPDATE_FAILED
  fix from previous session)
- SMSG_ACHIEVEMENT_EARNED: fall back to playerNameCache for non-visible
  players before showing a raw hex GUID in the achievement message
2026-03-13 06:36:04 -07:00
Kelsi
b1015abffe fix: use actual pct in durability death message, fix pet cast failure feedback
- SMSG_DURABILITY_DAMAGE_DEATH: use the actual pct field from the packet
  instead of hardcoding "10%"
- SMSG_PET_CAST_FAILED: read reason as uint8 (not uint32), look up spell
  name and show human-readable failure reason to player
- Trade status 9 (REJECTED): show "Trade declined." instead of "Trade
  cancelled." to distinguish explicit decline from cancellation
2026-03-13 06:30:30 -07:00
Kelsi
2a52aedbf7 fix: show quest name instead of ID in failed/timed-out quest messages
SMSG_QUESTUPDATE_FAILED and SMSG_QUESTUPDATE_FAILEDTIMER were emitting
generic "Quest 12345 failed!" messages. Now looks up the title from
questLog_ and shows e.g. "\"Report to Gryan Stoutmantle\" failed!" for
a much more readable notification. Falls back to the generic form if
the title is not cached.
2026-03-13 06:24:16 -07:00
Kelsi
156ddfad9a fix: pass power type from POWER_DRAIN energize to color-code combat text
SMSG_SPELLLOGEXECUTE POWER_DRAIN reads drainPower but was not passing it
to addCombatText, so drained-resource returns showed as blue (mana) even
for rage or energy. Now correctly colored following the energize palette
added in the earlier commit.
2026-03-13 06:22:51 -07:00
Kelsi
43b007cdcd fix: only show SMSG_DISPEL_FAILED message when player is the caster
The dispel-failed handler was showing the failure notification for every
dispel attempt in the party/raid, regardless of who cast it. Now checks
casterGuid == playerGuid before showing "X failed to dispel." so only
the player's own failed dispels surface in chat.
2026-03-13 06:21:33 -07:00
Kelsi
d79c79e1bc feat: show chat messages for channel notification events
SMSG_CHANNEL_NOTIFY carries many event types that were silently dropped
in the default case: wrong password, muted, banned, throttled, kicked,
not owner, not moderator, password changed, owner changed, invalid name,
not in area, not in LFG. These are now surfaced as system chat messages
matching WoW-standard phrasing.
2026-03-13 06:18:23 -07:00
Kelsi
38ab1e0aea fix: show correct duel fled message when loser left the duel area
SMSG_DUEL_WINNER type=1 means the loser fled the duel zone rather than
being defeated; was previously treated the same as a normal win. Now
shows "X has fled from the duel. Y wins!" for the flee case vs the
standard "X has defeated Y in a duel!" for a normal outcome.
2026-03-13 06:16:19 -07:00
Kelsi
3b499d6871 fix: prefix SMSG_SPELL_FAILURE error message with spell name
Previously a spell failure like "Not in range" gave no context about
which spell failed. Now the message reads e.g. "Fireball: Not in range"
using the spell name from the DBC cache. Falls back to the bare reason
string if the spell name is not yet cached.
2026-03-13 06:14:28 -07:00
Kelsi
c58fc3073f fix: clear action bar slots when spells are removed or unlearned
SMSG_REMOVED_SPELL and SMSG_SEND_UNLEARN_SPELLS both erased spells from
knownSpells but left stale references on the action bar. After a respec
or forced spell removal, action bar buttons would show removed talents
and spells as still present. Now both handlers clear matching slots and
persist the updated bar layout.
2026-03-13 06:11:10 -07:00
Kelsi
d9b9d1d2f2 fix: show dodge/parry/block/immune combat text when enemy spell misses player
SMSG_SPELLLOGMISS contains miss events for both directions: spells the
player cast that missed, and enemy spells that missed the player. The
victim side (dodge/parry/block/immune/absorb/resist) was silently
discarded. Now both caster==player and victim==player generate the
appropriate combat text floater.
2026-03-13 06:09:42 -07:00
Kelsi
4507a223cc feat: color-code ENERGIZE combat text by power type
Mana (0)=blue, Rage (1)=red, Focus (2)=orange, Energy (3)=yellow,
Runic Power (6)=teal. Previously all energize events showed as blue
regardless of resource type, making it impossible to distinguish
e.g. a Warrior's Rage generation from a Mage's Mana return.

Power type is now captured from SMSG_SPELLENERGIZELOG (uint8) and
SMSG_PERIODICAURALOG OBS_MOD_POWER/PERIODIC_ENERGIZE (uint32 cast
to uint8) and stored in CombatTextEntry::powerType.
2026-03-13 06:08:21 -07:00
Kelsi
b9c16e9be5 fix: suppress duplicate "Upgraded to X" message on trainer rank-up
When buying a higher spell rank from a trainer, SMSG_TRAINER_BUY_SUCCEEDED
already announces "You have learned X", and SMSG_SUPERCEDED_SPELLS would
then also print "Upgraded to X" — two messages for one action.

Fix: check if the new spell ID was already in knownSpells before inserting
it in handleSupercededSpell. If so, the trainer handler already announced
it and we skip the redundant "Upgraded to" message. Non-trainer supersedes
(quest rewards, etc.) where the spell wasn't pre-inserted still show it.
2026-03-13 06:00:39 -07:00
Kelsi
dfe091473c fix: show action bar cooldown timers for spells on cooldown at login
SMSG_INITIAL_SPELLS delivers active cooldowns which were stored in
spellCooldowns but never propagated to the action bar slot
cooldownRemaining/cooldownTotal fields. This meant that spells with
remaining cooldowns at login time showed no countdown overlay on the
action bar. Sync the action bar slots from spellCooldowns after
loadCharacterConfig() to restore the correct timers.
2026-03-13 05:58:57 -07:00
Kelsi
2b131548aa fix: show descriptive party command error messages
Replace the generic "Party command failed (error N)" message with
WoW-standard error strings for each PartyResult code, matching what
the original client displays (e.g. "Your party is full.", "%s is
already in a group.", "%s is ignoring you.", etc.).
2026-03-13 05:54:01 -07:00
Kelsi
b34df01331 fix: suppress duplicate chat message when learning trainer spell
SMSG_TRAINER_BUY_SUCCEEDED pre-inserts the spell into knownSpells and
shows "You have learned X." The subsequent SMSG_LEARNED_SPELL packet
would then show a second "You have learned a new spell: X." message.

Fix: check if the spell was already in knownSpells before inserting in
handleLearnedSpell. If it was pre-inserted by the trainer handler, skip
the chat notification to avoid the duplicate.
2026-03-13 05:51:15 -07:00
Kelsi
7dc12bb35e fix: persist action bar config after superceded spell slot upgrade
After automatically upgrading action bar slots to the new spell rank
in handleSupercededSpell, save the character config so the upgraded
slot IDs persist across sessions.
2026-03-13 05:42:24 -07:00
Kelsi
2d587d0d4b feat: upgrade action bar slots to new spell rank on supercede
When a spell is superceded (e.g. Fireball Rank 1 -> Rank 2 after
training), update any action bar slots referencing the old spell ID
to point to the new rank. This matches WoW client behaviour where
training a new rank automatically upgrades your action bars so you
don't have to manually re-place the spell.
2026-03-13 05:37:15 -07:00
Kelsi
3c704088af fix: clear lastInteractedGoGuid_ on world transfer in handleNewWorld
Same-map teleports (dungeon teleporters, etc.) clear casting state but
were not clearing lastInteractedGoGuid_.  If a gather cast was in progress
when the teleport happened, the stale GO guid could theoretically trigger
a spurious CMSG_LOOT on the destination map.

Also clears lastInteractedGoGuid_ in handleNewWorld alongside the rest of
the casting-state teardown for consistency with other reset paths.
2026-03-13 05:26:27 -07:00
Kelsi
103bb5a513 fix: query item info in SMSG_LOOT_START_ROLL and use live name in roll popup
SMSG_LOOT_START_ROLL was not calling queryItemInfo(), so the roll popup
would display item IDs instead of names when the item had not been
previously cached (e.g. first time seeing that item in the session).

Also update renderLootRollPopup to prefer the live ItemQueryResponseData
name/quality over the snapshot captured at parse time, so the popup
shows the correct name once SMSG_ITEM_QUERY_SINGLE_RESPONSE arrives.
2026-03-13 05:23:31 -07:00
Kelsi
48bcee32b4 fix: match spell ID in handleSpellGo to prevent proc spells triggering gather loot
wasInTimedCast checked casting == true but not whether the completing spell
was actually the gather cast.  A triggered/proc spell (SMSG_SPELL_GO with
a different spellId) could arrive while a gather cast is active (casting==true),
satisfying the old guard and firing lootTarget prematurely.

Require data.spellId == currentCastSpellId so only the spell that started
the cast bar triggers the post-gather CMSG_LOOT dispatch.
2026-03-13 05:12:22 -07:00
Kelsi
f44defec38 feat: show fish-hooked notification when fishing bobber splashes
When the server sends SMSG_GAMEOBJECT_CUSTOM_ANIM with animId=0 for a GO
of type 17 (FISHINGNODE), a fish has been hooked and the player needs to
click the bobber quickly.  Add a system chat message and a UI sound to
alert the player — previously there was no visual/audio feedback beyond
the bobber animation itself.
2026-03-13 05:07:51 -07:00
Kelsi
01f4ef5e79 fix: clear lastInteractedGoGuid_ for non-lootable GO interactions
Mailboxes, doors, buttons, and other non-lootable GOs set shouldSendLoot=false
so no CMSG_LOOT is dispatched — but lastInteractedGoGuid_ was still set.
Without SMSG_LOOT_RESPONSE to clear it, a subsequent timed cast completion
(e.g. player buffs at the mailbox) would fire a spurious CMSG_LOOT for the
mailbox GUID.
2026-03-13 05:06:00 -07:00
Kelsi
6878f120e9 fix: clear lastInteractedGoGuid_ in handleCastFailed path
SMSG_CAST_FAILED is a direct rejection (e.g. insufficient range, no mana)
before the cast starts.  Missing this path meant a stale gather-node guid
could survive into the next timed cast if SMSG_CAST_FAILED fired instead
of SMSG_SPELL_FAILURE.
2026-03-13 05:03:50 -07:00
Kelsi
7a4347dbac fix: clear lastInteractedGoGuid_ on cast failure, cancel, and world reset
If a gather cast was interrupted by SMSG_SPELL_FAILURE (e.g. player took
damage during mining), lastInteractedGoGuid_ was left set.  A subsequent
timed cast completion would then fire CMSG_LOOT for the stale node even
though the gather never completed.

Clear lastInteractedGoGuid_ in all cast-termination paths:
- SMSG_SPELL_FAILURE (cast interrupted by server)
- SMSG_CAST_RESULT non-zero (cast rejected before it started)
- cancelCast() (player or system cancelled the cast)
- World reset / logout block (state-clear boundary)
2026-03-13 05:02:58 -07:00
Kelsi
cc2b413e22 fix: guard gather-node CMSG_LOOT dispatch against instant casts and proc spells
handleSpellGo fired lootTarget(lastInteractedGoGuid_) on ANY player spell
completion, including instant casts and proc/triggered spells that arrive
while the gather cast is still in flight.  Save the casting flag before
clearing it and only dispatch CMSG_LOOT when wasInTimedCast is true — this
ensures only the gather cast completion triggers the post-gather loot send,
not unrelated instant spells that also produce SMSG_SPELL_GO.
2026-03-13 04:59:05 -07:00
Kelsi
2c6902d27d fix: mining nodes no longer report invalid target and now open loot after gather
Two bugs fixed:
1. Retry logic (for Classic) re-sent CMSG_GAMEOBJ_USE at 0.15s while the
   gather cast was in-flight, causing SPELL_FAILED_BAD_TARGETS. Now clears
   pendingGameObjectLootRetries_ as soon as SMSG_SPELL_START shows the player
   started a cast (gather accepted).

2. CMSG_LOOT was sent immediately before the gather cast completed, then
   never sent again — so the loot window never opened. Now tracks the last
   interacted GO and sends CMSG_LOOT in handleSpellGo once the gather spell
   completes, matching how the real client behaves.
2026-03-13 04:37:36 -07:00
Kelsi
4272491d56 feat: send CMSG_SET_ACTION_BUTTON to server when action bar slot changes
Action bar changes (dragging spells/items) were only saved locally.
Now notifies the server via CMSG_SET_ACTION_BUTTON so the layout
persists across relogs. Supports Classic (5-byte) and TBC/WotLK
(packed uint32) wire formats.
2026-03-13 04:25:05 -07:00
21 changed files with 2451 additions and 382 deletions

View file

@ -113,5 +113,8 @@
"Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41,
"Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45,
"Threshold8": 46, "Threshold9": 47
},
"LFGDungeons": {
"ID": 0, "Name": 1
}
}

View file

@ -23,6 +23,8 @@
"UNIT_FIELD_STAT3": 87,
"UNIT_FIELD_STAT4": 88,
"UNIT_END": 148,
"UNIT_FIELD_ATTACK_POWER": 123,
"UNIT_FIELD_RANGED_ATTACK_POWER": 126,
"PLAYER_FLAGS": 150,
"PLAYER_BYTES": 153,
"PLAYER_BYTES_2": 154,
@ -38,6 +40,15 @@
"PLAYER_SKILL_INFO_START": 636,
"PLAYER_EXPLORED_ZONES_START": 1041,
"PLAYER_CHOSEN_TITLE": 1349,
"PLAYER_FIELD_MOD_DAMAGE_DONE_POS": 1171,
"PLAYER_FIELD_MOD_HEALING_DONE_POS": 1192,
"PLAYER_BLOCK_PERCENTAGE": 1024,
"PLAYER_DODGE_PERCENTAGE": 1025,
"PLAYER_PARRY_PERCENTAGE": 1026,
"PLAYER_CRIT_PERCENTAGE": 1029,
"PLAYER_RANGED_CRIT_PERCENTAGE": 1030,
"PLAYER_SPELL_CRIT_PERCENTAGE1": 1032,
"PLAYER_FIELD_COMBAT_RATING_1": 1231,
"GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 60,

View file

@ -38,8 +38,11 @@ namespace game {
struct PlayerSkill {
uint32_t skillId = 0;
uint16_t value = 0;
uint16_t value = 0; // base + permanent item bonuses
uint16_t maxValue = 0;
uint16_t bonusTemp = 0; // temporary buff bonus (food, potions, etc.)
uint16_t bonusPerm = 0; // permanent spec/misc bonus (rarely non-zero)
uint16_t effectiveValue() const { return value + bonusTemp + bonusPerm; }
};
/**
@ -218,6 +221,7 @@ public:
pos = homeBindPos_;
return true;
}
uint32_t getHomeBindZoneId() const { return homeBindZoneId_; }
/**
* Send a movement packet
@ -283,6 +287,7 @@ public:
* @return Vector of chat messages
*/
const std::deque<MessageChatData>& getChatHistory() const { return chatHistory; }
void clearChatHistory() { chatHistory.clear(); }
/**
* Add a locally-generated chat message (e.g., emote feedback)
@ -309,6 +314,43 @@ public:
return playerStats_[idx];
}
// Server-authoritative attack power (WotLK: UNIT_FIELD_ATTACK_POWER / RANGED).
// Returns -1 if not yet received.
int32_t getMeleeAttackPower() const { return playerMeleeAP_; }
int32_t getRangedAttackPower() const { return playerRangedAP_; }
// Server-authoritative spell damage / healing bonus (WotLK: PLAYER_FIELD_MOD_*).
// getSpellPower returns the max damage bonus across magic schools 1-6 (Holy/Fire/Nature/Frost/Shadow/Arcane).
// Returns -1 if not yet received.
int32_t getSpellPower() const {
int32_t sp = -1;
for (int i = 1; i <= 6; ++i) {
if (playerSpellDmgBonus_[i] > sp) sp = playerSpellDmgBonus_[i];
}
return sp;
}
int32_t getHealingPower() const { return playerHealBonus_; }
// Server-authoritative combat chance percentages (WotLK: PLAYER_* float fields).
// Returns -1.0f if not yet received.
float getDodgePct() const { return playerDodgePct_; }
float getParryPct() const { return playerParryPct_; }
float getBlockPct() const { return playerBlockPct_; }
float getCritPct() const { return playerCritPct_; }
float getRangedCritPct() const { return playerRangedCritPct_; }
// Spell crit by school (0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane)
float getSpellCritPct(int school = 1) const {
if (school < 0 || school > 6) return -1.0f;
return playerSpellCritPct_[school];
}
// Server-authoritative combat ratings (WotLK: PLAYER_FIELD_COMBAT_RATING_1+idx).
// Returns -1 if not yet received. Indices match AzerothCore CombatRating enum.
int32_t getCombatRating(int cr) const {
if (cr < 0 || cr > 24) return -1;
return playerCombatRatings_[cr];
}
// Inventory
Inventory& getInventory() { return inventory; }
const Inventory& getInventory() const { return inventory; }
@ -402,6 +444,8 @@ public:
uint8_t arenaType = 0;
uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress
uint32_t inviteTimeout = 80;
uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE)
uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE)
std::chrono::steady_clock::time_point inviteReceivedTime{};
};
@ -457,6 +501,8 @@ public:
// Logout commands
void requestLogout();
void cancelLogout();
bool isLoggingOut() const { return loggingOut_; }
float getLogoutCountdown() const { return logoutCountdown_; }
// Stand state
void setStandState(uint8_t state); // 0=stand, 1=sit, 2=sit_chair, 3=sleep, 4=sit_low_chair, 5=sit_medium_chair, 6=sit_high_chair, 7=dead, 8=kneel, 9=submerged
@ -1089,6 +1135,10 @@ public:
const Character* ch = getActiveCharacter();
return ch ? static_cast<uint8_t>(ch->characterClass) : 0;
}
uint8_t getPlayerRace() const {
const Character* ch = getActiveCharacter();
return ch ? static_cast<uint8_t>(ch->race) : 0;
}
void setPlayerGuid(uint64_t guid) { playerGuid = guid; }
// Player death state
@ -1274,6 +1324,8 @@ public:
bool isLfgQueued() const { return lfgState_ == LfgState::Queued; }
bool isLfgInDungeon() const { return lfgState_ == LfgState::InDungeon; }
uint32_t getLfgDungeonId() const { return lfgDungeonId_; }
std::string getCurrentLfgDungeonName() const { return getLfgDungeonName(lfgDungeonId_); }
std::string getMapName(uint32_t mapId) const;
uint32_t getLfgProposalId() const { return lfgProposalId_; }
int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; }
uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; }
@ -1833,6 +1885,7 @@ public:
bool isTaxiMountActive() const { return taxiMountActive_; }
bool isTaxiActivationPending() const { return taxiActivatePending_; }
void forceClearTaxiAndMovementState();
const std::string& getTaxiDestName() const { return taxiDestName_; }
const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; }
uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; }
@ -2314,7 +2367,8 @@ private:
void handleLogoutResponse(network::Packet& packet);
void handleLogoutComplete(network::Packet& packet);
void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource);
void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0,
uint64_t srcGuid = 0, uint64_t dstGuid = 0);
void addSystemChatMessage(const std::string& message);
/**
@ -2421,6 +2475,7 @@ private:
uint32_t currentMapId_ = 0;
bool hasHomeBind_ = false;
uint32_t homeBindMapId_ = 0;
uint32_t homeBindZoneId_ = 0;
glm::vec3 homeBindPos_{0.0f};
// ---- Phase 1: Name caches ----
@ -2448,7 +2503,8 @@ private:
std::unordered_map<std::string, uint64_t> ignoreCache; // name -> guid
// ---- Logout state ----
bool loggingOut_ = false;
bool loggingOut_ = false;
float logoutCountdown_ = 0.0f; // seconds remaining before server logs us out (0 = instant/done)
// ---- Display state ----
bool helmVisible_ = true;
@ -2778,6 +2834,9 @@ private:
float timer = 0.0f;
};
std::vector<PendingLootOpen> pendingGameObjectLootOpens_;
// Tracks the last GO we sent CMSG_GAMEOBJ_USE to; used in handleSpellGo
// to send CMSG_LOOT after a gather cast (mining/herbalism) completes.
uint64_t lastInteractedGoGuid_ = 0;
uint64_t pendingLootMoneyGuid_ = 0;
uint32_t pendingLootMoneyAmount_ = 0;
float pendingLootMoneyNotifyTimer_ = 0.0f;
@ -2787,6 +2846,18 @@ private:
int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane
// Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet
int32_t playerStats_[5] = {-1, -1, -1, -1, -1};
// WotLK secondary combat stats (-1 = not yet received)
int32_t playerMeleeAP_ = -1;
int32_t playerRangedAP_ = -1;
int32_t playerSpellDmgBonus_[7] = {-1,-1,-1,-1,-1,-1,-1}; // per school 0-6
int32_t playerHealBonus_ = -1;
float playerDodgePct_ = -1.0f;
float playerParryPct_ = -1.0f;
float playerBlockPct_ = -1.0f;
float playerCritPct_ = -1.0f;
float playerRangedCritPct_ = -1.0f;
float playerSpellCritPct_[7] = {-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f};
int32_t playerCombatRatings_[25] = {-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1};
// Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating
// money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime.
uint32_t pendingMoneyDelta_ = 0;
@ -2845,6 +2916,7 @@ private:
ShowTaxiNodesData currentTaxiData_;
uint64_t taxiNpcGuid_ = 0;
bool onTaxiFlight_ = false;
std::string taxiDestName_;
bool taxiMountActive_ = false;
uint32_t taxiMountDisplayId_ = 0;
bool taxiActivatePending_ = false;
@ -2968,6 +3040,17 @@ private:
bool areaNameCacheLoaded_ = false;
void loadAreaNameCache();
std::string getAreaName(uint32_t areaId) const;
// Map name cache (lazy-loaded from Map.dbc; maps mapId → localized display name)
std::unordered_map<uint32_t, std::string> mapNameCache_;
bool mapNameCacheLoaded_ = false;
void loadMapNameCache();
// LFG dungeon name cache (lazy-loaded from LFGDungeons.dbc; WotLK only)
std::unordered_map<uint32_t, std::string> lfgDungeonNameCache_;
bool lfgDungeonNameCacheLoaded_ = false;
void loadLfgDungeonDbc();
std::string getLfgDungeonName(uint32_t dungeonId) const;
std::vector<TrainerTab> trainerTabs_;
void handleTrainerList(network::Packet& packet);
void loadSpellNameCache();

View file

@ -15,6 +15,8 @@ enum class ItemQuality : uint8_t {
RARE = 3, // Blue
EPIC = 4, // Purple
LEGENDARY = 5, // Orange
ARTIFACT = 6, // Yellow (unused in 3.3.5a but valid quality value)
HEIRLOOM = 7, // Yellow/gold (WotLK bind-on-account heirlooms)
};
enum class EquipSlot : uint8_t {

View file

@ -52,13 +52,15 @@ struct CombatTextEntry {
enum Type : uint8_t {
MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK,
CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL,
ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER
ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER,
DISPEL, INTERRUPT
};
Type type;
int32_t amount = 0;
uint32_t spellId = 0;
float age = 0.0f; // Seconds since creation (for fadeout)
bool isPlayerSource = false; // True if player dealt this
uint8_t powerType = 0; // For ENERGIZE: 0=mana,1=rage,2=focus,3=energy,6=runicpower
static constexpr float LIFETIME = 2.5f;
bool isExpired() const { return age >= LIFETIME; }

View file

@ -42,6 +42,10 @@ enum class UF : uint16_t {
UNIT_FIELD_STAT4, // Spirit
UNIT_END,
// Unit combat fields (WotLK: PRIVATE+OWNER — only visible for the player character)
UNIT_FIELD_ATTACK_POWER, // Melee attack power (int32)
UNIT_FIELD_RANGED_ATTACK_POWER, // Ranged attack power (int32)
// Player fields
PLAYER_FLAGS,
PLAYER_BYTES,
@ -59,6 +63,19 @@ enum class UF : uint16_t {
PLAYER_EXPLORED_ZONES_START,
PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title)
// Player spell power / healing bonus (WotLK: PRIVATE — int32 per school)
PLAYER_FIELD_MOD_DAMAGE_DONE_POS, // Spell damage bonus (first of 7 schools)
PLAYER_FIELD_MOD_HEALING_DONE_POS, // Healing bonus
// Player combat stats (WotLK: PRIVATE — float values)
PLAYER_BLOCK_PERCENTAGE, // Block chance %
PLAYER_DODGE_PERCENTAGE, // Dodge chance %
PLAYER_PARRY_PERCENTAGE, // Parry chance %
PLAYER_CRIT_PERCENTAGE, // Melee crit chance %
PLAYER_RANGED_CRIT_PERCENTAGE, // Ranged crit chance %
PLAYER_SPELL_CRIT_PERCENTAGE1, // Spell crit chance % (first school; 7 consecutive float fields)
PLAYER_FIELD_COMBAT_RATING_1, // First of 25 int32 combat rating slots (CR_* indices)
// GameObject fields
GAMEOBJECT_DISPLAYID,

View file

@ -947,6 +947,21 @@ public:
static network::Packet build(uint8_t state);
};
// ============================================================
// Action Bar
// ============================================================
/** CMSG_SET_ACTION_BUTTON packet builder */
class SetActionButtonPacket {
public:
// button: 0-based slot index
// type: ActionBarSlot::Type (SPELL=0, ITEM=1, MACRO=2, EMPTY=0)
// id: spellId, itemId, or macroId (0 to clear)
// isClassic: true for Vanilla/Turtle format (5-byte payload),
// false for TBC/WotLK (5-byte packed uint32)
static network::Packet build(uint8_t button, uint8_t type, uint32_t id, bool isClassic);
};
// ============================================================
// Display Toggles
// ============================================================
@ -1572,13 +1587,21 @@ struct ItemQueryResponseData {
uint32_t subClass = 0;
uint32_t displayInfoId = 0;
uint32_t quality = 0;
uint32_t itemFlags = 0; // Item flag bitmask (Heroic=0x8, Unique-Equipped=0x1000000)
uint32_t inventoryType = 0;
int32_t maxCount = 0; // Max that can be carried (1 = Unique, 0 = unlimited)
int32_t maxStack = 1;
uint32_t containerSlots = 0;
float damageMin = 0.0f;
float damageMax = 0.0f;
uint32_t delayMs = 0;
int32_t armor = 0;
int32_t holyRes = 0;
int32_t fireRes = 0;
int32_t natureRes = 0;
int32_t frostRes = 0;
int32_t shadowRes = 0;
int32_t arcaneRes = 0;
int32_t stamina = 0;
int32_t strength = 0;
int32_t agility = 0;
@ -1604,6 +1627,13 @@ struct ItemQueryResponseData {
std::array<uint32_t, 3> socketColor{};
uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none
uint32_t itemSetId = 0; // ItemSet.dbc entry; 0=not part of a set
// Requirement fields
uint32_t requiredSkill = 0; // SkillLine.dbc ID (0 = no skill required)
uint32_t requiredSkillRank = 0; // Minimum skill value
uint32_t allowableClass = 0; // Class bitmask (0 = all classes)
uint32_t allowableRace = 0; // Race bitmask (0 = all races)
uint32_t requiredReputationFaction = 0; // Faction.dbc ID (0 = none)
uint32_t requiredReputationRank = 0; // 0=Hated..8=Exalted
bool valid = false;
};

View file

@ -39,6 +39,7 @@ class StarField;
class Clouds;
class LensFlare;
class Weather;
class Lightning;
class LightingManager;
class SwimEffects;
class MountDust;
@ -127,6 +128,7 @@ public:
Clouds* getClouds() const { return skySystem ? skySystem->getClouds() : nullptr; }
LensFlare* getLensFlare() const { return skySystem ? skySystem->getLensFlare() : nullptr; }
Weather* getWeather() const { return weather.get(); }
Lightning* getLightning() const { return lightning.get(); }
CharacterRenderer* getCharacterRenderer() const { return characterRenderer.get(); }
WMORenderer* getWMORenderer() const { return wmoRenderer.get(); }
M2Renderer* getM2Renderer() const { return m2Renderer.get(); }
@ -216,6 +218,7 @@ private:
std::unique_ptr<Clouds> clouds;
std::unique_ptr<LensFlare> lensFlare;
std::unique_ptr<Weather> weather;
std::unique_ptr<Lightning> lightning;
std::unique_ptr<LightingManager> lightingManager;
std::unique_ptr<SkySystem> skySystem; // Coordinator for sky rendering
std::unique_ptr<SwimEffects> swimEffects;

View file

@ -348,6 +348,7 @@ private:
void renderTrainerWindow(game::GameHandler& gameHandler);
void renderStableWindow(game::GameHandler& gameHandler);
void renderTaxiWindow(game::GameHandler& gameHandler);
void renderLogoutCountdown(game::GameHandler& gameHandler);
void renderDeathScreen(game::GameHandler& gameHandler);
void renderReclaimCorpseButton(game::GameHandler& gameHandler);
void renderResurrectDialog(game::GameHandler& gameHandler);

View file

@ -149,7 +149,8 @@ private:
void renderEquipmentPanel(game::Inventory& inventory);
void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false);
void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0,
const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr);
const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr,
const game::GameHandler* gh = nullptr);
void renderReputationPanel(game::GameHandler& gameHandler);
void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot,

File diff suppressed because it is too large Load diff

View file

@ -313,6 +313,8 @@ const char* getQualityName(ItemQuality quality) {
case ItemQuality::RARE: return "Rare";
case ItemQuality::EPIC: return "Epic";
case ItemQuality::LEGENDARY: return "Legendary";
case ItemQuality::ARTIFACT: return "Artifact";
case ItemQuality::HEIRLOOM: return "Heirloom";
default: return "Unknown";
}
}

View file

@ -1381,7 +1381,7 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
return false;
}
packet.readUInt32(); // Flags
data.itemFlags = packet.readUInt32(); // Flags
// Vanilla: NO Flags2
packet.readUInt32(); // BuyPrice
data.sellPrice = packet.readUInt32(); // SellPrice
@ -1394,18 +1394,18 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
return false;
}
packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace
data.allowableClass = packet.readUInt32(); // AllowableClass
data.allowableRace = packet.readUInt32(); // AllowableRace
data.itemLevel = packet.readUInt32();
data.requiredLevel = packet.readUInt32();
packet.readUInt32(); // RequiredSkill
packet.readUInt32(); // RequiredSkillRank
data.requiredSkill = packet.readUInt32(); // RequiredSkill
data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank
packet.readUInt32(); // RequiredSpell
packet.readUInt32(); // RequiredHonorRank
packet.readUInt32(); // RequiredCityRank
packet.readUInt32(); // RequiredReputationFaction
packet.readUInt32(); // RequiredReputationRank
packet.readUInt32(); // MaxCount
data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction
data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank
data.maxCount = static_cast<int32_t>(packet.readUInt32()); // MaxCount (1 = Unique)
data.maxStack = static_cast<int32_t>(packet.readUInt32()); // Stackable
data.containerSlots = packet.readUInt32();
@ -1468,12 +1468,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
// Remaining tail can vary by core. Read resistances + delay when present.
if (packet.getSize() - packet.getReadPos() >= 28) {
packet.readUInt32(); // HolyRes
packet.readUInt32(); // FireRes
packet.readUInt32(); // NatureRes
packet.readUInt32(); // FrostRes
packet.readUInt32(); // ShadowRes
packet.readUInt32(); // ArcaneRes
data.holyRes = static_cast<int32_t>(packet.readUInt32()); // HolyRes
data.fireRes = static_cast<int32_t>(packet.readUInt32()); // FireRes
data.natureRes = static_cast<int32_t>(packet.readUInt32()); // NatureRes
data.frostRes = static_cast<int32_t>(packet.readUInt32()); // FrostRes
data.shadowRes = static_cast<int32_t>(packet.readUInt32()); // ShadowRes
data.arcaneRes = static_cast<int32_t>(packet.readUInt32()); // ArcaneRes
data.delayMs = packet.readUInt32();
}

View file

@ -998,7 +998,7 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
return false;
}
packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2)
data.itemFlags = packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2)
// TBC: NO Flags2, NO BuyCount
packet.readUInt32(); // BuyPrice
data.sellPrice = packet.readUInt32();
@ -1011,19 +1011,19 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
return false;
}
packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace
data.allowableClass = packet.readUInt32(); // AllowableClass
data.allowableRace = packet.readUInt32(); // AllowableRace
data.itemLevel = packet.readUInt32();
data.requiredLevel = packet.readUInt32();
packet.readUInt32(); // RequiredSkill
packet.readUInt32(); // RequiredSkillRank
data.requiredSkill = packet.readUInt32(); // RequiredSkill
data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank
packet.readUInt32(); // RequiredSpell
packet.readUInt32(); // RequiredHonorRank
packet.readUInt32(); // RequiredCityRank
packet.readUInt32(); // RequiredReputationFaction
packet.readUInt32(); // RequiredReputationRank
packet.readUInt32(); // MaxCount
data.maxStack = static_cast<int32_t>(packet.readUInt32()); // Stackable
data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction
data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank
data.maxCount = static_cast<int32_t>(packet.readUInt32()); // MaxCount (1 = Unique)
data.maxStack = static_cast<int32_t>(packet.readUInt32()); // Stackable
data.containerSlots = packet.readUInt32();
// TBC: statsCount prefix + exactly statsCount pairs (WotLK always sends 10)
@ -1087,12 +1087,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
data.armor = static_cast<int32_t>(packet.readUInt32());
if (packet.getSize() - packet.getReadPos() >= 28) {
packet.readUInt32(); // HolyRes
packet.readUInt32(); // FireRes
packet.readUInt32(); // NatureRes
packet.readUInt32(); // FrostRes
packet.readUInt32(); // ShadowRes
packet.readUInt32(); // ArcaneRes
data.holyRes = static_cast<int32_t>(packet.readUInt32()); // HolyRes
data.fireRes = static_cast<int32_t>(packet.readUInt32()); // FireRes
data.natureRes = static_cast<int32_t>(packet.readUInt32()); // NatureRes
data.frostRes = static_cast<int32_t>(packet.readUInt32()); // FrostRes
data.shadowRes = static_cast<int32_t>(packet.readUInt32()); // ShadowRes
data.arcaneRes = static_cast<int32_t>(packet.readUInt32()); // ArcaneRes
data.delayMs = packet.readUInt32();
}

View file

@ -43,6 +43,8 @@ static const UFNameEntry kUFNames[] = {
{"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3},
{"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4},
{"UNIT_END", UF::UNIT_END},
{"UNIT_FIELD_ATTACK_POWER", UF::UNIT_FIELD_ATTACK_POWER},
{"UNIT_FIELD_RANGED_ATTACK_POWER", UF::UNIT_FIELD_RANGED_ATTACK_POWER},
{"PLAYER_FLAGS", UF::PLAYER_FLAGS},
{"PLAYER_BYTES", UF::PLAYER_BYTES},
{"PLAYER_BYTES_2", UF::PLAYER_BYTES_2},
@ -61,6 +63,16 @@ static const UFNameEntry kUFNames[] = {
{"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY},
{"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY},
{"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE},
{"PLAYER_CHOSEN_TITLE", UF::PLAYER_CHOSEN_TITLE},
{"PLAYER_FIELD_MOD_DAMAGE_DONE_POS", UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS},
{"PLAYER_FIELD_MOD_HEALING_DONE_POS", UF::PLAYER_FIELD_MOD_HEALING_DONE_POS},
{"PLAYER_BLOCK_PERCENTAGE", UF::PLAYER_BLOCK_PERCENTAGE},
{"PLAYER_DODGE_PERCENTAGE", UF::PLAYER_DODGE_PERCENTAGE},
{"PLAYER_PARRY_PERCENTAGE", UF::PLAYER_PARRY_PERCENTAGE},
{"PLAYER_CRIT_PERCENTAGE", UF::PLAYER_CRIT_PERCENTAGE},
{"PLAYER_RANGED_CRIT_PERCENTAGE", UF::PLAYER_RANGED_CRIT_PERCENTAGE},
{"PLAYER_SPELL_CRIT_PERCENTAGE1", UF::PLAYER_SPELL_CRIT_PERCENTAGE1},
{"PLAYER_FIELD_COMBAT_RATING_1", UF::PLAYER_FIELD_COMBAT_RATING_1},
{"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS},
{"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1},
};

View file

@ -1905,6 +1905,42 @@ network::Packet StandStateChangePacket::build(uint8_t state) {
return packet;
}
// ============================================================
// Action Bar
// ============================================================
network::Packet SetActionButtonPacket::build(uint8_t button, uint8_t type, uint32_t id, bool isClassic) {
// Classic/Turtle (1.12): uint8 button + uint16 id + uint8 type + uint8 misc(0)
// type encoding: 0=spell, 1=item, 64=macro
// TBC/WotLK: uint8 button + uint32 packed (type<<24 | id)
// type encoding: 0x00=spell, 0x80=item, 0x40=macro
// packed=0 means clear the slot
network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTION_BUTTON));
packet.writeUInt8(button);
if (isClassic) {
// Classic: 16-bit id, 8-bit type code, 8-bit misc
// Map ActionBarSlot::Type (0=EMPTY,1=SPELL,2=ITEM,3=MACRO) → classic type byte
uint8_t classicType = 0; // 0 = spell
if (type == 2 /* ITEM */) classicType = 1;
if (type == 3 /* MACRO */) classicType = 64;
packet.writeUInt16(static_cast<uint16_t>(id));
packet.writeUInt8(classicType);
packet.writeUInt8(0); // misc
LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (Classic): button=", (int)button,
" id=", id, " type=", (int)classicType);
} else {
// TBC/WotLK: type in bits 2431, id in bits 023; packed=0 clears slot
uint8_t packedType = 0x00; // spell
if (type == 2 /* ITEM */) packedType = 0x80;
if (type == 3 /* MACRO */) packedType = 0x40;
uint32_t packed = (id == 0) ? 0 : (static_cast<uint32_t>(packedType) << 24) | (id & 0x00FFFFFF);
packet.writeUInt32(packed);
LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (TBC/WotLK): button=", (int)button,
" packed=0x", std::hex, packed, std::dec);
}
return packet;
}
// ============================================================
// Display Toggles
// ============================================================
@ -2810,7 +2846,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before flags (entry=", data.entry, ")");
return false;
}
packet.readUInt32(); // Flags
data.itemFlags = packet.readUInt32(); // Flags
packet.readUInt32(); // Flags2
packet.readUInt32(); // BuyCount
packet.readUInt32(); // BuyPrice
@ -2820,7 +2856,7 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
if (data.inventoryType > 28) {
// inventoryType out of range — BuyCount probably not present; rewind and try 4 fields
packet.setReadPos(postQualityPos);
packet.readUInt32(); // Flags
data.itemFlags = packet.readUInt32(); // Flags
packet.readUInt32(); // Flags2
packet.readUInt32(); // BuyPrice
data.sellPrice = packet.readUInt32(); // SellPrice
@ -2832,18 +2868,18 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")");
return false;
}
packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace
data.allowableClass = packet.readUInt32(); // AllowableClass
data.allowableRace = packet.readUInt32(); // AllowableRace
data.itemLevel = packet.readUInt32();
data.requiredLevel = packet.readUInt32();
packet.readUInt32(); // RequiredSkill
packet.readUInt32(); // RequiredSkillRank
data.requiredSkill = packet.readUInt32(); // RequiredSkill
data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank
packet.readUInt32(); // RequiredSpell
packet.readUInt32(); // RequiredHonorRank
packet.readUInt32(); // RequiredCityRank
packet.readUInt32(); // RequiredReputationFaction
packet.readUInt32(); // RequiredReputationRank
packet.readUInt32(); // MaxCount
data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction
data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank
data.maxCount = static_cast<int32_t>(packet.readUInt32()); // MaxCount (1 = Unique)
data.maxStack = static_cast<int32_t>(packet.readUInt32()); // Stackable
data.containerSlots = packet.readUInt32();
@ -2909,12 +2945,12 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
}
data.armor = static_cast<int32_t>(packet.readUInt32());
packet.readUInt32(); // HolyRes
packet.readUInt32(); // FireRes
packet.readUInt32(); // NatureRes
packet.readUInt32(); // FrostRes
packet.readUInt32(); // ShadowRes
packet.readUInt32(); // ArcaneRes
data.holyRes = static_cast<int32_t>(packet.readUInt32()); // HolyRes
data.fireRes = static_cast<int32_t>(packet.readUInt32()); // FireRes
data.natureRes = static_cast<int32_t>(packet.readUInt32()); // NatureRes
data.frostRes = static_cast<int32_t>(packet.readUInt32()); // FrostRes
data.shadowRes = static_cast<int32_t>(packet.readUInt32()); // ShadowRes
data.arcaneRes = static_cast<int32_t>(packet.readUInt32()); // ArcaneRes
data.delayMs = packet.readUInt32();
packet.readUInt32(); // AmmoType
packet.readFloat(); // RangedModRange

View file

@ -10,6 +10,7 @@
#include "rendering/clouds.hpp"
#include "rendering/lens_flare.hpp"
#include "rendering/weather.hpp"
#include "rendering/lightning.hpp"
#include "rendering/character_renderer.hpp"
#include "rendering/wmo_renderer.hpp"
#include "rendering/m2_renderer.hpp"
@ -369,6 +370,11 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) {
ImGui::Text("Intensity: %.0f%%", weather->getIntensity() * 100.0f);
}
auto* lightning = renderer->getLightning();
if (lightning && lightning->isEnabled()) {
ImGui::Text("Lightning: active (%.0f%%)", lightning->getIntensity() * 100.0f);
}
ImGui::Spacing();
}
}

View file

@ -12,6 +12,7 @@
#include "rendering/clouds.hpp"
#include "rendering/lens_flare.hpp"
#include "rendering/weather.hpp"
#include "rendering/lightning.hpp"
#include "rendering/lighting_manager.hpp"
#include "rendering/sky_system.hpp"
#include "rendering/swim_effects.hpp"
@ -699,6 +700,9 @@ bool Renderer::initialize(core::Window* win) {
weather = std::make_unique<Weather>();
weather->initialize(vkCtx, perFrameSetLayout);
lightning = std::make_unique<Lightning>();
lightning->initialize(vkCtx, perFrameSetLayout);
swimEffects = std::make_unique<SwimEffects>();
swimEffects->initialize(vkCtx, perFrameSetLayout);
@ -802,6 +806,11 @@ void Renderer::shutdown() {
weather.reset();
}
if (lightning) {
lightning->shutdown();
lightning.reset();
}
if (swimEffects) {
swimEffects->shutdown();
swimEffects.reset();
@ -942,6 +951,7 @@ void Renderer::applyMsaaChange() {
if (characterRenderer) characterRenderer->recreatePipelines();
if (questMarkerRenderer) questMarkerRenderer->recreatePipelines();
if (weather) weather->recreatePipelines();
if (lightning) lightning->recreatePipelines();
if (swimEffects) swimEffects->recreatePipelines();
if (mountDust) mountDust->recreatePipelines();
if (chargeEffect) chargeEffect->recreatePipelines();
@ -2856,6 +2866,7 @@ void Renderer::update(float deltaTime) {
// Server-driven weather (SMSG_WEATHER) — authoritative
if (wType == 1) weather->setWeatherType(Weather::Type::RAIN);
else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW);
else if (wType == 3) weather->setWeatherType(Weather::Type::RAIN); // thunderstorm — use rain particles
else weather->setWeatherType(Weather::Type::NONE);
weather->setIntensity(wInt);
} else {
@ -2863,6 +2874,20 @@ void Renderer::update(float deltaTime) {
weather->updateZoneWeather(currentZoneId, deltaTime);
}
weather->setEnabled(true);
// Enable lightning during storms (wType==3) and heavy rain
if (lightning) {
uint32_t wType2 = gh->getWeatherType();
float wInt2 = gh->getWeatherIntensity();
bool stormActive = (wType2 == 3 && wInt2 > 0.1f)
|| (wType2 == 1 && wInt2 > 0.7f);
lightning->setEnabled(stormActive);
if (stormActive) {
// Scale intensity: storm at full, heavy rain proportionally
float lIntensity = (wType2 == 3) ? wInt2 : (wInt2 - 0.7f) / 0.3f;
lightning->setIntensity(lIntensity);
}
}
} else if (weather) {
// No game handler (single-player without network) — zone weather only
weather->updateZoneWeather(currentZoneId, deltaTime);
@ -2932,6 +2957,11 @@ void Renderer::update(float deltaTime) {
weather->update(*camera, deltaTime);
}
// Update lightning (storm / heavy rain)
if (lightning && camera && lightning->isEnabled()) {
lightning->update(deltaTime, *camera);
}
// Update swim effects
if (swimEffects && camera && cameraController && waterRenderer) {
swimEffects->update(*camera, *cameraController, *waterRenderer, deltaTime);
@ -5217,6 +5247,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
if (waterRenderer && camera)
waterRenderer->render(cmd, perFrameSet, *camera, globalTime, false, frameIdx);
if (weather && camera) weather->render(cmd, perFrameSet);
if (lightning && camera && lightning->isEnabled()) lightning->render(cmd, perFrameSet);
if (swimEffects && camera) swimEffects->render(cmd, perFrameSet);
if (mountDust && camera) mountDust->render(cmd, perFrameSet);
if (chargeEffect && camera) chargeEffect->render(cmd, perFrameSet);
@ -5353,6 +5384,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
if (waterRenderer && camera)
waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, frameIdx);
if (weather && camera) weather->render(currentCmd, perFrameSet);
if (lightning && camera && lightning->isEnabled()) lightning->render(currentCmd, perFrameSet);
if (swimEffects && camera) swimEffects->render(currentCmd, perFrameSet);
if (mountDust && camera) mountDust->render(currentCmd, perFrameSet);
if (chargeEffect && camera) chargeEffect->render(currentCmd, perFrameSet);

View file

@ -37,6 +37,22 @@ static uint64_t hashEquipment(const std::vector<game::EquipmentItem>& eq) {
return h;
}
static ImVec4 classColor(uint8_t classId) {
switch (classId) {
case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E
case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA
case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473
case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569
case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF
case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B
case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE
case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0
case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9
case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A
default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f);
}
}
void CharacterScreen::render(game::GameHandler& gameHandler) {
ImGuiViewport* vp = ImGui::GetMainViewport();
const ImVec2 pad(24.0f, 24.0f);
@ -184,7 +200,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 45.0f);
ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthStretch, 1.0f);
ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 1.2f);
ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 55.0f);
ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 1.5f);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
@ -224,10 +240,16 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
ImGui::Text("%s", game::getRaceName(character.race));
ImGui::TableSetColumnIndex(3);
ImGui::Text("%s", game::getClassName(character.characterClass));
ImGui::TextColored(classColor(static_cast<uint8_t>(character.characterClass)), "%s", game::getClassName(character.characterClass));
ImGui::TableSetColumnIndex(4);
ImGui::Text("%d", character.zoneId);
{
std::string zoneName = gameHandler.getWhoAreaName(character.zoneId);
if (!zoneName.empty())
ImGui::TextUnformatted(zoneName.c_str());
else
ImGui::Text("%u", character.zoneId);
}
}
ImGui::EndTable();
@ -325,10 +347,21 @@ void CharacterScreen::render(game::GameHandler& gameHandler) {
ImGui::Text("Level %d", character.level);
ImGui::Text("%s", game::getRaceName(character.race));
ImGui::Text("%s", game::getClassName(character.characterClass));
ImGui::TextColored(classColor(static_cast<uint8_t>(character.characterClass)), "%s", game::getClassName(character.characterClass));
ImGui::Text("%s", game::getGenderName(character.gender));
ImGui::Spacing();
ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId);
{
std::string mapName = gameHandler.getMapName(character.mapId);
std::string zoneName = gameHandler.getWhoAreaName(character.zoneId);
if (!mapName.empty() && !zoneName.empty())
ImGui::Text("%s — %s", mapName.c_str(), zoneName.c_str());
else if (!mapName.empty())
ImGui::Text("%s (Zone %u)", mapName.c_str(), character.zoneId);
else if (!zoneName.empty())
ImGui::Text("Map %u — %s", character.mapId, zoneName.c_str());
else
ImGui::Text("Map %u, Zone %u", character.mapId, character.zoneId);
}
if (character.hasGuild()) {
ImGui::Text("Guild ID: %d", character.guildId);

View file

@ -50,8 +50,8 @@ namespace {
// Build a WoW-format item link string for chat insertion.
// Format: |cff<qualHex>|Hitem:<itemId>:0:0:0:0:0:0:0:0|h[<name>]|h|r
std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) {
static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000"};
uint8_t qi = quality < 6 ? quality : 1;
static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"};
uint8_t qi = quality < 8 ? quality : 1;
char buf[512];
snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r",
kQualHex[qi], itemId, name.c_str());
@ -395,6 +395,10 @@ void GameScreen::render(game::GameHandler& gameHandler) {
gameHandler.setUIErrorCallback([this](const std::string& msg) {
uiErrors_.push_back({msg, 0.0f});
if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin());
// Play error sound for each new error (rate-limited by deque cap of 5)
if (auto* r = core::Application::getInstance().getRenderer()) {
if (auto* sfx = r->getUiSoundManager()) sfx->playError();
}
});
uiErrorCallbackSet_ = true;
}
@ -722,6 +726,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
if (showMinimap_) {
renderMinimapMarkers(gameHandler);
}
renderLogoutCountdown(gameHandler);
renderDeathScreen(gameHandler);
renderReclaimCorpseButton(gameHandler);
renderResurrectDialog(gameHandler);
@ -2756,6 +2761,18 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat");
}
// Active title — shown in gold below the name/level line
{
int32_t titleBit = gameHandler.getChosenTitleBit();
if (titleBit >= 0) {
const std::string titleText = gameHandler.getFormattedTitle(
static_cast<uint32_t>(titleBit));
if (!titleText.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str());
}
}
}
// Try to get real HP/mana from the player entity
auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) {
@ -3758,6 +3775,44 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
}
// Target-of-Target (ToT): show who the current target is targeting
{
uint64_t totGuid = 0;
const auto& tFields = target->getFields();
auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO));
if (itLo != tFields.end()) {
totGuid = itLo->second;
auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI));
if (itHi != tFields.end())
totGuid |= (static_cast<uint64_t>(itHi->second) << 32);
}
if (totGuid != 0) {
auto totEnt = gameHandler.getEntityManager().getEntity(totGuid);
std::string totName;
ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f);
if (totGuid == gameHandler.getPlayerGuid()) {
auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid);
totName = playerEnt ? getEntityName(playerEnt) : "You";
totColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} else if (totEnt) {
totName = getEntityName(totEnt);
uint8_t cid = entityClassId(totEnt.get());
if (cid != 0) totColor = classColorVec4(cid);
}
if (!totName.empty()) {
ImGui::TextDisabled("");
ImGui::SameLine(0, 2);
ImGui::TextColored(totColor, "%s", totName.c_str());
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str());
}
if (ImGui::IsItemClicked()) {
gameHandler.setTarget(totGuid);
}
}
}
}
// Distance
const auto& movement = gameHandler.getMovementInfo();
float dx = target->getX() - movement.x;
@ -4619,6 +4674,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return;
}
if (cmdLower == "clear") {
gameHandler.clearChatHistory();
chatInputBuffer[0] = '\0';
return;
}
// /invite command
if (cmdLower == "invite" && spacePos != std::string::npos) {
std::string targetName = command.substr(spacePos + 1);
@ -6754,14 +6815,24 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) {
if (slot.id == 8690) {
uint32_t mapId = 0; glm::vec3 pos;
if (gameHandler.getHomeBind(mapId, pos)) {
const char* mapName = "Unknown";
switch (mapId) {
case 0: mapName = "Eastern Kingdoms"; break;
case 1: mapName = "Kalimdor"; break;
case 530: mapName = "Outland"; break;
case 571: mapName = "Northrend"; break;
std::string homeLocation;
// Zone name (from zoneId stored in bind point)
uint32_t zoneId = gameHandler.getHomeBindZoneId();
if (zoneId != 0) {
homeLocation = gameHandler.getWhoAreaName(zoneId);
}
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName);
// Fall back to continent name if zone unavailable
if (homeLocation.empty()) {
switch (mapId) {
case 0: homeLocation = "Eastern Kingdoms"; break;
case 1: homeLocation = "Kalimdor"; break;
case 530: homeLocation = "Outland"; break;
case 571: homeLocation = "Northrend"; break;
default: homeLocation = "Unknown"; break;
}
}
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f),
"Home: %s", homeLocation.c_str());
}
}
if (outOfRange) {
@ -8287,7 +8358,13 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
break;
case game::CombatTextEntry::ENERGIZE:
snprintf(text, sizeof(text), "+%d", entry.amount);
color = ImVec4(0.3f, 0.6f, 1.0f, alpha); // Blue for mana/energy
switch (entry.powerType) {
case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; // Rage: red
case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; // Focus: orange
case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; // Energy: yellow
case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; // Runic Power: teal
default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue
}
break;
case game::CombatTextEntry::XP_GAIN:
snprintf(text, sizeof(text), "+%d XP", entry.amount);
@ -10674,9 +10751,11 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) {
ImVec4(0.0f, 0.44f, 0.87f, 1.0f),// 3=rare (blue)
ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4=epic (purple)
ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5=legendary (orange)
ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 6=artifact (light gold)
ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 7=heirloom (light gold)
};
uint8_t q = roll.itemQuality;
ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1];
ImVec4 col = (q < 8) ? kQualityColors[q] : kQualityColors[1];
// Countdown bar
{
@ -10716,7 +10795,14 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) {
ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24));
ImGui::SameLine();
}
ImGui::TextColored(col, "[%s]", roll.itemName.c_str());
// Prefer live item info (arrives via SMSG_ITEM_QUERY_SINGLE_RESPONSE after the
// roll popup opens); fall back to the name cached at SMSG_LOOT_START_ROLL time.
const char* displayName = (rollInfo && rollInfo->valid && !rollInfo->name.empty())
? rollInfo->name.c_str()
: roll.itemName.c_str();
if (rollInfo && rollInfo->valid)
col = (rollInfo->quality < 8) ? kQualityColors[rollInfo->quality] : kQualityColors[1];
ImGui::TextColored(col, "[%s]", displayName);
if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) {
inventoryScreen.renderItemTooltip(*rollInfo);
}
@ -12376,8 +12462,9 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
// Show item tooltip on hover
if (hovered && info && info->valid) {
inventoryScreen.renderItemTooltip(*info);
} else if (hovered && !itemName.empty() && itemName[0] != 'I') {
ImGui::SetTooltip("%s", itemName.c_str());
} else if (hovered && info && !info->name.empty()) {
// Item info received but not yet fully valid — show name at minimum
ImGui::SetTooltip("%s", info->name.c_str());
}
ImDrawList* drawList = ImGui::GetWindowDrawList();
@ -13130,7 +13217,16 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
gameHandler.repairAll(vendor.vendorGuid, false);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Repair all equipped items");
ImGui::SetTooltip("Repair all equipped items using your gold");
}
if (gameHandler.isInGuild()) {
ImGui::SameLine();
if (ImGui::SmallButton("Repair (Guild)")) {
gameHandler.repairAll(vendor.vendorGuid, true);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Repair all equipped items using guild bank funds");
}
}
}
ImGui::Separator();
@ -14016,6 +14112,68 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) {
}
}
// ============================================================
// Logout Countdown
// ============================================================
void GameScreen::renderLogoutCountdown(game::GameHandler& gameHandler) {
if (!gameHandler.isLoggingOut()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
constexpr float W = 280.0f;
constexpr float H = 80.0f;
ImGui::SetNextWindowPos(ImVec2((screenW - W) * 0.5f, screenH * 0.5f - H * 0.5f - 60.0f),
ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(W, H), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.88f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.8f, 1.0f));
if (ImGui::Begin("##LogoutCountdown", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) {
float cd = gameHandler.getLogoutCountdown();
if (cd > 0.0f) {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 6.0f);
ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out in 20s...").x) * 0.5f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f),
"Logging out in %ds...", static_cast<int>(std::ceil(cd)));
// Progress bar (20 second countdown)
float frac = 1.0f - std::min(cd / 20.0f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.5f, 0.5f, 0.9f, 1.0f));
ImGui::ProgressBar(frac, ImVec2(-1.0f, 8.0f), "");
ImGui::PopStyleColor();
ImGui::Spacing();
} else {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 14.0f);
ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out...").x) * 0.5f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "Logging out...");
ImGui::Spacing();
}
// Cancel button — only while countdown is still running
if (cd > 0.0f) {
float btnW = 100.0f;
ImGui::SetCursorPosX((W - btnW) * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f));
if (ImGui::Button("Cancel", ImVec2(btnW, 0))) {
gameHandler.cancelLogout();
}
ImGui::PopStyleColor(2);
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
// ============================================================
// Death Screen
// ============================================================
@ -16557,8 +16715,15 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.5f);
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"In Queue: %s", bgName.c_str());
if (slot.avgWaitTimeSec > 0) {
int avgMin = static_cast<int>(slot.avgWaitTimeSec) / 60;
int avgSec = static_cast<int>(slot.avgWaitTimeSec) % 60;
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec);
} else {
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"In Queue: %s", bgName.c_str());
}
}
ImGui::End();
nextIndicatorY += kIndicatorH;
@ -16590,6 +16755,47 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
}
}
// Calendar pending invites indicator (WotLK only)
{
auto* expReg = core::Application::getInstance().getExpansionRegistry();
bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk";
if (isWotLK) {
uint32_t calPending = gameHandler.getCalendarPendingInvites();
if (calPending > 0) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 2.0f);
char calBuf[48];
snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s",
calPending, calPending == 1 ? "" : "s");
ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf);
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
}
}
// Taxi flight indicator — shown while on a flight path
if (gameHandler.isOnTaxiFlight()) {
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) {
const std::string& dest = gameHandler.getTaxiDestName();
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.0f);
if (dest.empty()) {
ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight");
} else {
char buf[64];
snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str());
ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf);
}
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
// Latency indicator — centered at top of screen
uint32_t latMs = gameHandler.getLatencyMs();
if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) {
@ -18843,6 +19049,8 @@ void GameScreen::renderItemLootToasts() {
IM_COL32( 0, 112, 221, 255), // 3 blue (rare)
IM_COL32(163, 53, 238, 255), // 4 purple (epic)
IM_COL32(255, 128, 0, 255), // 5 orange (legendary)
IM_COL32(230, 204, 128, 255), // 6 light gold (artifact)
IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom)
};
// Stack at bottom-left above action bars; each item is 24 px tall
@ -18881,7 +19089,7 @@ void GameScreen::renderItemLootToasts() {
IM_COL32(12, 12, 12, bgA), 3.0f);
// Quality colour accent bar on left edge (3px wide)
ImU32 qualCol = kQualityColors[std::min(static_cast<uint32_t>(5u), toast.quality)];
ImU32 qualCol = kQualityColors[std::min(static_cast<uint32_t>(7u), toast.quality)];
ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast<uint32_t>(fgA) << 24u);
bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f);
@ -19315,7 +19523,12 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) {
uint32_t qMs = gameHandler.getLfgTimeInQueueMs();
int qMin = static_cast<int>(qMs / 60000);
int qSec = static_cast<int>((qMs % 60000) / 1000);
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec);
std::string dName = gameHandler.getCurrentLfgDungeonName();
if (!dName.empty())
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f),
"Status: In queue for %s (%d:%02d)", dName.c_str(), qMin, qSec);
else
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec);
if (avgSec >= 0) {
int aMin = avgSec / 60;
int aSec = avgSec % 60;
@ -19324,18 +19537,33 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) {
}
break;
}
case LfgState::Proposal:
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!");
case LfgState::Proposal: {
std::string dName = gameHandler.getCurrentLfgDungeonName();
if (!dName.empty())
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found for %s!", dName.c_str());
else
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!");
break;
}
case LfgState::Boot:
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Status: Vote kick in progress");
break;
case LfgState::InDungeon:
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon");
case LfgState::InDungeon: {
std::string dName = gameHandler.getCurrentLfgDungeonName();
if (!dName.empty())
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon (%s)", dName.c_str());
else
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon");
break;
case LfgState::FinishedDungeon:
ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete");
}
case LfgState::FinishedDungeon: {
std::string dName = gameHandler.getCurrentLfgDungeonName();
if (!dName.empty())
ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: %s complete", dName.c_str());
else
ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete");
break;
}
case LfgState::RaidBrowser:
ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser");
break;
@ -19345,8 +19573,13 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) {
// ---- Proposal accept/decline ----
if (state == LfgState::Proposal) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f),
"A group has been found for your dungeon!");
std::string dName = gameHandler.getCurrentLfgDungeonName();
if (!dName.empty())
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f),
"A group has been found for %s!", dName.c_str());
else
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f),
"A group has been found for your dungeon!");
ImGui::Spacing();
if (ImGui::Button("Accept", ImVec2(120, 0))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true);
@ -19517,24 +19750,6 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) {
if (lockouts.empty()) {
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active instance lockouts.");
} else {
// Build map name lookup from Map.dbc (cached after first call)
static std::unordered_map<uint32_t, std::string> sMapNames;
static bool sMapNamesLoaded = false;
if (!sMapNamesLoaded) {
sMapNamesLoaded = true;
if (auto* am = core::Application::getInstance().getAssetManager()) {
if (auto dbc = am->loadDBC("Map.dbc"); dbc && dbc->isLoaded()) {
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t id = dbc->getUInt32(i, 0);
// Field 2 = MapName_enUS (first localized), field 1 = InternalName
std::string name = dbc->getString(i, 2);
if (name.empty()) name = dbc->getString(i, 1);
if (!name.empty()) sMapNames[id] = std::move(name);
}
}
}
}
auto difficultyLabel = [](uint32_t diff) -> const char* {
switch (diff) {
case 0: return "Normal";
@ -19560,11 +19775,11 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) {
for (const auto& lo : lockouts) {
ImGui::TableNextRow();
// Instance name
// Instance name — use GameHandler's Map.dbc cache (avoids duplicate DBC load)
ImGui::TableSetColumnIndex(0);
auto it = sMapNames.find(lo.mapId);
if (it != sMapNames.end()) {
ImGui::TextUnformatted(it->second.c_str());
std::string mapName = gameHandler.getMapName(lo.mapId);
if (!mapName.empty()) {
ImGui::TextUnformatted(mapName.c_str());
} else {
ImGui::Text("Map %u", lo.mapId);
}
@ -19972,31 +20187,60 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) {
color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f);
break;
case T::MISS:
snprintf(desc, sizeof(desc), "%s misses %s", src, tgt);
if (spell)
snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt);
else
snprintf(desc, sizeof(desc), "%s misses %s", src, tgt);
color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f);
break;
case T::DODGE:
snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src);
if (spell)
snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src);
color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f);
break;
case T::PARRY:
snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src);
if (spell)
snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell);
else
snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src);
color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f);
break;
case T::BLOCK:
snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount);
if (spell)
snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount);
else
snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount);
color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f);
break;
case T::IMMUNE:
snprintf(desc, sizeof(desc), "%s is immune", tgt);
if (spell)
snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell);
else
snprintf(desc, sizeof(desc), "%s is immune", tgt);
color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f);
break;
case T::ABSORB:
snprintf(desc, sizeof(desc), "%d absorbed", e.amount);
if (spell && e.amount > 0)
snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount);
else if (spell)
snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell);
else if (e.amount > 0)
snprintf(desc, sizeof(desc), "%d absorbed", e.amount);
else
snprintf(desc, sizeof(desc), "Absorbed");
color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f);
break;
case T::RESIST:
snprintf(desc, sizeof(desc), "%d resisted", e.amount);
if (spell && e.amount > 0)
snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount);
else if (spell)
snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell);
else if (e.amount > 0)
snprintf(desc, sizeof(desc), "%d resisted", e.amount);
else
snprintf(desc, sizeof(desc), "Resisted");
color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f);
break;
case T::ENVIRONMENTAL:
@ -20021,6 +20265,28 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) {
snprintf(desc, sizeof(desc), "Proc triggered");
color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f);
break;
case T::DISPEL:
if (spell && e.isPlayerSource)
snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt);
else if (spell)
snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt);
else if (e.isPlayerSource)
snprintf(desc, sizeof(desc), "You dispel from %s", tgt);
else
snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt);
color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f);
break;
case T::INTERRUPT:
if (spell && e.isPlayerSource)
snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell);
else if (spell)
snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell);
else if (e.isPlayerSource)
snprintf(desc, sizeof(desc), "You interrupt %s", tgt);
else
snprintf(desc, sizeof(desc), "%s interrupted", tgt);
color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f);
break;
default:
snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount);
color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);

View file

@ -17,6 +17,7 @@
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <unordered_set>
namespace wowee {
@ -102,6 +103,8 @@ ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) {
case game::ItemQuality::RARE: return ImVec4(0.0f, 0.44f, 0.87f, 1.0f); // Blue
case game::ItemQuality::EPIC: return ImVec4(0.64f, 0.21f, 0.93f, 1.0f); // Purple
case game::ItemQuality::LEGENDARY: return ImVec4(1.0f, 0.50f, 0.0f, 1.0f); // Orange
case game::ItemQuality::ARTIFACT: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold
case game::ItemQuality::HEIRLOOM: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold
default: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
}
}
@ -1161,7 +1164,7 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr;
int32_t resists[6];
for (int i = 0; i < 6; ++i) resists[i] = gameHandler.getResistance(i + 1);
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists);
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists, &gameHandler);
// Played time (shown if available, fetched on character screen open)
uint32_t totalSec = gameHandler.getTotalTimePlayed();
@ -1242,18 +1245,35 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
snprintf(label, sizeof(label), "%s", name.c_str());
}
// Show progress bar with value/max overlay
// Effective value includes temporary and permanent bonuses
uint16_t effective = skill->effectiveValue();
uint16_t bonus = skill->bonusTemp + skill->bonusPerm;
// Progress bar reflects effective / max; cap visual fill at 1.0
float ratio = (skill->maxValue > 0)
? static_cast<float>(skill->value) / static_cast<float>(skill->maxValue)
? std::min(1.0f, static_cast<float>(effective) / static_cast<float>(skill->maxValue))
: 0.0f;
char overlay[64];
snprintf(overlay, sizeof(overlay), "%u / %u", skill->value, skill->maxValue);
if (bonus > 0)
snprintf(overlay, sizeof(overlay), "%u / %u (+%u)", effective, skill->maxValue, bonus);
else
snprintf(overlay, sizeof(overlay), "%u / %u", effective, skill->maxValue);
ImGui::Text("%s", label);
// Gold name when maxed out, cyan when buffed above base, default otherwise
bool isMaxed = (effective >= skill->maxValue && skill->maxValue > 0);
bool isBuffed = (bonus > 0);
ImVec4 nameColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f)
: isBuffed ? ImVec4(0.4f, 0.9f, 1.0f, 1.0f)
: ImVec4(0.85f, 0.85f, 0.85f, 1.0f);
ImGui::TextColored(nameColor, "%s", label);
ImGui::SameLine(180.0f);
ImGui::SetNextItemWidth(-1.0f);
// Bar color: gold when maxed, green otherwise
ImVec4 barColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) : ImVec4(0.2f, 0.7f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor);
ImGui::ProgressBar(ratio, ImVec2(0, 14.0f), overlay);
ImGui::PopStyleColor();
}
}
}
@ -1443,6 +1463,8 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId);
bool isWatched = (factionId == watchedFactionId);
ImGui::PushID(static_cast<int>(factionId));
// Faction name + tier label on same line; mark at-war and watched factions
ImGui::TextColored(tier.color, "[%s]", tier.name);
ImGui::SameLine(90.0f);
@ -1478,7 +1500,23 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
ImGui::SetNextItemWidth(-1.0f);
ImGui::ProgressBar(ratio, ImVec2(0, 12.0f), overlay);
ImGui::PopStyleColor();
// Right-click context menu on the progress bar
if (ImGui::BeginPopupContextItem("##RepCtx")) {
ImGui::TextDisabled("%s", displayName);
ImGui::Separator();
if (isWatched) {
if (ImGui::MenuItem("Untrack"))
gameHandler.setWatchedFactionId(0);
} else {
if (ImGui::MenuItem("Track on Rep Bar"))
gameHandler.setWatchedFactionId(factionId);
}
ImGui::EndPopup();
}
ImGui::Spacing();
ImGui::PopID();
}
ImGui::EndChild();
@ -1606,7 +1644,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel,
int32_t serverArmor, const int32_t* serverStats,
const int32_t* serverResists) {
const int32_t* serverResists,
const game::GameHandler* gh) {
// 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
@ -1776,6 +1815,174 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
}
}
}
// Server-authoritative combat stats (WotLK update fields — only shown when received)
if (gh) {
int32_t meleeAP = gh->getMeleeAttackPower();
int32_t rangedAP = gh->getRangedAttackPower();
int32_t spellPow = gh->getSpellPower();
int32_t healPow = gh->getHealingPower();
float dodgePct = gh->getDodgePct();
float parryPct = gh->getParryPct();
float blockPct = gh->getBlockPct();
float critPct = gh->getCritPct();
float rCritPct = gh->getRangedCritPct();
float sCritPct = gh->getSpellCritPct(1); // Holy school (avg proxy for spell crit)
// Hit rating: CR_HIT_MELEE=5, CR_HIT_RANGED=6, CR_HIT_SPELL=7
// Haste rating: CR_HASTE_MELEE=17, CR_HASTE_RANGED=18, CR_HASTE_SPELL=19
// Other: CR_EXPERTISE=23, CR_ARMOR_PENETRATION=24, CR_CRIT_TAKEN_MELEE=14
int32_t hitRating = gh->getCombatRating(5);
int32_t hitRangedR = gh->getCombatRating(6);
int32_t hitSpellR = gh->getCombatRating(7);
int32_t expertiseR = gh->getCombatRating(23);
int32_t hasteR = gh->getCombatRating(17);
int32_t hasteRangedR = gh->getCombatRating(18);
int32_t hasteSpellR = gh->getCombatRating(19);
int32_t armorPenR = gh->getCombatRating(24);
int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience
bool hasAny = (meleeAP >= 0 || spellPow >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f ||
blockPct >= 0.0f || critPct >= 0.0f || hitRating >= 0);
if (hasAny) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Combat");
ImVec4 cyan(0.5f, 0.9f, 1.0f, 1.0f);
if (meleeAP >= 0) ImGui::TextColored(cyan, "Attack Power: %d", meleeAP);
if (rangedAP >= 0 && rangedAP != meleeAP)
ImGui::TextColored(cyan, "Ranged Attack Power: %d", rangedAP);
if (spellPow >= 0) ImGui::TextColored(cyan, "Spell Power: %d", spellPow);
if (healPow >= 0 && healPow != spellPow)
ImGui::TextColored(cyan, "Healing Power: %d", healPow);
if (dodgePct >= 0.0f) ImGui::TextColored(cyan, "Dodge: %.2f%%", dodgePct);
if (parryPct >= 0.0f) ImGui::TextColored(cyan, "Parry: %.2f%%", parryPct);
if (blockPct >= 0.0f) ImGui::TextColored(cyan, "Block: %.2f%%", blockPct);
if (critPct >= 0.0f) ImGui::TextColored(cyan, "Melee Crit: %.2f%%", critPct);
if (rCritPct >= 0.0f) ImGui::TextColored(cyan, "Ranged Crit: %.2f%%", rCritPct);
if (sCritPct >= 0.0f) ImGui::TextColored(cyan, "Spell Crit: %.2f%%", sCritPct);
// Combat ratings with percentage conversion (WotLK level-80 divisors scaled by level).
// Formula: pct = rating / (divisorAt80 * pow(level/80.0, 0.93))
// Level-80 divisors derived from gtCombatRatings.dbc (well-known WotLK constants):
// Hit: 26.23, Expertise: 8.19/expertise (0.25% each),
// Haste: 32.79, ArmorPen: 13.99, Resilience: 94.27
uint32_t level = playerLevel > 0 ? playerLevel : gh->getPlayerLevel();
if (level == 0) level = 80;
double lvlScale = level <= 80
? std::pow(static_cast<double>(level) / 80.0, 0.93)
: 1.0;
auto ratingPct = [&](int32_t rating, double divisorAt80) -> float {
if (rating < 0 || divisorAt80 <= 0.0) return -1.0f;
double d = divisorAt80 * lvlScale;
return static_cast<float>(rating / d);
};
if (hitRating >= 0) {
float pct = ratingPct(hitRating, 26.23);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Hit Rating: %d (%.2f%%)", hitRating, pct);
else
ImGui::TextColored(cyan, "Hit Rating: %d", hitRating);
}
// Show ranged/spell hit only when they differ from melee hit
if (hitRangedR >= 0 && hitRangedR != hitRating) {
float pct = ratingPct(hitRangedR, 26.23);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Ranged Hit Rating: %d (%.2f%%)", hitRangedR, pct);
else
ImGui::TextColored(cyan, "Ranged Hit Rating: %d", hitRangedR);
}
if (hitSpellR >= 0 && hitSpellR != hitRating) {
// Spell hit cap at 17% (446 rating at 80); divisor same as melee hit
float pct = ratingPct(hitSpellR, 26.23);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Spell Hit Rating: %d (%.2f%%)", hitSpellR, pct);
else
ImGui::TextColored(cyan, "Spell Hit Rating: %d", hitSpellR);
}
if (expertiseR >= 0) {
// Each expertise point reduces dodge and parry chance by 0.25%
// expertise_points = rating / 8.19
float exp_pts = ratingPct(expertiseR, 8.19);
if (exp_pts >= 0.0f) {
float exp_pct = exp_pts * 0.25f; // % dodge/parry reduction
ImGui::TextColored(cyan, "Expertise: %d (%.1f / %.2f%%)",
expertiseR, exp_pts, exp_pct);
} else {
ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR);
}
}
if (hasteR >= 0) {
float pct = ratingPct(hasteR, 32.79);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Haste Rating: %d (%.2f%%)", hasteR, pct);
else
ImGui::TextColored(cyan, "Haste Rating: %d", hasteR);
}
if (hasteRangedR >= 0 && hasteRangedR != hasteR) {
float pct = ratingPct(hasteRangedR, 32.79);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Ranged Haste Rating: %d (%.2f%%)", hasteRangedR, pct);
else
ImGui::TextColored(cyan, "Ranged Haste Rating: %d", hasteRangedR);
}
if (hasteSpellR >= 0 && hasteSpellR != hasteR) {
float pct = ratingPct(hasteSpellR, 32.79);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Spell Haste Rating: %d (%.2f%%)", hasteSpellR, pct);
else
ImGui::TextColored(cyan, "Spell Haste Rating: %d", hasteSpellR);
}
if (armorPenR >= 0) {
float pct = ratingPct(armorPenR, 13.99);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Armor Pen: %d (%.2f%%)", armorPenR, pct);
else
ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR);
}
if (resilR >= 0) {
// Resilience: reduces crit chance against you by pct%, and crit damage by 2*pct%
float pct = ratingPct(resilR, 94.27);
if (pct >= 0.0f)
ImGui::TextColored(cyan, "Resilience: %d (%.2f%%)", resilR, pct);
else
ImGui::TextColored(cyan, "Resilience: %d", resilR);
}
}
// Movement speeds (always show when non-default)
{
constexpr float kBaseRun = 7.0f;
constexpr float kBaseFlight = 7.0f;
float runSpeed = gh->getServerRunSpeed();
float flightSpeed = gh->getServerFlightSpeed();
float swimSpeed = gh->getServerSwimSpeed();
bool showRun = runSpeed > 0.0f && std::fabs(runSpeed - kBaseRun) > 0.05f;
bool showFlight = flightSpeed > 0.0f && std::fabs(flightSpeed - kBaseFlight) > 0.05f;
bool showSwim = swimSpeed > 0.0f && std::fabs(swimSpeed - 4.722f) > 0.05f;
if (showRun || showFlight || showSwim) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Movement");
ImVec4 speedColor(0.6f, 1.0f, 0.8f, 1.0f);
if (showRun) {
float pct = (runSpeed / kBaseRun) * 100.0f;
ImGui::TextColored(speedColor, "Run Speed: %.1f%%", pct);
}
if (showFlight) {
float pct = (flightSpeed / kBaseFlight) * 100.0f;
ImGui::TextColored(speedColor, "Flight Speed: %.1f%%", pct);
}
if (showSwim) {
float pct = (swimSpeed / 4.722f) * 100.0f;
ImGui::TextColored(speedColor, "Swim Speed: %.1f%%", pct);
}
}
}
}
}
void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {
@ -2080,6 +2287,8 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
case game::ItemQuality::RARE: qualHex = "0070dd"; break;
case game::ItemQuality::EPIC: qualHex = "a335ee"; break;
case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break;
case game::ItemQuality::ARTIFACT: qualHex = "e6cc80"; break;
case game::ItemQuality::HEIRLOOM: qualHex = "e6cc80"; break;
default: break;
}
char linkBuf[512];
@ -2106,6 +2315,23 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel);
}
// Heroic / Unique / Unique-Equipped indicators
if (gameHandler_) {
const auto* qi = gameHandler_->getItemInfo(item.itemId);
if (qi && qi->valid) {
constexpr uint32_t kFlagHeroic = 0x8;
constexpr uint32_t kFlagUniqueEquipped = 0x1000000;
if (qi->itemFlags & kFlagHeroic) {
ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic");
}
if (qi->maxCount == 1) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique");
} else if (qi->itemFlags & kFlagUniqueEquipped) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped");
}
}
}
// Binding type
switch (item.bindType) {
case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break;
@ -2119,16 +2345,24 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
uint32_t mapId = 0;
glm::vec3 pos;
if (gameHandler_->getHomeBind(mapId, pos)) {
const char* mapName = "Unknown";
switch (mapId) {
case 0: mapName = "Eastern Kingdoms"; break;
case 1: mapName = "Kalimdor"; break;
case 530: mapName = "Outland"; break;
case 571: mapName = "Northrend"; break;
case 13: mapName = "Test"; break;
case 169: mapName = "Emerald Dream"; break;
std::string homeLocation;
// Prefer the specific zone name from the bind-point zone ID
uint32_t zoneId = gameHandler_->getHomeBindZoneId();
if (zoneId != 0)
homeLocation = gameHandler_->getWhoAreaName(zoneId);
// Fall back to continent name if zone unavailable
if (homeLocation.empty()) {
switch (mapId) {
case 0: homeLocation = "Eastern Kingdoms"; break;
case 1: homeLocation = "Kalimdor"; break;
case 530: homeLocation = "Outland"; break;
case 571: homeLocation = "Northrend"; break;
case 13: homeLocation = "Test"; break;
case 169: homeLocation = "Emerald Dream"; break;
default: homeLocation = "Unknown"; break;
}
}
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName);
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", homeLocation.c_str());
} else {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Home: not set");
}
@ -2206,6 +2440,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
ImGui::Text("%d Armor", item.armor);
}
// Elemental resistances from item query cache (fire resist gear, nature resist gear, etc.)
if (gameHandler_) {
const auto* qi = gameHandler_->getItemInfo(item.itemId);
if (qi && qi->valid) {
const int32_t resValsI[6] = { qi->holyRes, qi->fireRes, qi->natureRes,
qi->frostRes, qi->shadowRes, qi->arcaneRes };
static const char* resLabelsI[6] = {
"Holy Resistance", "Fire Resistance", "Nature Resistance",
"Frost Resistance", "Shadow Resistance", "Arcane Resistance"
};
for (int i = 0; i < 6; ++i)
if (resValsI[i] > 0) ImGui::Text("+%d %s", resValsI[i], resLabelsI[i]);
}
}
auto appendBonus = [](std::string& out, int32_t val, const char* shortName) {
if (val <= 0) return;
if (!out.empty()) out += " ";
@ -2290,10 +2539,12 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
if (sp.spellId == 0) continue;
const char* trigger = nullptr;
switch (sp.spellTrigger) {
case 0: trigger = "Use"; break;
case 1: trigger = "Equip"; break;
case 2: trigger = "Chance on Hit"; break;
case 6: trigger = "Soulstone"; break;
case 0: trigger = "Use"; break; // on use
case 1: trigger = "Equip"; break; // on equip
case 2: trigger = "Chance on Hit"; break; // proc on melee hit
case 4: trigger = "Use"; break; // soulstone (still shows as Use)
case 5: trigger = "Use"; break; // on use, no delay
case 6: trigger = "Use"; break; // learn spell (recipe/pattern)
default: break;
}
if (!trigger) continue;
@ -2312,6 +2563,258 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
}
// Skill / reputation requirements from item query cache
if (gameHandler_) {
const auto* qInfo = gameHandler_->getItemInfo(item.itemId);
if (qInfo && qInfo->valid) {
if (qInfo->requiredSkill != 0 && qInfo->requiredSkillRank > 0) {
static std::unordered_map<uint32_t, std::string> s_skillNamesB;
static bool s_skillNamesLoadedB = false;
if (!s_skillNamesLoadedB && assetManager_) {
s_skillNamesLoadedB = true;
auto dbc = assetManager_->loadDBC("SkillLine.dbc");
if (dbc && dbc->isLoaded()) {
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
uint32_t idF = layout ? (*layout)["ID"] : 0;
uint32_t nameF = layout ? (*layout)["Name"] : 2;
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t sid = dbc->getUInt32(r, idF);
if (!sid) continue;
std::string sname = dbc->getString(r, nameF);
if (!sname.empty()) s_skillNamesB[sid] = std::move(sname);
}
}
}
uint32_t playerSkillVal = 0;
const auto& skills = gameHandler_->getPlayerSkills();
auto skPit = skills.find(qInfo->requiredSkill);
if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue();
bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= qInfo->requiredSkillRank);
ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f);
auto skIt = s_skillNamesB.find(qInfo->requiredSkill);
if (skIt != s_skillNamesB.end())
ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), qInfo->requiredSkillRank);
else
ImGui::TextColored(skColor, "Requires Skill %u (%u)", qInfo->requiredSkill, qInfo->requiredSkillRank);
}
if (qInfo->requiredReputationFaction != 0 && qInfo->requiredReputationRank > 0) {
static std::unordered_map<uint32_t, std::string> s_factionNamesB;
static bool s_factionNamesLoadedB = false;
if (!s_factionNamesLoadedB && assetManager_) {
s_factionNamesLoadedB = true;
auto dbc = assetManager_->loadDBC("Faction.dbc");
if (dbc && dbc->isLoaded()) {
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr;
uint32_t idF = layout ? (*layout)["ID"] : 0;
uint32_t nameF = layout ? (*layout)["Name"] : 20;
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t fid = dbc->getUInt32(r, idF);
if (!fid) continue;
std::string fname = dbc->getString(r, nameF);
if (!fname.empty()) s_factionNamesB[fid] = std::move(fname);
}
}
}
static const char* kRepRankNamesB[] = {
"Hated","Hostile","Unfriendly","Neutral","Friendly","Honored","Revered","Exalted"
};
const char* rankName = (qInfo->requiredReputationRank < 8)
? kRepRankNamesB[qInfo->requiredReputationRank] : "Unknown";
auto fIt = s_factionNamesB.find(qInfo->requiredReputationFaction);
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s",
rankName,
fIt != s_factionNamesB.end() ? fIt->second.c_str() : "Unknown Faction");
}
// Class restriction
if (qInfo->allowableClass != 0) {
static const struct { uint32_t mask; const char* name; } kClassesB[] = {
{ 1,"Warrior" },{ 2,"Paladin" },{ 4,"Hunter" },{ 8,"Rogue" },
{ 16,"Priest" },{ 32,"Death Knight" },{ 64,"Shaman" },
{ 128,"Mage" },{ 256,"Warlock" },{ 1024,"Druid" },
};
int mc = 0;
for (const auto& kc : kClassesB) if (qInfo->allowableClass & kc.mask) ++mc;
if (mc > 0 && mc < 10) {
char buf[128] = "Classes: "; bool first = true;
for (const auto& kc : kClassesB) {
if (!(qInfo->allowableClass & kc.mask)) continue;
if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1);
strncat(buf, kc.name, sizeof(buf)-strlen(buf)-1);
first = false;
}
uint8_t pc = gameHandler_->getPlayerClass();
uint32_t pm = (pc > 0 && pc <= 10) ? (1u << (pc-1)) : 0;
bool ok = (pm == 0 || (qInfo->allowableClass & pm));
ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf);
}
}
// Race restriction
if (qInfo->allowableRace != 0) {
static const struct { uint32_t mask; const char* name; } kRacesB[] = {
{ 1,"Human" },{ 2,"Orc" },{ 4,"Dwarf" },{ 8,"Night Elf" },
{ 16,"Undead" },{ 32,"Tauren" },{ 64,"Gnome" },{ 128,"Troll" },
{ 512,"Blood Elf" },{ 1024,"Draenei" },
};
constexpr uint32_t kAll = 1|2|4|8|16|32|64|128|512|1024;
if ((qInfo->allowableRace & kAll) != kAll) {
int mc = 0;
for (const auto& kr : kRacesB) if (qInfo->allowableRace & kr.mask) ++mc;
if (mc > 0) {
char buf[160] = "Races: "; bool first = true;
for (const auto& kr : kRacesB) {
if (!(qInfo->allowableRace & kr.mask)) continue;
if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1);
strncat(buf, kr.name, sizeof(buf)-strlen(buf)-1);
first = false;
}
uint8_t pr = gameHandler_->getPlayerRace();
uint32_t pm = (pr > 0 && pr <= 11) ? (1u << (pr-1)) : 0;
bool ok = (pm == 0 || (qInfo->allowableRace & pm));
ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf);
}
}
}
}
}
// Gem socket slots and item set — look up from query cache
if (gameHandler_) {
const auto* qi2 = gameHandler_->getItemInfo(item.itemId);
if (qi2 && qi2->valid) {
// Gem sockets
{
static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = {
{ 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } },
{ 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } },
{ 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } },
{ 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } },
};
bool hasSocket = false;
for (int i = 0; i < 3; ++i) {
if (qi2->socketColor[i] == 0) continue;
if (!hasSocket) { ImGui::Spacing(); hasSocket = true; }
for (const auto& st : kSocketTypes) {
if (qi2->socketColor[i] & st.mask) {
ImGui::TextColored(st.col, "%s", st.label);
break;
}
}
}
if (hasSocket && qi2->socketBonus != 0) {
static std::unordered_map<uint32_t, std::string> s_enchantNamesD;
static bool s_enchantNamesLoadedD = false;
if (!s_enchantNamesLoadedD && assetManager_) {
s_enchantNamesLoadedD = true;
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
if (dbc && dbc->isLoaded()) {
const auto* lay = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
uint32_t nameField = lay ? lay->field("Name") : 8u;
if (nameField == 0xFFFFFFFF) nameField = 8;
uint32_t fc = dbc->getFieldCount();
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t eid = dbc->getUInt32(r, 0);
if (eid == 0 || nameField >= fc) continue;
std::string ename = dbc->getString(r, nameField);
if (!ename.empty()) s_enchantNamesD[eid] = std::move(ename);
}
}
}
auto enchIt = s_enchantNamesD.find(qi2->socketBonus);
if (enchIt != s_enchantNamesD.end())
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str());
else
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", qi2->socketBonus);
}
}
// Item set membership
if (qi2->itemSetId != 0) {
struct SetEntryD {
std::string name;
std::array<uint32_t, 10> itemIds{};
std::array<uint32_t, 10> spellIds{};
std::array<uint32_t, 10> thresholds{};
};
static std::unordered_map<uint32_t, SetEntryD> s_setDataD;
static bool s_setDataLoadedD = false;
if (!s_setDataLoadedD && assetManager_) {
s_setDataLoadedD = true;
auto dbc = assetManager_->loadDBC("ItemSet.dbc");
if (dbc && dbc->isLoaded()) {
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr;
auto lf = [&](const char* k, uint32_t def) -> uint32_t {
return layout ? (*layout)[k] : def;
};
uint32_t idF = lf("ID", 0), nameF = lf("Name", 1);
static const char* itemKeys[10] = {
"Item0","Item1","Item2","Item3","Item4",
"Item5","Item6","Item7","Item8","Item9" };
static const char* spellKeys[10] = {
"Spell0","Spell1","Spell2","Spell3","Spell4",
"Spell5","Spell6","Spell7","Spell8","Spell9" };
static const char* thrKeys[10] = {
"Threshold0","Threshold1","Threshold2","Threshold3","Threshold4",
"Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" };
uint32_t itemFB[10], spellFB[10], thrFB[10];
for (int i = 0; i < 10; ++i) {
itemFB[i] = 18+i; spellFB[i] = 28+i; thrFB[i] = 38+i;
}
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t id = dbc->getUInt32(r, idF);
if (!id) continue;
SetEntryD e;
e.name = dbc->getString(r, nameF);
for (int i = 0; i < 10; ++i) {
e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFB[i]);
e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFB[i]);
e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFB[i]);
}
s_setDataD[id] = std::move(e);
}
}
}
auto setIt = s_setDataD.find(qi2->itemSetId);
ImGui::Spacing();
if (setIt != s_setDataD.end()) {
const SetEntryD& se = setIt->second;
int equipped = 0, total = 0;
for (int i = 0; i < 10; ++i) {
if (se.itemIds[i] == 0) continue;
++total;
if (inventory) {
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& eSlot = inventory->getEquipSlot(static_cast<game::EquipSlot>(s));
if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; }
}
}
}
if (total > 0) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f),
"%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total);
} else if (!se.name.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str());
}
for (int i = 0; i < 10; ++i) {
if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue;
const std::string& bname = gameHandler_->getSpellName(se.spellIds[i]);
bool active = (equipped >= static_cast<int>(se.thresholds[i]));
ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f)
: ImVec4(0.55f, 0.55f, 0.55f, 1.0f);
if (!bname.empty())
ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str());
else
ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]);
}
} else {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", qi2->itemSetId);
}
}
}
}
// "Begins a Quest" line (shown in yellow-green like the game)
if (item.startQuestId != 0) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest");
@ -2460,6 +2963,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel);
}
// Unique / Heroic indicators
constexpr uint32_t kFlagHeroic = 0x8; // ITEM_FLAG_HEROIC_TOOLTIP
constexpr uint32_t kFlagUniqueEquipped = 0x1000000; // ITEM_FLAG_UNIQUE_EQUIPPABLE
if (info.itemFlags & kFlagHeroic) {
ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic");
}
if (info.maxCount == 1) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique");
} else if (info.itemFlags & kFlagUniqueEquipped) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped");
}
// Binding type
switch (info.bindType) {
case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break;
@ -2524,6 +3039,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
if (info.armor > 0) ImGui::Text("%d Armor", info.armor);
// Elemental resistances (fire resist gear, nature resist gear, etc.)
{
const int32_t resVals[6] = { info.holyRes, info.fireRes, info.natureRes,
info.frostRes, info.shadowRes, info.arcaneRes };
static const char* resLabels[6] = {
"Holy Resistance", "Fire Resistance", "Nature Resistance",
"Frost Resistance", "Shadow Resistance", "Arcane Resistance"
};
for (int i = 0; i < 6; ++i)
if (resVals[i] > 0) ImGui::Text("+%d %s", resVals[i], resLabels[i]);
}
auto appendBonus = [](std::string& out, int32_t val, const char* name) {
if (val <= 0) return;
if (!out.empty()) out += " ";
@ -2576,14 +3103,166 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel);
}
// Required skill (e.g. "Requires Engineering (300)")
if (info.requiredSkill != 0 && info.requiredSkillRank > 0) {
// Lazy-load SkillLine.dbc names
static std::unordered_map<uint32_t, std::string> s_skillNames;
static bool s_skillNamesLoaded = false;
if (!s_skillNamesLoaded && assetManager_) {
s_skillNamesLoaded = true;
auto dbc = assetManager_->loadDBC("SkillLine.dbc");
if (dbc && dbc->isLoaded()) {
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
uint32_t idF = layout ? (*layout)["ID"] : 0;
uint32_t nameF = layout ? (*layout)["Name"] : 2;
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t sid = dbc->getUInt32(r, idF);
if (!sid) continue;
std::string sname = dbc->getString(r, nameF);
if (!sname.empty()) s_skillNames[sid] = std::move(sname);
}
}
}
uint32_t playerSkillVal = 0;
if (gameHandler_) {
const auto& skills = gameHandler_->getPlayerSkills();
auto skPit = skills.find(info.requiredSkill);
if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue();
}
bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info.requiredSkillRank);
ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f);
auto skIt = s_skillNames.find(info.requiredSkill);
if (skIt != s_skillNames.end())
ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info.requiredSkillRank);
else
ImGui::TextColored(skColor, "Requires Skill %u (%u)", info.requiredSkill, info.requiredSkillRank);
}
// Required reputation (e.g. "Requires Exalted with Argent Dawn")
if (info.requiredReputationFaction != 0 && info.requiredReputationRank > 0) {
static std::unordered_map<uint32_t, std::string> s_factionNames;
static bool s_factionNamesLoaded = false;
if (!s_factionNamesLoaded && assetManager_) {
s_factionNamesLoaded = true;
auto dbc = assetManager_->loadDBC("Faction.dbc");
if (dbc && dbc->isLoaded()) {
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr;
uint32_t idF = layout ? (*layout)["ID"] : 0;
uint32_t nameF = layout ? (*layout)["Name"] : 20;
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t fid = dbc->getUInt32(r, idF);
if (!fid) continue;
std::string fname = dbc->getString(r, nameF);
if (!fname.empty()) s_factionNames[fid] = std::move(fname);
}
}
}
static const char* kRepRankNames[] = {
"Hated", "Hostile", "Unfriendly", "Neutral",
"Friendly", "Honored", "Revered", "Exalted"
};
const char* rankName = (info.requiredReputationRank < 8)
? kRepRankNames[info.requiredReputationRank] : "Unknown";
auto fIt = s_factionNames.find(info.requiredReputationFaction);
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s",
rankName,
fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction");
}
// Class restriction (e.g. "Classes: Paladin, Warrior")
if (info.allowableClass != 0) {
static const struct { uint32_t mask; const char* name; } kClasses[] = {
{ 1, "Warrior" },
{ 2, "Paladin" },
{ 4, "Hunter" },
{ 8, "Rogue" },
{ 16, "Priest" },
{ 32, "Death Knight" },
{ 64, "Shaman" },
{ 128, "Mage" },
{ 256, "Warlock" },
{ 1024, "Druid" },
};
// Count matching classes
int matchCount = 0;
for (const auto& kc : kClasses)
if (info.allowableClass & kc.mask) ++matchCount;
// Only show if restricted to a subset (not all classes)
if (matchCount > 0 && matchCount < 10) {
char classBuf[128] = "Classes: ";
bool first = true;
for (const auto& kc : kClasses) {
if (!(info.allowableClass & kc.mask)) continue;
if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1);
strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1);
first = false;
}
// Check if player's class is allowed
bool playerAllowed = true;
if (gameHandler_) {
uint8_t pc = gameHandler_->getPlayerClass();
uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0;
playerAllowed = (pmask == 0 || (info.allowableClass & pmask));
}
ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f);
ImGui::TextColored(clColor, "%s", classBuf);
}
}
// Race restriction (e.g. "Races: Night Elf, Human")
if (info.allowableRace != 0) {
static const struct { uint32_t mask; const char* name; } kRaces[] = {
{ 1, "Human" },
{ 2, "Orc" },
{ 4, "Dwarf" },
{ 8, "Night Elf" },
{ 16, "Undead" },
{ 32, "Tauren" },
{ 64, "Gnome" },
{ 128, "Troll" },
{ 512, "Blood Elf" },
{ 1024, "Draenei" },
};
constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024;
// Only show if not all playable races are allowed
if ((info.allowableRace & kAllPlayable) != kAllPlayable) {
int matchCount = 0;
for (const auto& kr : kRaces)
if (info.allowableRace & kr.mask) ++matchCount;
if (matchCount > 0) {
char raceBuf[160] = "Races: ";
bool first = true;
for (const auto& kr : kRaces) {
if (!(info.allowableRace & kr.mask)) continue;
if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1);
strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1);
first = false;
}
bool playerAllowed = true;
if (gameHandler_) {
uint8_t pr = gameHandler_->getPlayerRace();
uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0;
playerAllowed = (pmask == 0 || (info.allowableRace & pmask));
}
ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f);
ImGui::TextColored(rColor, "%s", raceBuf);
}
}
}
// Spell effects
for (const auto& sp : info.spells) {
if (sp.spellId == 0) continue;
const char* trigger = nullptr;
switch (sp.spellTrigger) {
case 0: trigger = "Use"; break;
case 1: trigger = "Equip"; break;
case 2: trigger = "Chance on Hit"; break;
case 0: trigger = "Use"; break; // on use
case 1: trigger = "Equip"; break; // on equip
case 2: trigger = "Chance on Hit"; break; // proc on melee hit
case 4: trigger = "Use"; break; // soulstone (still shows as Use)
case 5: trigger = "Use"; break; // on use, no delay
case 6: trigger = "Use"; break; // learn spell (recipe/pattern)
default: break;
}
if (!trigger) continue;