Compare commits

..

26 commits

Author SHA1 Message Date
Kelsi
e24c39f4be fix: add UNIT_FIELD_AURAFLAGS to update field name table
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
UNIT_FIELD_AURAFLAGS was defined in the UF enum and used in Classic and
Turtle JSON files (index 98) but missing from the kUFNames lookup table.
The JSON loader silently skipped it, so Classic/Turtle aura flag data
from UPDATE_OBJECT was never mapped. This could cause aura display
issues on Classic 1.12 and Turtle WoW servers.
2026-03-20 07:16:34 -07:00
Kelsi
ebc7d66dfe fix: add honor/arena currency to update field name table
PLAYER_FIELD_HONOR_CURRENCY and PLAYER_FIELD_ARENA_CURRENCY were added
to the UF enum and JSON files in cycle 1, but the kUFNames lookup table
in update_field_table.cpp was not updated. This meant the JSON loader
could not map these field names to their enum values, so honor and
arena point values from UPDATE_OBJECT were silently ignored.
2026-03-20 07:12:40 -07:00
Kelsi
5172c07e15 fix: include category cooldowns in initial spell cooldown tracking
SMSG_INITIAL_SPELLS cooldown entries have both cooldownMs (individual)
and categoryCooldownMs (shared, e.g. potions). The handler only checked
cooldownMs, so spells with category-only cooldowns (cooldownMs=0,
categoryCooldownMs=120000) were not tracked. Now uses the maximum of
both values, ensuring potion and similar shared cooldowns show on the
action bar after login.
2026-03-20 07:02:57 -07:00
Kelsi
533831e18d fix: sync pending spell cooldowns to action bar after login
SMSG_SPELL_COOLDOWN arrives before SMSG_ACTION_BUTTONS during login,
so cooldown times were stored in spellCooldowns but never applied to
the newly populated action bar slots. Players would see all abilities
as ready immediately after login even if spells were on cooldown.
Now applies pending cooldowns from the spellCooldowns map to each
matching slot when the action bar is first populated.
2026-03-20 06:59:23 -07:00
Kelsi
72993121ab feat: add pulsing yellow flash to chat tabs with unread messages
Chat tabs with unread messages now pulse yellow to attract attention.
The existing unread count "(N)" suffix was text-only and easy to miss,
especially for whisper and guild tabs. The pulsing color clears when
the tab is clicked, matching standard WoW chat tab behavior.
2026-03-20 06:47:39 -07:00
Kelsi
22742fedb8 feat: add [raid], [noraid], and [spec:N] macro conditionals
Add commonly-used WoW macro conditionals:
- [raid]/[noraid] — checks if the player is in a raid group (groupType
  == 1) vs a regular party. Used for conditional healing/targeting in
  raid content.
- [spec:1]/[spec:2] — checks the active talent spec (1-based index).
  Used for dual-spec macros that swap gear sets or use different
  rotations per spec.

Updated /macrohelp to list the new conditionals.
2026-03-20 06:42:43 -07:00
Kelsi
a6fe5662c8 fix: implement [target=pet] and [@pet] macro target specifiers
The /macrohelp listed [target=pet] as supported but the conditional
evaluator didn't handle the "pet" specifier for target= or @ syntax.
Now resolves to the player's active pet GUID (or skips the alternative
if no pet is active). Essential for hunter/warlock macros like:
  /cast [target=pet] Mend Pet
  /cast [@pet,dead] Revive Pet
2026-03-20 06:38:13 -07:00
Kelsi
fa82d32a9f feat: add [indoors]/[outdoors] macro conditionals via WMO detection
Add indoor/outdoor state macro conditionals using the renderer's WMO
interior detection. Essential for mount macros that need to select
ground mounts indoors vs flying mounts outdoors. The Renderer now
caches the insideWmo state in playerIndoors_ and exposes it via
isPlayerIndoors(). Updated /macrohelp to list the new conditionals.
2026-03-20 06:29:33 -07:00
Kelsi
114478271e feat: add [pet], [nopet], [group], [nogroup] macro conditionals
Add frequently-used macro conditionals for pet and group state:
- [pet]/[nopet] — checks if the player has an active pet (hunters,
  warlocks, DKs). Essential for pet management macros.
- [group]/[nogroup]/[party] — checks if the player is in a party or
  raid. Used for conditional targeting and ability usage.

Updated /macrohelp output to list the new conditionals.
2026-03-20 06:25:02 -07:00
Kelsi
a9e0a99f2b feat: add /macrohelp command to list available macro conditionals
Players can now type /macrohelp to see all supported macro conditionals
grouped by category (state, target, form, keys, aura). Also added to
the /help output and chat auto-complete list. This helps users discover
the macro system without external documentation.
2026-03-20 06:17:23 -07:00
Kelsi
d7059c66dc feat: add mounted/swimming/flying/stealthed/channeling macro conditionals
Add commonly-used WoW macro conditionals that were missing:
- [mounted]/[nomounted] — checks isMounted() state
- [swimming]/[noswimming] — checks SWIMMING movement flag
- [flying]/[noflying] — checks CAN_FLY + FLYING movement flags
- [stealthed]/[nostealthed] — checks UNIT_FLAG_SNEAKING (0x02000000)
- [channeling]/[nochanneling] — checks if currently channeling a spell

These are essential for common macros like mount/dismount toggles,
rogue opener macros, and conditional cast sequences.
2026-03-20 06:13:27 -07:00
Kelsi
6b7975107e fix: add proficiency warning to vendor/loot item tooltips
The proficiency check added in the previous commit only applied to the
ItemDef tooltip variant (inventory items). Vendor, loot, and AH
tooltips use the ItemQueryResponseData variant which was missing the
check. Now both tooltip paths show "You can't use this type of item."
in red when the player lacks weapon or armor proficiency.
2026-03-20 06:07:38 -07:00
Kelsi
120c2967eb feat: show proficiency warning in item tooltips
Item tooltips now display a red "You can't use this type of item."
warning when the player lacks proficiency for the weapon or armor
subclass (e.g. a mage hovering over a plate item or a two-handed
sword). Uses the existing canUseWeaponSubclass/canUseArmorSubclass
checks against SMSG_SET_PROFICIENCY bitmasks.
2026-03-20 06:04:29 -07:00
Kelsi
bc2085b0fc fix: increase compressed UPDATE_OBJECT decompressed size limit to 5MB
Capital cities and large raids can produce UPDATE_OBJECT packets that
decompress to more than 1MB. The real WoW client handles up to ~10MB.
Bump the limit from 1MB to 5MB to avoid silently dropping entity
updates in densely populated areas like Dalaran or 40-man raids.
2026-03-20 05:59:11 -07:00
Kelsi
bda5bb0a2b fix: add negative cache for failed spell visual model loads
Spell visual M2 models that fail to load (missing file, empty model,
or GPU upload failure) were re-attempted on every subsequent spell cast,
causing repeated file I/O during combat. Now caches failed model IDs in
spellVisualFailedModels_ so they are skipped on subsequent attempts.
2026-03-20 05:56:33 -07:00
Kelsi
90edb3bc07 feat: use M2 animation duration for spell visual lifetime
Spell visual effects previously used a fixed 3.5s duration for all
effects, causing some to linger too long and overlap during combat.
Now queries the M2 model's default animation duration via the new
getInstanceAnimDuration() method and clamps it to 0.5-5s. Effects
without animations fall back to a 2s default. This makes spell impacts
feel more responsive and reduces visual clutter.
2026-03-20 05:52:47 -07:00
Kelsi
29c938dec2 feat: add Isle of Conquest to battleground score frame
Add IoC (map 628) to the BG score display with Alliance/Horde
reinforcement counters (world state keys 4221/4222, max 300).
2026-03-20 05:40:53 -07:00
Kelsi
9d1fb39363 feat: add "Usable" filter to auction house and query token item names
Add a "Usable" checkbox to the AH search UI that filters results to
items the player can actually equip/use (server-side filtering via the
usableOnly parameter in CMSG_AUCTION_LIST_ITEMS). Also ensure token
item names for extended costs are queried from the server via
ensureItemInfo() so they display properly instead of "Item#12345".
2026-03-20 05:34:17 -07:00
Kelsi
5230815353 feat: display detailed honor/arena/token costs for vendor items
Load ItemExtendedCost.dbc and show specific costs (e.g. "2000 Honor",
"200 Arena", "30x Badge of Justice") instead of generic "[Tokens]" for
vendor items with extended costs. Items with both gold and token costs
now show both. Token item names are resolved from item info cache.
2026-03-20 05:28:45 -07:00
Kelsi
595ea466c2 fix: update local equipment set GUID on save confirmation and auto-request played time on login
SMSG_EQUIPMENT_SET_SAVED now updates the local set's GUID from the
server response, preventing duplicate set creation when clicking
"Update" on a newly-saved set. New sets are also added to the local
list immediately so the UI reflects them without a relog.

Additionally, CMSG_PLAYED_TIME is now auto-sent on initial world entry
(with sendToChat=false) so the character Stats tab shows total and
level time immediately without requiring /played.
2026-03-20 05:17:27 -07:00
Kelsi
e68a1fa2ec fix: guard equipment set packets against unsupported expansions
Classic and TBC lack equipment set opcodes, so sending save/use/delete
packets would transmit wire opcode 0xFFFF and potentially disconnect the
client. Now all three methods check wireOpcode != 0xFFFF before sending,
and the Outfits tab is only shown when the expansion supports equipment
sets (via supportsEquipmentSets() check).
2026-03-20 05:12:24 -07:00
Kelsi
9600dd40e3 fix: correct CMSG_EQUIPMENT_SET_USE packet format
The packet previously sent only a uint32 setId, which does not match
the WotLK protocol. AzerothCore/TrinityCore expect 19 iterations of
(PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot). Now looks up the
equipment set's target item GUIDs and searches equipment, backpack, and
extra bags to provide correct source locations for each item.
2026-03-20 05:01:21 -07:00
Kelsi
1ae4cfaf3f fix: auto-acknowledge cinematic and movie triggers to prevent server hangs
Send CMSG_NEXT_CINEMATIC_CAMERA in response to SMSG_TRIGGER_CINEMATIC
and CMSG_COMPLETE_MOVIE in response to SMSG_TRIGGER_MOVIE. Some WotLK
servers block further packets or disconnect clients that don't respond
to these triggers, especially during the intro cinematic on first login.
2026-03-20 04:53:54 -07:00
Kelsi
f4d705738b fix: send CMSG_SET_WATCHED_FACTION when tracking a reputation
setWatchedFactionId() previously only stored the faction locally.
Now it also sends CMSG_SET_WATCHED_FACTION with the correct repListId
to the server, so the tracked faction persists across sessions.
2026-03-20 04:50:49 -07:00
Kelsi
ae56f2eb80 feat: implement equipment set save, update, and delete
Add saveEquipmentSet() and deleteEquipmentSet() methods that send
CMSG_EQUIPMENT_SET_SAVE and CMSG_DELETEEQUIPMENT_SET packets. The save
packet captures all 19 equipment slot GUIDs via packed GUID encoding.
The Outfits tab now always shows (not just when sets exist), with an
input field to create new sets and Update/Delete buttons per set.
2026-03-20 04:43:46 -07:00
Kelsi
f88d90ee88 feat: track and display honor/arena points from update fields
Add PLAYER_FIELD_HONOR_CURRENCY and PLAYER_FIELD_ARENA_CURRENCY to the
update field system for WotLK (indices 1422/1423) and TBC (1505/1506).
Parse values from both CREATE_OBJECT and VALUES update paths, and show
them in the character Stats tab under a PvP Currency section.
2026-03-20 04:36:30 -07:00
13 changed files with 555 additions and 46 deletions

View file

@ -37,6 +37,8 @@
"PLAYER_FIELD_BANKBAG_SLOT_1": 784,
"PLAYER_SKILL_INFO_START": 928,
"PLAYER_EXPLORED_ZONES_START": 1312,
"PLAYER_FIELD_HONOR_CURRENCY": 1505,
"PLAYER_FIELD_ARENA_CURRENCY": 1506,
"GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 60,

View file

@ -49,6 +49,8 @@
"PLAYER_RANGED_CRIT_PERCENTAGE": 1030,
"PLAYER_SPELL_CRIT_PERCENTAGE1": 1032,
"PLAYER_FIELD_COMBAT_RATING_1": 1231,
"PLAYER_FIELD_HONOR_CURRENCY": 1422,
"PLAYER_FIELD_ARENA_CURRENCY": 1423,
"GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 60,

View file

@ -299,6 +299,10 @@ public:
// Money (copper)
uint64_t getMoneyCopper() const { return playerMoneyCopper_; }
// PvP currency (TBC/WotLK only)
uint32_t getHonorPoints() const { return playerHonorPoints_; }
uint32_t getArenaPoints() const { return playerArenaPoints_; }
// Server-authoritative armor (UNIT_FIELD_RESISTANCES[0])
int32_t getArmorRating() const { return playerArmorRating_; }
@ -1530,7 +1534,11 @@ public:
std::string iconName;
};
const std::vector<EquipmentSetInfo>& getEquipmentSets() const { return equipmentSetInfo_; }
bool supportsEquipmentSets() const;
void useEquipmentSet(uint32_t setId);
void saveEquipmentSet(const std::string& name, const std::string& iconName = "INV_Misc_QuestionMark",
uint64_t existingGuid = 0, uint32_t setIndex = 0xFFFFFFFF);
void deleteEquipmentSet(uint64_t setGuid);
// NPC Gossip
void interactWithNpc(uint64_t guid);
@ -1793,7 +1801,7 @@ public:
const std::string& getFactionNamePublic(uint32_t factionId) const;
uint32_t getWatchedFactionId() const { return watchedFactionId_; }
void setWatchedFactionId(uint32_t id) { watchedFactionId_ = id; }
void setWatchedFactionId(uint32_t factionId);
uint32_t getLastContactListMask() const { return lastContactListMask_; }
uint32_t getLastContactListCount() const { return lastContactListCount_; }
bool isServerMovementAllowed() const { return serverMovementAllowed_; }
@ -3067,6 +3075,8 @@ private:
float pendingLootMoneyNotifyTimer_ = 0.0f;
std::unordered_map<uint64_t, float> recentLootMoneyAnnounceCooldowns_;
uint64_t playerMoneyCopper_ = 0;
uint32_t playerHonorPoints_ = 0;
uint32_t playerArenaPoints_ = 0;
int32_t playerArmorRating_ = 0;
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
@ -3469,6 +3479,8 @@ private:
std::array<uint64_t, 19> itemGuids{};
};
std::vector<EquipmentSet> equipmentSets_;
std::string pendingSaveSetName_; // Saved between CMSG_EQUIPMENT_SET_SAVE and SMSG_EQUIPMENT_SET_SAVED
std::string pendingSaveSetIcon_;
std::vector<EquipmentSetInfo> equipmentSetInfo_; // public-facing copy
// ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ----

View file

@ -77,6 +77,10 @@ enum class UF : uint16_t {
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)
// Player PvP currency (TBC/WotLK only — Classic uses the old weekly honor system)
PLAYER_FIELD_HONOR_CURRENCY, // Accumulated honor points (uint32)
PLAYER_FIELD_ARENA_CURRENCY, // Accumulated arena points (uint32)
// GameObject fields
GAMEOBJECT_DISPLAYID,

View file

@ -323,6 +323,7 @@ public:
void setInstancePosition(uint32_t instanceId, const glm::vec3& position);
void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform);
void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen);
float getInstanceAnimDuration(uint32_t instanceId) const;
void removeInstance(uint32_t instanceId);
void removeInstances(const std::vector<uint32_t>& instanceIds);
void setSkipCollision(uint32_t instanceId, bool skip);

View file

@ -7,6 +7,7 @@
#include <future>
#include <cstddef>
#include <unordered_map>
#include <unordered_set>
#include <glm/glm.hpp>
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
@ -138,6 +139,7 @@ public:
QuestMarkerRenderer* getQuestMarkerRenderer() const { return questMarkerRenderer.get(); }
SkySystem* getSkySystem() const { return skySystem.get(); }
const std::string& getCurrentZoneName() const { return currentZoneName; }
bool isPlayerIndoors() const { return playerIndoors_; }
VkContext* getVkContext() const { return vkCtx; }
VkDescriptorSetLayout getPerFrameSetLayout() const { return perFrameSetLayout; }
VkRenderPass getShadowRenderPass() const { return shadowRenderPass; }
@ -334,21 +336,28 @@ private:
pipeline::AssetManager* cachedAssetManager = nullptr;
// Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL/IMPACT
struct SpellVisualInstance { uint32_t instanceId; float elapsed; };
struct SpellVisualInstance {
uint32_t instanceId;
float elapsed;
float duration; // per-instance lifetime in seconds (from M2 anim or default)
};
std::vector<SpellVisualInstance> activeSpellVisuals_;
std::unordered_map<uint32_t, std::string> spellVisualCastPath_; // visualId → cast M2 path
std::unordered_map<uint32_t, std::string> spellVisualImpactPath_; // visualId → impact M2 path
std::unordered_map<std::string, uint32_t> spellVisualModelIds_; // M2 path → M2Renderer modelId
std::unordered_set<uint32_t> spellVisualFailedModels_; // modelIds that failed to load (negative cache)
uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799
bool spellVisualDbcLoaded_ = false;
void loadSpellVisualDbc();
void updateSpellVisuals(float deltaTime);
static constexpr float SPELL_VISUAL_DURATION = 3.5f;
static constexpr float SPELL_VISUAL_MAX_DURATION = 5.0f;
static constexpr float SPELL_VISUAL_DEFAULT_DURATION = 2.0f;
uint32_t currentZoneId = 0;
std::string currentZoneName;
bool inTavern_ = false;
bool inBlacksmith_ = false;
bool playerIndoors_ = false; // Cached WMO inside state for macro conditionals
float musicSwitchCooldown_ = 0.0f;
bool deferredWorldInitEnabled_ = true;
bool deferredWorldInitPending_ = false;

View file

@ -423,6 +423,18 @@ private:
bool spellIconDbLoaded_ = false;
VkDescriptorSet getSpellIcon(uint32_t spellId, pipeline::AssetManager* am);
// ItemExtendedCost.dbc cache: extendedCostId -> cost details
struct ExtendedCostEntry {
uint32_t honorPoints = 0;
uint32_t arenaPoints = 0;
uint32_t itemId[5] = {};
uint32_t itemCount[5] = {};
};
std::unordered_map<uint32_t, ExtendedCostEntry> extendedCostCache_;
bool extendedCostDbLoaded_ = false;
void loadExtendedCostDBC();
std::string formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler);
// Death Knight rune bar: client-predicted fill (0.0=depleted, 1.0=ready) for smooth animation
float runeClientFill_[6] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f};
@ -569,6 +581,7 @@ private:
uint32_t auctionBrowseOffset_ = 0; // Pagination offset for browse results
int auctionItemClass_ = -1; // Item class filter (-1 = All)
int auctionItemSubClass_ = -1; // Item subclass filter (-1 = All)
bool auctionUsableOnly_ = false; // Filter to items usable by current class/level
// Guild bank money input
int guildBankMoneyInput_[3] = {0, 0, 0}; // gold, silver, copper

View file

@ -4218,19 +4218,52 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() >= 12) {
uint32_t setIndex = packet.readUInt32();
uint64_t setGuid = packet.readUInt64();
for (const auto& es : equipmentSets_) {
if (es.setGuid == setGuid ||
(es.setGuid == 0 && es.setId == setIndex)) {
// Update the local set's GUID so subsequent "Update" calls
// use the server-assigned GUID instead of 0 (which would
// create a duplicate instead of updating).
bool found = false;
for (auto& es : equipmentSets_) {
if (es.setGuid == setGuid || es.setId == setIndex) {
es.setGuid = setGuid;
setName = es.name;
found = true;
break;
}
}
(void)setIndex;
// Also update public-facing info
for (auto& info : equipmentSetInfo_) {
if (info.setGuid == setGuid || info.setId == setIndex) {
info.setGuid = setGuid;
break;
}
}
// If the set doesn't exist locally yet (new save), add a
// placeholder entry so it shows up in the UI immediately.
if (!found && setGuid != 0) {
EquipmentSet newEs;
newEs.setGuid = setGuid;
newEs.setId = setIndex;
newEs.name = pendingSaveSetName_;
newEs.iconName = pendingSaveSetIcon_;
for (int s = 0; s < 19; ++s)
newEs.itemGuids[s] = getEquipSlotGuid(s);
equipmentSets_.push_back(std::move(newEs));
EquipmentSetInfo newInfo;
newInfo.setGuid = setGuid;
newInfo.setId = setIndex;
newInfo.name = pendingSaveSetName_;
newInfo.iconName = pendingSaveSetIcon_;
equipmentSetInfo_.push_back(std::move(newInfo));
setName = pendingSaveSetName_;
}
pendingSaveSetName_.clear();
pendingSaveSetIcon_.clear();
LOG_INFO("SMSG_EQUIPMENT_SET_SAVED: index=", setIndex,
" guid=", setGuid, " name=", setName);
}
addSystemChatMessage(setName.empty()
? std::string("Equipment set saved.")
: "Equipment set \"" + setName + "\" saved.");
LOG_DEBUG("Equipment set saved");
break;
}
case Opcode::SMSG_PERIODICAURALOG: {
@ -4459,6 +4492,18 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
actionBar[i] = slot;
}
// Apply any pending cooldowns from spellCooldowns to newly populated slots.
// SMSG_SPELL_COOLDOWN often arrives before SMSG_ACTION_BUTTONS during login,
// so the per-slot cooldownRemaining would be 0 without this sync.
for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id != 0) {
auto cdIt = spellCooldowns.find(slot.id);
if (cdIt != spellCooldowns.end() && cdIt->second > 0.0f) {
slot.cooldownRemaining = cdIt->second;
slot.cooldownTotal = cdIt->second;
}
}
}
LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server");
packet.setReadPos(packet.getSize());
break;
@ -4554,11 +4599,16 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
}
case Opcode::SMSG_TRIGGER_CINEMATIC:
// uint32 cinematicId — we don't play cinematics; consume and skip.
case Opcode::SMSG_TRIGGER_CINEMATIC: {
// uint32 cinematicId — we don't play cinematics; acknowledge immediately.
packet.setReadPos(packet.getSize());
LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped");
// Send CMSG_NEXT_CINEMATIC_CAMERA to signal cinematic completion;
// servers may block further packets until this is received.
network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA));
socket->send(ack);
LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped, sent CMSG_NEXT_CINEMATIC_CAMERA");
break;
}
case Opcode::SMSG_LOOT_MONEY_NOTIFY: {
// Format: uint32 money + uint8 soleLooter
@ -6298,9 +6348,19 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
// ---- Movie trigger ----
case Opcode::SMSG_TRIGGER_MOVIE:
case Opcode::SMSG_TRIGGER_MOVIE: {
// uint32 movieId — we don't play movies; acknowledge immediately.
packet.setReadPos(packet.getSize());
// WotLK servers expect CMSG_COMPLETE_MOVIE after the movie finishes;
// without it, the server may hang or disconnect the client.
uint16_t wire = wireOpcode(Opcode::CMSG_COMPLETE_MOVIE);
if (wire != 0xFFFF) {
network::Packet ack(wire);
socket->send(ack);
LOG_DEBUG("SMSG_TRIGGER_MOVIE: skipped, sent CMSG_COMPLETE_MOVIE");
}
break;
}
// ---- Equipment sets ----
case Opcode::SMSG_EQUIPMENT_SET_LIST:
@ -9381,6 +9441,14 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion");
}
}
// Auto-request played time on login so the character Stats tab is
// populated immediately without requiring /played.
if (socket) {
auto ptPkt = RequestPlayedTimePacket::build(false); // false = don't show in chat
socket->send(ptPkt);
LOG_INFO("Auto-requested played time on login");
}
}
}
@ -10660,12 +10728,115 @@ void GameHandler::sendRequestVehicleExit() {
vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0)
}
bool GameHandler::supportsEquipmentSets() const {
return wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE) != 0xFFFF;
}
void GameHandler::useEquipmentSet(uint32_t setId) {
if (state != WorldState::IN_WORLD) return;
// CMSG_EQUIPMENT_SET_USE: uint32 setId
network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE));
pkt.writeUInt32(setId);
if (state != WorldState::IN_WORLD || !socket) return;
uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE);
if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; }
// Find the equipment set to get target item GUIDs per slot
const EquipmentSet* es = nullptr;
for (const auto& s : equipmentSets_) {
if (s.setId == setId) { es = &s; break; }
}
if (!es) {
addUIError("Equipment set not found.");
return;
}
// CMSG_EQUIPMENT_SET_USE: 19 × (PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot)
network::Packet pkt(wire);
for (int slot = 0; slot < 19; ++slot) {
uint64_t itemGuid = es->itemGuids[slot];
MovementPacket::writePackedGuid(pkt, itemGuid);
uint8_t srcBag = 0xFF;
uint8_t srcSlot = 0;
if (itemGuid != 0) {
bool found = false;
// Check if item is already in an equipment slot
for (int eq = 0; eq < 19 && !found; ++eq) {
if (getEquipSlotGuid(eq) == itemGuid) {
srcBag = 0xFF; // INVENTORY_SLOT_BAG_0
srcSlot = static_cast<uint8_t>(eq);
found = true;
}
}
// Check backpack (slots 23-38 in the body container)
for (int bp = 0; bp < 16 && !found; ++bp) {
if (getBackpackItemGuid(bp) == itemGuid) {
srcBag = 0xFF;
srcSlot = static_cast<uint8_t>(23 + bp);
found = true;
}
}
// Check extra bags (bag indices 19-22)
for (int bag = 0; bag < 4 && !found; ++bag) {
int bagSize = inventory.getBagSize(bag);
for (int s = 0; s < bagSize && !found; ++s) {
if (getBagItemGuid(bag, s) == itemGuid) {
srcBag = static_cast<uint8_t>(19 + bag);
srcSlot = static_cast<uint8_t>(s);
found = true;
}
}
}
}
pkt.writeUInt8(srcBag);
pkt.writeUInt8(srcSlot);
}
socket->send(pkt);
LOG_INFO("CMSG_EQUIPMENT_SET_USE: setId=", setId);
}
void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName,
uint64_t existingGuid, uint32_t setIndex) {
if (state != WorldState::IN_WORLD) return;
uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE);
if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; }
// CMSG_EQUIPMENT_SET_SAVE: uint64 setGuid + uint32 setIndex + string name + string iconName
// + 19 × PackedGuid itemGuid (one per equipment slot, 018)
if (setIndex == 0xFFFFFFFF) {
// Auto-assign next free index
setIndex = 0;
for (const auto& es : equipmentSets_) {
if (es.setId >= setIndex) setIndex = es.setId + 1;
}
}
network::Packet pkt(wire);
pkt.writeUInt64(existingGuid); // 0 = create new, nonzero = update
pkt.writeUInt32(setIndex);
pkt.writeString(name);
pkt.writeString(iconName);
for (int slot = 0; slot < 19; ++slot) {
uint64_t guid = getEquipSlotGuid(slot);
MovementPacket::writePackedGuid(pkt, guid);
}
// Track pending save so SMSG_EQUIPMENT_SET_SAVED can add the new set locally
pendingSaveSetName_ = name;
pendingSaveSetIcon_ = iconName;
socket->send(pkt);
LOG_INFO("CMSG_EQUIPMENT_SET_SAVE: name=\"", name, "\" guid=", existingGuid, " index=", setIndex);
}
void GameHandler::deleteEquipmentSet(uint64_t setGuid) {
if (state != WorldState::IN_WORLD || setGuid == 0) return;
uint16_t wire = wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET);
if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; }
// CMSG_DELETEEQUIPMENT_SET: uint64 setGuid
network::Packet pkt(wire);
pkt.writeUInt64(setGuid);
socket->send(pkt);
// Remove locally so UI updates immediately
equipmentSets_.erase(
std::remove_if(equipmentSets_.begin(), equipmentSets_.end(),
[setGuid](const EquipmentSet& es) { return es.setGuid == setGuid; }),
equipmentSets_.end());
equipmentSetInfo_.erase(
std::remove_if(equipmentSetInfo_.begin(), equipmentSetInfo_.end(),
[setGuid](const EquipmentSetInfo& es) { return es.setGuid == setGuid; }),
equipmentSetInfo_.end());
LOG_INFO("CMSG_DELETEEQUIPMENT_SET: guid=", setGuid);
}
void GameHandler::sendMinimapPing(float wowX, float wowY) {
@ -11781,6 +11952,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY);
const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2);
const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE);
@ -11814,6 +11987,14 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
playerMoneyCopper_ = val;
LOG_DEBUG("Money set from update fields: ", val, " copper");
}
else if (ufHonor != 0xFFFF && key == ufHonor) {
playerHonorPoints_ = val;
LOG_DEBUG("Honor points from update fields: ", val);
}
else if (ufArena != 0xFFFF && key == ufArena) {
playerArenaPoints_ = val;
LOG_DEBUG("Arena points from update fields: ", val);
}
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_);
@ -12207,6 +12388,8 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY);
const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY);
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES);
@ -12254,6 +12437,14 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
playerMoneyCopper_ = val;
LOG_DEBUG("Money updated via VALUES: ", val, " copper");
}
else if (ufHonorV != 0xFFFF && key == ufHonorV) {
playerHonorPoints_ = val;
LOG_DEBUG("Honor points updated: ", val);
}
else if (ufArenaV != 0xFFFF && key == ufArenaV) {
playerArenaPoints_ = val;
LOG_DEBUG("Arena points updated: ", val);
}
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
}
@ -12608,7 +12799,9 @@ void GameHandler::handleCompressedUpdateObject(network::Packet& packet) {
uint32_t decompressedSize = packet.readUInt32();
LOG_DEBUG(" Decompressed size: ", decompressedSize);
if (decompressedSize == 0 || decompressedSize > 1024 * 1024) {
// Capital cities and large raids can produce very large update packets.
// The real WoW client handles up to ~10MB; 5MB covers all practical cases.
if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) {
LOG_WARNING("Invalid decompressed size: ", decompressedSize);
return;
}
@ -18560,10 +18753,12 @@ void GameHandler::handleInitialSpells(network::Packet& packet) {
knownSpells.insert(6603u);
knownSpells.insert(8690u);
// Set initial cooldowns
// Set initial cooldowns — use the longer of individual vs category cooldown.
// Spells like potions have cooldownMs=0 but categoryCooldownMs=120000.
for (const auto& cd : data.cooldowns) {
if (cd.cooldownMs > 0) {
spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f;
uint32_t effectiveMs = std::max(cd.cooldownMs, cd.categoryCooldownMs);
if (effectiveMs > 0) {
spellCooldowns[cd.spellId] = effectiveMs / 1000.0f;
}
}
@ -25674,6 +25869,21 @@ uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const {
return (it != factionIdToRepList_.end()) ? it->second : 0xFFFFFFFFu;
}
void GameHandler::setWatchedFactionId(uint32_t factionId) {
watchedFactionId_ = factionId;
if (state != WorldState::IN_WORLD || !socket) return;
// CMSG_SET_WATCHED_FACTION: int32 repListId (-1 = unwatch)
int32_t repListId = -1;
if (factionId != 0) {
uint32_t rl = getRepListIdByFactionId(factionId);
if (rl != 0xFFFFFFFFu) repListId = static_cast<int32_t>(rl);
}
network::Packet pkt(wireOpcode(Opcode::CMSG_SET_WATCHED_FACTION));
pkt.writeUInt32(static_cast<uint32_t>(repListId));
socket->send(pkt);
LOG_DEBUG("CMSG_SET_WATCHED_FACTION: repListId=", repListId, " (factionId=", factionId, ")");
}
std::string GameHandler::getFactionName(uint32_t factionId) const {
auto it = factionNameCache_.find(factionId);
if (it != factionNameCache_.end()) return it->second;

View file

@ -34,6 +34,7 @@ static const UFNameEntry kUFNames[] = {
{"UNIT_FIELD_DISPLAYID", UF::UNIT_FIELD_DISPLAYID},
{"UNIT_FIELD_MOUNTDISPLAYID", UF::UNIT_FIELD_MOUNTDISPLAYID},
{"UNIT_FIELD_AURAS", UF::UNIT_FIELD_AURAS},
{"UNIT_FIELD_AURAFLAGS", UF::UNIT_FIELD_AURAFLAGS},
{"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS},
{"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS},
{"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES},
@ -74,6 +75,8 @@ static const UFNameEntry kUFNames[] = {
{"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},
{"PLAYER_FIELD_HONOR_CURRENCY", UF::PLAYER_FIELD_HONOR_CURRENCY},
{"PLAYER_FIELD_ARENA_CURRENCY", UF::PLAYER_FIELD_ARENA_CURRENCY},
{"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS},
{"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1},
};

View file

@ -3956,6 +3956,18 @@ void M2Renderer::setInstanceAnimationFrozen(uint32_t instanceId, bool frozen) {
}
}
float M2Renderer::getInstanceAnimDuration(uint32_t instanceId) const {
auto idxIt = instanceIndexById.find(instanceId);
if (idxIt == instanceIndexById.end()) return 0.0f;
const auto& inst = instances[idxIt->second];
if (!inst.cachedModel) return 0.0f;
const auto& seqs = inst.cachedModel->sequences;
if (seqs.empty()) return 0.0f;
int seqIdx = inst.currentSequenceIndex;
if (seqIdx < 0 || seqIdx >= static_cast<int>(seqs.size())) seqIdx = 0;
return seqs[seqIdx].duration; // in milliseconds
}
void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) {
auto idxIt = instanceIndexById.find(instanceId);
if (idxIt == instanceIndexById.end()) return;

View file

@ -2860,16 +2860,21 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition
spellVisualModelIds_[modelPath] = modelId;
}
// Skip models that have previously failed to load (avoid repeated I/O)
if (spellVisualFailedModels_.count(modelId)) return;
// Load the M2 model if not already loaded
if (!m2Renderer->hasModel(modelId)) {
auto m2Data = cachedAssetManager->readFile(modelPath);
if (m2Data.empty()) {
LOG_DEBUG("SpellVisual: could not read model: ", modelPath);
spellVisualFailedModels_.insert(modelId);
return;
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (model.vertices.empty() && model.particleEmitters.empty()) {
LOG_DEBUG("SpellVisual: empty model: ", modelPath);
spellVisualFailedModels_.insert(modelId);
return;
}
// Load skin file for WotLK-format M2s
@ -2880,6 +2885,7 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition
}
if (!m2Renderer->loadModel(model, modelId)) {
LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath);
spellVisualFailedModels_.insert(modelId);
return;
}
LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath);
@ -2892,16 +2898,21 @@ void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition
LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId);
return;
}
activeSpellVisuals_.push_back({instanceId, 0.0f});
// Determine lifetime from M2 animation duration (clamp to reasonable range)
float animDurMs = m2Renderer->getInstanceAnimDuration(instanceId);
float duration = (animDurMs > 100.0f)
? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION)
: SPELL_VISUAL_DEFAULT_DURATION;
activeSpellVisuals_.push_back({instanceId, 0.0f, duration});
LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId,
" model=", modelPath);
" duration=", duration, "s model=", modelPath);
}
void Renderer::updateSpellVisuals(float deltaTime) {
if (activeSpellVisuals_.empty() || !m2Renderer) return;
for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) {
it->elapsed += deltaTime;
if (it->elapsed >= SPELL_VISUAL_DURATION) {
if (it->elapsed >= it->duration) {
m2Renderer->removeInstance(it->instanceId);
it = activeSpellVisuals_.erase(it);
} else {
@ -3465,6 +3476,7 @@ void Renderer::update(float deltaTime) {
uint32_t insideWmoId = 0;
const bool insideWmo = canQueryWmo &&
wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &insideWmoId);
playerIndoors_ = insideWmo;
// Ambient environmental sounds: fireplaces, water, birds, etc.
if (ambientSoundManager && camera && wmoRenderer && cameraController) {

View file

@ -1315,7 +1315,12 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
if (i > 0 && i < static_cast<int>(chatTabUnread_.size()) && chatTabUnread_[i] > 0) {
tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")";
}
// Use ImGuiTabItemFlags_NoPushId so label changes don't break tab identity
// Flash tab text color when unread messages exist
bool hasUnread = (i > 0 && i < static_cast<int>(chatTabUnread_.size()) && chatTabUnread_[i] > 0);
if (hasUnread) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 4.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f * pulse, 0.2f * pulse, 1.0f));
}
if (ImGui::BeginTabItem(tabLabel.c_str())) {
if (activeChatTab_ != i) {
activeChatTab_ = i;
@ -1325,6 +1330,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
}
ImGui::EndTabItem();
}
if (hasUnread) ImGui::PopStyleColor();
}
ImGui::EndTabBar();
}
@ -2627,7 +2633,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
"/g", "/guild", "/guildinfo",
"/gmticket", "/grouploot", "/i", "/instance",
"/invite", "/j", "/join", "/kick",
"/l", "/leave", "/local", "/me",
"/l", "/leave", "/local", "/macrohelp", "/me",
"/p", "/party", "/petaggressive", "/petattack", "/petdefensive",
"/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay",
"/r", "/raid",
@ -5668,12 +5674,17 @@ static std::string evaluateMacroConditionals(const std::string& rawArg,
size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1);
if (c.empty()) return true;
// @target specifiers: @player, @focus, @mouseover, @target
// @target specifiers: @player, @focus, @pet, @mouseover, @target
if (!c.empty() && c[0] == '@') {
std::string spec = c.substr(1);
if (spec == "player") tgt = gameHandler.getPlayerGuid();
else if (spec == "focus") tgt = gameHandler.getFocusGuid();
else if (spec == "target") tgt = gameHandler.getTargetGuid();
else if (spec == "pet") {
uint64_t pg = gameHandler.getPetGuid();
if (pg != 0) tgt = pg;
else return false; // no pet — skip this alternative
}
else if (spec == "mouseover") {
uint64_t mo = gameHandler.getMouseoverGuid();
if (mo != 0) tgt = mo;
@ -5687,6 +5698,11 @@ static std::string evaluateMacroConditionals(const std::string& rawArg,
if (spec == "player") tgt = gameHandler.getPlayerGuid();
else if (spec == "focus") tgt = gameHandler.getFocusGuid();
else if (spec == "target") tgt = gameHandler.getTargetGuid();
else if (spec == "pet") {
uint64_t pg = gameHandler.getPetGuid();
if (pg != 0) tgt = pg;
else return false; // no pet — skip this alternative
}
else if (spec == "mouseover") {
uint64_t mo = gameHandler.getMouseoverGuid();
if (mo != 0) tgt = mo;
@ -5742,6 +5758,61 @@ static std::string evaluateMacroConditionals(const std::string& rawArg,
if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); }
if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); }
// mounted / nomounted
if (c == "mounted") return gameHandler.isMounted();
if (c == "nomounted") return !gameHandler.isMounted();
// swimming / noswimming
if (c == "swimming") return gameHandler.isSwimming();
if (c == "noswimming") return !gameHandler.isSwimming();
// flying / noflying (CAN_FLY + FLYING flags active)
if (c == "flying") return gameHandler.isPlayerFlying();
if (c == "noflying") return !gameHandler.isPlayerFlying();
// channeling / nochanneling
if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling();
if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling());
// stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING)
auto isStealthedFn = [&]() -> bool {
auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid());
if (!pe) return false;
auto pu = std::dynamic_pointer_cast<game::Unit>(pe);
return pu && (pu->getUnitFlags() & 0x02000000u) != 0;
};
if (c == "stealthed") return isStealthedFn();
if (c == "nostealthed") return !isStealthedFn();
// pet / nopet — player has an active pet (hunters, warlocks, DKs)
if (c == "pet") return gameHandler.hasPet();
if (c == "nopet") return !gameHandler.hasPet();
// indoors / outdoors — WMO interior detection (affects mount type selection)
if (c == "indoors" || c == "nooutdoors") {
auto* r = core::Application::getInstance().getRenderer();
return r && r->isPlayerIndoors();
}
if (c == "outdoors" || c == "noindoors") {
auto* r = core::Application::getInstance().getRenderer();
return !r || !r->isPlayerIndoors();
}
// group / nogroup — player is in a party or raid
if (c == "group" || c == "party") return gameHandler.isInGroup();
if (c == "nogroup") return !gameHandler.isInGroup();
// raid / noraid — player is in a raid group (groupType == 1)
if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1;
if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1;
// spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary)
if (c.rfind("spec:", 0) == 0) {
uint8_t wantSpec = 0;
try { wantSpec = static_cast<uint8_t>(std::stoul(c.substr(5))); } catch (...) {}
return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1);
}
// noform / nostance — player is NOT in a shapeshift/stance
if (c == "noform" || c == "nostance") {
for (const auto& a : gameHandler.getPlayerAuras())
@ -6091,6 +6162,33 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return;
}
// /macrohelp command — list available macro conditionals
if (cmdLower == "macrohelp") {
static const char* kMacroHelp[] = {
"--- Macro Conditionals ---",
"Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default",
"State: [combat] [mounted] [swimming] [flying] [stealthed]",
" [channeling] [pet] [group] [raid] [indoors] [outdoors]",
"Spec: [spec:1] [spec:2] (active talent spec, 1-based)",
" (prefix no- to negate any condition)",
"Target: [harm] [help] [exists] [noexists] [dead] [nodead]",
" [target=focus] [target=pet] [target=player]",
"Form: [noform] [nostance] [form:0]",
"Keys: [mod:shift] [mod:ctrl] [mod:alt]",
"Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]",
"Other: #showtooltip, /stopmacro [cond], /castsequence",
};
for (const char* line : kMacroHelp) {
game::MessageChatData m;
m.type = game::ChatType::SYSTEM;
m.language = game::ChatLanguage::UNIVERSAL;
m.message = line;
gameHandler.addLocalChatMessage(m);
}
chatInputBuffer[0] = '\0';
return;
}
// /help command — list available slash commands
if (cmdLower == "help" || cmdLower == "?") {
static const char* kHelpLines[] = {
@ -6109,7 +6207,7 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
"Movement: /sit /stand /kneel /dismount",
"Misc: /played /time /zone /loc /afk [msg] /dnd [msg] /inspect",
" /helm /cloak /trade /join <channel> /leave <channel>",
" /score /unstuck /logout /ticket /screenshot /help",
" /score /unstuck /logout /ticket /screenshot /macrohelp /help",
};
for (const char* line : kHelpLines) {
game::MessageChatData helpMsg;
@ -16020,6 +16118,61 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) {
}
}
// ============================================================
// ItemExtendedCost.dbc loader
// ============================================================
void GameScreen::loadExtendedCostDBC() {
if (extendedCostDbLoaded_) return;
extendedCostDbLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("ItemExtendedCost.dbc");
if (!dbc || !dbc->isLoaded()) return;
// WotLK ItemExtendedCost.dbc: field 0=ID, 1=honorPoints, 2=arenaPoints,
// 3=arenaSlotRestrictions, 4-8=itemId[5], 9-13=itemCount[5], 14=reqRating, 15=purchaseGroup
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t id = dbc->getUInt32(i, 0);
if (id == 0) continue;
ExtendedCostEntry e;
e.honorPoints = dbc->getUInt32(i, 1);
e.arenaPoints = dbc->getUInt32(i, 2);
for (int j = 0; j < 5; ++j) {
e.itemId[j] = dbc->getUInt32(i, 4 + j);
e.itemCount[j] = dbc->getUInt32(i, 9 + j);
}
extendedCostCache_[id] = e;
}
LOG_INFO("ItemExtendedCost.dbc: loaded ", extendedCostCache_.size(), " entries");
}
std::string GameScreen::formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler) {
loadExtendedCostDBC();
auto it = extendedCostCache_.find(extendedCostId);
if (it == extendedCostCache_.end()) return "[Tokens]";
const auto& e = it->second;
std::string result;
if (e.honorPoints > 0) {
result += std::to_string(e.honorPoints) + " Honor";
}
if (e.arenaPoints > 0) {
if (!result.empty()) result += ", ";
result += std::to_string(e.arenaPoints) + " Arena";
}
for (int j = 0; j < 5; ++j) {
if (e.itemId[j] == 0 || e.itemCount[j] == 0) continue;
if (!result.empty()) result += ", ";
gameHandler.ensureItemInfo(e.itemId[j]); // query if not cached
const auto* itemInfo = gameHandler.getItemInfo(e.itemId[j]);
if (itemInfo && itemInfo->valid && !itemInfo->name.empty()) {
result += std::to_string(e.itemCount[j]) + "x " + itemInfo->name;
} else {
result += std::to_string(e.itemCount[j]) + "x Item#" + std::to_string(e.itemId[j]);
}
}
return result.empty() ? "[Tokens]" : result;
}
// ============================================================
// Vendor Window (Phase 5)
// ============================================================
@ -16272,8 +16425,9 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
ImGui::TableSetColumnIndex(2);
if (item.buyPrice == 0 && item.extendedCost != 0) {
// Token-only item (no gold cost)
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "[Tokens]");
// Token-only item — show detailed cost from ItemExtendedCost.dbc
std::string costStr = formatExtendedCost(item.extendedCost, gameHandler);
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", costStr.c_str());
} else {
uint32_t g = item.buyPrice / 10000;
uint32_t s = (item.buyPrice / 100) % 100;
@ -16284,6 +16438,13 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
} else {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c);
}
// Show additional token cost if both gold and tokens are required
if (item.extendedCost != 0) {
std::string costStr = formatExtendedCost(item.extendedCost, gameHandler);
if (costStr != "[Tokens]") {
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 0.8f), "+ %s", costStr.c_str());
}
}
}
ImGui::TableSetColumnIndex(3);
@ -21690,7 +21851,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) {
gameHandler.auctionSearch(auctionSearchName_,
static_cast<uint8_t>(auctionLevelMin_),
static_cast<uint8_t>(auctionLevelMax_),
q, getSearchClassId(), getSearchSubClassId(), 0, 0, offset);
q, getSearchClassId(), getSearchSubClassId(), 0,
auctionUsableOnly_ ? 1 : 0, offset);
};
// Row 1: Name + Level range
@ -21736,6 +21898,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) {
}
}
ImGui::SameLine();
ImGui::Checkbox("Usable", &auctionUsableOnly_);
ImGui::SameLine();
float delay = gameHandler.getAuctionSearchDelay();
if (delay > 0.0f) {
@ -23441,6 +23605,8 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) {
{ 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" },
// Strand of the Ancients (WotLK)
{ 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" },
// Isle of Conquest (WotLK): reinforcements (300 default)
{ 628, "Isle of Conquest", 4221, 4222, 0, 300, "reinforcements" },
};
const BgScoreDef* def = nullptr;

View file

@ -1249,6 +1249,22 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn();
ImGui::Columns(1);
}
// PvP Currency (TBC/WotLK only)
uint32_t honor = gameHandler.getHonorPoints();
uint32_t arena = gameHandler.getArenaPoints();
if (honor > 0 || arena > 0) {
ImGui::Separator();
ImGui::TextDisabled("PvP Currency");
ImGui::Columns(2, "##pvpcurrency", false);
ImGui::SetColumnWidth(0, 130);
ImGui::Text("Honor Points:"); ImGui::NextColumn();
ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.2f, 1.0f), "%u", honor); ImGui::NextColumn();
ImGui::Text("Arena Points:"); ImGui::NextColumn();
ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.2f, 1.0f), "%u", arena); ImGui::NextColumn();
ImGui::Columns(1);
}
ImGui::EndTabItem();
}
@ -1422,32 +1438,54 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
ImGui::EndTabItem();
}
// Equipment Sets tab (WotLK only)
const auto& eqSets = gameHandler.getEquipmentSets();
if (!eqSets.empty()) {
if (ImGui::BeginTabItem("Outfits")) {
// Equipment Sets tab (WotLK only — requires server support)
if (gameHandler.supportsEquipmentSets() && ImGui::BeginTabItem("Outfits")) {
ImGui::Spacing();
ImGui::TextDisabled("Saved Equipment Sets");
// Save current gear as new set
static char newSetName[64] = {};
ImGui::SetNextItemWidth(160.0f);
ImGui::InputTextWithHint("##newsetname", "New set name...", newSetName, sizeof(newSetName));
ImGui::SameLine();
bool canSave = (newSetName[0] != '\0');
if (!canSave) ImGui::BeginDisabled();
if (ImGui::SmallButton("Save Current Gear")) {
gameHandler.saveEquipmentSet(newSetName);
newSetName[0] = '\0';
}
if (!canSave) ImGui::EndDisabled();
ImGui::Separator();
const auto& eqSets = gameHandler.getEquipmentSets();
if (eqSets.empty()) {
ImGui::TextDisabled("No saved equipment sets.");
} else {
ImGui::BeginChild("##EqSetsList", ImVec2(0, 0), false);
for (const auto& es : eqSets) {
ImGui::PushID(static_cast<int>(es.setId));
// Icon placeholder or name
const char* displayName = es.name.empty() ? "(Unnamed)" : es.name.c_str();
ImGui::Text("%s", displayName);
if (!es.iconName.empty()) {
ImGui::SameLine();
ImGui::TextDisabled("(%s)", es.iconName.c_str());
}
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f);
float btnAreaW = 150.0f;
ImGui::SameLine(ImGui::GetContentRegionAvail().x - btnAreaW + ImGui::GetCursorPosX());
if (ImGui::SmallButton("Equip")) {
gameHandler.useEquipmentSet(es.setId);
}
ImGui::SameLine();
if (ImGui::SmallButton("Update")) {
gameHandler.saveEquipmentSet(es.name, es.iconName, es.setGuid, es.setId);
}
ImGui::SameLine();
if (ImGui::SmallButton("Delete")) {
gameHandler.deleteEquipmentSet(es.setGuid);
ImGui::PopID();
break; // Iterator invalidated
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::EndTabItem();
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
@ -2589,6 +2627,20 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName);
}
}
// Show red warning if player lacks proficiency for this weapon/armor type
if (gameHandler_) {
const auto* qi = gameHandler_->getItemInfo(item.itemId);
if (qi && qi->valid) {
bool canUse = true;
if (qi->itemClass == 2) // Weapon
canUse = gameHandler_->canUseWeaponSubclass(qi->subClass);
else if (qi->itemClass == 4 && qi->subClass > 0) // Armor (skip subclass 0 = misc)
canUse = gameHandler_->canUseArmorSubclass(qi->subClass);
if (!canUse)
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "You can't use this type of item.");
}
}
}
auto isWeaponInventoryType = [](uint32_t invType) {
@ -3246,6 +3298,17 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
else
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName);
}
// Proficiency check for vendor/loot tooltips (ItemQueryResponseData has itemClass/subClass)
if (gameHandler_) {
bool canUse = true;
if (info.itemClass == 2) // Weapon
canUse = gameHandler_->canUseWeaponSubclass(info.subClass);
else if (info.itemClass == 4 && info.subClass > 0) // Armor (skip subclass 0 = misc)
canUse = gameHandler_->canUseArmorSubclass(info.subClass);
if (!canUse)
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "You can't use this type of item.");
}
}
// Weapon stats