mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Compare commits
55 commits
87cb293297
...
e0346c85df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0346c85df | ||
|
|
379ca116d1 | ||
|
|
702155ff4f | ||
|
|
25138b5648 | ||
|
|
2fb7901cca | ||
|
|
fabcde42a5 | ||
|
|
90843ea989 | ||
|
|
d0f544395e | ||
|
|
4e13a344e8 | ||
|
|
a802e05091 | ||
|
|
e7fe35c1f9 | ||
|
|
586408516b | ||
|
|
5f3bc79653 | ||
|
|
277a26b351 | ||
|
|
c1765b6b39 | ||
|
|
09b0bea981 | ||
|
|
d7c377292e | ||
|
|
167e710f92 | ||
|
|
1fd3d5fdc8 | ||
|
|
4025e6576c | ||
|
|
df7150503b | ||
|
|
5d4b0b0f04 | ||
|
|
a151531a2a | ||
|
|
11c07f19cb | ||
|
|
6cd3c613ef | ||
|
|
e2a484256c | ||
|
|
28d7d3ec00 | ||
|
|
7967bfdcb1 | ||
|
|
d2b2a25393 | ||
|
|
30513d0f06 | ||
|
|
ed3bca3d17 | ||
|
|
c676d99fc2 | ||
|
|
ae3e57ac3b | ||
|
|
c3be43de58 | ||
|
|
db0f868549 | ||
|
|
b236a85454 | ||
|
|
fa3a5ec67e | ||
|
|
8abb65a813 | ||
|
|
2c86fb4fa6 | ||
|
|
1588c1029a | ||
|
|
36158ae3e3 | ||
|
|
7a0c7241ba | ||
|
|
5801af41bc | ||
|
|
57b44d2347 | ||
|
|
6be695078b | ||
|
|
76ba428b87 | ||
|
|
60d5edf97f | ||
|
|
4907f4124b | ||
|
|
0f8852d290 | ||
|
|
5a5c2dcda3 | ||
|
|
395a8f77c4 | ||
|
|
b0046fa777 | ||
|
|
2acab47eee | ||
|
|
d99fe8de0f | ||
|
|
3e3bbf915e |
15 changed files with 2092 additions and 237 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Project Status
|
||||
|
||||
**Last updated**: 2026-03-11
|
||||
**Last updated**: 2026-03-18
|
||||
|
||||
## What This Repo Is
|
||||
|
||||
|
|
@ -35,9 +35,9 @@ Implemented (working in normal use):
|
|||
In progress / known gaps:
|
||||
|
||||
- Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain
|
||||
- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects)
|
||||
- Visual edge cases: some M2/WMO rendering gaps (some particle effects)
|
||||
- Lava steam particles: sparse in some areas (tuning opportunity)
|
||||
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs); currently requires FSR to be active
|
||||
- Water refraction: enabled by default; srcAccessMask barrier fix (2026-03-18) resolved prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs
|
||||
|
||||
## Where To Look
|
||||
|
||||
|
|
|
|||
|
|
@ -375,6 +375,10 @@ public:
|
|||
std::shared_ptr<Entity> getFocus() const;
|
||||
bool hasFocus() const { return focusGuid != 0; }
|
||||
|
||||
// Mouseover targeting — set each frame by the nameplate renderer
|
||||
void setMouseoverGuid(uint64_t guid) { mouseoverGuid_ = guid; }
|
||||
uint64_t getMouseoverGuid() const { return mouseoverGuid_; }
|
||||
|
||||
// Advanced targeting
|
||||
void targetLastTarget();
|
||||
void targetEnemy(bool reverse = false);
|
||||
|
|
@ -742,6 +746,8 @@ public:
|
|||
}
|
||||
// Send CMSG_PET_ACTION to issue a pet command
|
||||
void sendPetAction(uint32_t action, uint64_t targetGuid = 0);
|
||||
// Toggle autocast for a pet spell via CMSG_PET_SPELL_AUTOCAST
|
||||
void togglePetSpellAutocast(uint32_t spellId);
|
||||
const std::unordered_set<uint32_t>& getKnownSpells() const { return knownSpells; }
|
||||
|
||||
// ---- Pet Stable ----
|
||||
|
|
@ -803,6 +809,9 @@ public:
|
|||
int getCraftQueueRemaining() const { return craftQueueRemaining_; }
|
||||
uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; }
|
||||
|
||||
// 400ms spell-queue window: next spell to cast when current finishes
|
||||
uint32_t getQueuedSpellId() const { return queuedSpellId_; }
|
||||
|
||||
// Unit cast state (tracked per GUID for target frame + boss frames)
|
||||
struct UnitCastState {
|
||||
bool casting = false;
|
||||
|
|
@ -885,6 +894,10 @@ public:
|
|||
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
|
||||
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
|
||||
|
||||
// Client-side macro text storage (server sends only macro index; text is stored locally)
|
||||
const std::string& getMacroText(uint32_t macroId) const;
|
||||
void setMacroText(uint32_t macroId, const std::string& text);
|
||||
|
||||
void saveCharacterConfig();
|
||||
void loadCharacterConfig();
|
||||
static std::string getCharacterConfigDir();
|
||||
|
|
@ -931,6 +944,10 @@ public:
|
|||
using SpellCastAnimCallback = std::function<void(uint64_t guid, bool start, bool isChannel)>;
|
||||
void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); }
|
||||
|
||||
// Fired when the player's own spell cast fails (spellId of the failed spell).
|
||||
using SpellCastFailedCallback = std::function<void(uint32_t spellId)>;
|
||||
void setSpellCastFailedCallback(SpellCastFailedCallback cb) { spellCastFailedCallback_ = std::move(cb); }
|
||||
|
||||
// Unit animation hint: signal jump (animId=38) for other players/NPCs
|
||||
using UnitAnimHintCallback = std::function<void(uint64_t guid, uint32_t animId)>;
|
||||
void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); }
|
||||
|
|
@ -1171,6 +1188,10 @@ public:
|
|||
bool isPlayerGhost() const { return releasedSpirit_; }
|
||||
bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; }
|
||||
bool showResurrectDialog() const { return resurrectRequestPending_; }
|
||||
/** True when SMSG_PRE_RESURRECT arrived — Reincarnation/Twisting Nether available. */
|
||||
bool canSelfRes() const { return selfResAvailable_; }
|
||||
/** Send CMSG_SELF_RES to use Reincarnation / Twisting Nether. */
|
||||
void useSelfRes();
|
||||
const std::string& getResurrectCasterName() const { return resurrectCasterName_; }
|
||||
bool showTalentWipeConfirmDialog() const { return talentWipePending_; }
|
||||
uint32_t getTalentWipeCost() const { return talentWipeCost_; }
|
||||
|
|
@ -1183,6 +1204,8 @@ public:
|
|||
void cancelPetUnlearn() { petUnlearnPending_ = false; }
|
||||
/** True when ghost is within 40 yards of corpse position (same map). */
|
||||
bool canReclaimCorpse() const;
|
||||
/** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */
|
||||
float getCorpseReclaimDelaySec() const;
|
||||
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
|
||||
float getCorpseDistance() const {
|
||||
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f;
|
||||
|
|
@ -1878,6 +1901,7 @@ public:
|
|||
|
||||
bool isMounted() const { return currentMountDisplayId_ != 0; }
|
||||
bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
|
||||
bool isHostileFactionPublic(uint32_t factionTemplateId) const { return isHostileFaction(factionTemplateId); }
|
||||
float getServerRunSpeed() const { return serverRunSpeed_; }
|
||||
float getServerWalkSpeed() const { return serverWalkSpeed_; }
|
||||
float getServerSwimSpeed() const { return serverSwimSpeed_; }
|
||||
|
|
@ -1983,6 +2007,9 @@ public:
|
|||
void autoEquipItemInBag(int bagIndex, int slotIndex);
|
||||
void useItemBySlot(int backpackIndex);
|
||||
void useItemInBag(int bagIndex, int slotIndex);
|
||||
// CMSG_OPEN_ITEM — for locked containers (lockboxes); server checks keyring automatically
|
||||
void openItemBySlot(int backpackIndex);
|
||||
void openItemInBag(int bagIndex, int slotIndex);
|
||||
void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1);
|
||||
void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot);
|
||||
void swapBagSlots(int srcBagIndex, int dstBagIndex);
|
||||
|
|
@ -2110,6 +2137,22 @@ public:
|
|||
if (index < 0 || index >= static_cast<int>(backpackSlotGuids_.size())) return 0;
|
||||
return backpackSlotGuids_[index];
|
||||
}
|
||||
uint64_t getEquipSlotGuid(int slot) const {
|
||||
if (slot < 0 || slot >= static_cast<int>(equipSlotGuids_.size())) return 0;
|
||||
return equipSlotGuids_[slot];
|
||||
}
|
||||
// Returns the permanent and temporary enchant IDs for an item by GUID (0 if unknown).
|
||||
std::pair<uint32_t, uint32_t> getItemEnchantIds(uint64_t guid) const {
|
||||
auto it = onlineItems_.find(guid);
|
||||
if (it == onlineItems_.end()) return {0, 0};
|
||||
return {it->second.permanentEnchantId, it->second.temporaryEnchantId};
|
||||
}
|
||||
// Returns the socket gem enchant IDs (3 slots; 0 = empty socket) for an item by GUID.
|
||||
std::array<uint32_t, 3> getItemSocketEnchantIds(uint64_t guid) const {
|
||||
auto it = onlineItems_.find(guid);
|
||||
if (it == onlineItems_.end()) return {};
|
||||
return it->second.socketEnchantIds;
|
||||
}
|
||||
uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; }
|
||||
|
||||
/**
|
||||
|
|
@ -2526,6 +2569,7 @@ private:
|
|||
uint64_t targetGuid = 0;
|
||||
uint64_t focusGuid = 0; // Focus target
|
||||
uint64_t lastTargetGuid = 0; // Previous target
|
||||
uint64_t mouseoverGuid_ = 0; // Set each frame by nameplate renderer
|
||||
std::vector<uint64_t> tabCycleList;
|
||||
int tabCycleIndex = -1;
|
||||
bool tabCycleStale = true;
|
||||
|
|
@ -2605,10 +2649,21 @@ private:
|
|||
uint32_t stackCount = 1;
|
||||
uint32_t curDurability = 0;
|
||||
uint32_t maxDurability = 0;
|
||||
uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting)
|
||||
uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons)
|
||||
std::array<uint32_t, 3> socketEnchantIds{}; // ITEM_ENCHANTMENT_SLOT 2-4 (gems)
|
||||
};
|
||||
std::unordered_map<uint64_t, OnlineItemInfo> onlineItems_;
|
||||
std::unordered_map<uint32_t, ItemQueryResponseData> itemInfoCache_;
|
||||
std::unordered_set<uint32_t> pendingItemQueries_;
|
||||
|
||||
// Deferred SMSG_ITEM_PUSH_RESULT notifications for items whose info wasn't
|
||||
// cached at arrival time; emitted once the query response arrives.
|
||||
struct PendingItemPushNotif {
|
||||
uint32_t itemId = 0;
|
||||
uint32_t count = 1;
|
||||
};
|
||||
std::vector<PendingItemPushNotif> pendingItemPushNotifs_;
|
||||
std::array<uint64_t, 23> equipSlotGuids_{};
|
||||
std::array<uint64_t, 16> backpackSlotGuids_{};
|
||||
std::array<uint64_t, 32> keyringSlotGuids_{};
|
||||
|
|
@ -2719,6 +2774,9 @@ private:
|
|||
// Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes
|
||||
uint32_t craftQueueSpellId_ = 0;
|
||||
int craftQueueRemaining_ = 0;
|
||||
// Spell queue: next spell to cast within the 400ms window before current cast ends
|
||||
uint32_t queuedSpellId_ = 0;
|
||||
uint64_t queuedSpellTarget_ = 0;
|
||||
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
|
||||
std::unordered_map<uint64_t, UnitCastState> unitCastStates_;
|
||||
uint64_t pendingGameObjectInteractGuid_ = 0;
|
||||
|
|
@ -2750,6 +2808,7 @@ private:
|
|||
|
||||
float castTimeTotal = 0.0f;
|
||||
std::array<ActionBarSlot, ACTION_BAR_SLOTS> actionBar{};
|
||||
std::unordered_map<uint32_t, std::string> macros_; // client-side macro text (persisted in char config)
|
||||
std::vector<AuraSlot> playerAuras;
|
||||
std::vector<AuraSlot> targetAuras;
|
||||
std::unordered_map<uint64_t, std::vector<AuraSlot>> unitAurasCache_; // per-unit aura cache
|
||||
|
|
@ -3262,6 +3321,7 @@ private:
|
|||
MeleeSwingCallback meleeSwingCallback_;
|
||||
uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing
|
||||
SpellCastAnimCallback spellCastAnimCallback_;
|
||||
SpellCastFailedCallback spellCastFailedCallback_;
|
||||
UnitAnimHintCallback unitAnimHintCallback_;
|
||||
UnitMoveFlagsCallback unitMoveFlagsCallback_;
|
||||
NpcSwingCallback npcSwingCallback_;
|
||||
|
|
@ -3298,6 +3358,9 @@ private:
|
|||
uint32_t corpseMapId_ = 0;
|
||||
float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f;
|
||||
uint64_t corpseGuid_ = 0;
|
||||
// Absolute time (ms since epoch) when PvP corpse-reclaim delay expires.
|
||||
// 0 means no active delay (reclaim allowed immediately upon proximity).
|
||||
uint64_t corpseReclaimAvailableMs_ = 0;
|
||||
// Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially
|
||||
std::array<RuneSlot, 6> playerRunes_ = [] {
|
||||
std::array<RuneSlot, 6> r{};
|
||||
|
|
@ -3309,6 +3372,7 @@ private:
|
|||
uint64_t pendingSpiritHealerGuid_ = 0;
|
||||
bool resurrectPending_ = false;
|
||||
bool resurrectRequestPending_ = false;
|
||||
bool selfResAvailable_ = false; // SMSG_PRE_RESURRECT received — Reincarnation/Twisting Nether
|
||||
// ---- Talent wipe confirm dialog ----
|
||||
bool talentWipePending_ = false;
|
||||
uint64_t talentWipeNpcGuid_ = 0;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ public:
|
|||
int findFreeBackpackSlot() const;
|
||||
bool addItem(const ItemDef& item);
|
||||
|
||||
// Sort all bag slots (backpack + equip bags) by quality desc → itemId asc → stackCount desc.
|
||||
// Purely client-side: reorders the local inventory struct without server interaction.
|
||||
void sortBags();
|
||||
|
||||
// Test data
|
||||
void populateTestItems();
|
||||
|
||||
|
|
|
|||
|
|
@ -2027,6 +2027,12 @@ public:
|
|||
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0);
|
||||
};
|
||||
|
||||
/** CMSG_OPEN_ITEM packet builder (for locked containers / lockboxes) */
|
||||
class OpenItemPacket {
|
||||
public:
|
||||
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex);
|
||||
};
|
||||
|
||||
/** CMSG_AUTOEQUIP_ITEM packet builder */
|
||||
class AutoEquipItemPacket {
|
||||
public:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ private:
|
|||
std::vector<std::string> chatSentHistory_;
|
||||
int chatHistoryIdx_ = -1; // -1 = not browsing history
|
||||
|
||||
// Set to true by /stopmacro; checked in executeMacroText to halt remaining commands.
|
||||
bool macroStopped_ = false;
|
||||
|
||||
// Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends.
|
||||
// Populated by the SpellCastFailedCallback; queried during action bar button rendering.
|
||||
std::unordered_map<uint32_t, float> actionFlashEndTimes_;
|
||||
|
||||
// Tab-completion state for slash commands
|
||||
std::string chatTabPrefix_; // prefix captured on first Tab press
|
||||
std::vector<std::string> chatTabMatches_; // matching command list
|
||||
|
|
@ -106,6 +113,8 @@ private:
|
|||
std::vector<UIErrorEntry> uiErrors_;
|
||||
bool uiErrorCallbackSet_ = false;
|
||||
static constexpr float kUIErrorLifetime = 2.5f;
|
||||
bool castFailedCallbackSet_ = false;
|
||||
static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade
|
||||
|
||||
// Reputation change toast: brief colored slide-in below minimap
|
||||
struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; };
|
||||
|
|
@ -170,7 +179,7 @@ private:
|
|||
int pendingResIndex = 0;
|
||||
bool pendingShadows = true;
|
||||
float pendingShadowDistance = 300.0f;
|
||||
bool pendingWaterRefraction = false;
|
||||
bool pendingWaterRefraction = true;
|
||||
int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default)
|
||||
int pendingMasterVolume = 100;
|
||||
int pendingMusicVolume = 30;
|
||||
|
|
@ -201,6 +210,10 @@ private:
|
|||
// Keybinding customization
|
||||
int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index
|
||||
bool awaitingKeyPress = false;
|
||||
// Macro editor popup state
|
||||
uint32_t macroEditorId_ = 0; // macro index being edited
|
||||
bool macroEditorOpen_ = false; // deferred OpenPopup flag
|
||||
char macroEditorBuf_[256] = {}; // edit buffer
|
||||
bool pendingUseOriginalSoundtrack = true;
|
||||
bool pendingShowActionBar2 = true; // Show second action bar above main bar
|
||||
float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5)
|
||||
|
|
@ -274,6 +287,7 @@ private:
|
|||
* Send chat message
|
||||
*/
|
||||
void sendChatMessage(game::GameHandler& gameHandler);
|
||||
void executeMacroText(game::GameHandler& gameHandler, const std::string& macroText);
|
||||
|
||||
/**
|
||||
* Get chat type name
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ private:
|
|||
std::unordered_map<uint32_t, VkDescriptorSet> iconCache_;
|
||||
public:
|
||||
VkDescriptorSet getItemIcon(uint32_t displayInfoId);
|
||||
void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr);
|
||||
void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0);
|
||||
private:
|
||||
|
||||
// Character model preview
|
||||
|
|
@ -161,7 +161,7 @@ private:
|
|||
SlotKind kind, int backpackIndex,
|
||||
game::EquipSlot equipSlot,
|
||||
int bagIndex = -1, int bagSlotIndex = -1);
|
||||
void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr);
|
||||
void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0);
|
||||
|
||||
// Held item helpers
|
||||
void pickupFromBackpack(game::Inventory& inv, int index);
|
||||
|
|
|
|||
|
|
@ -6908,6 +6908,10 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
|
|||
};
|
||||
|
||||
// --- Geosets ---
|
||||
// Mirror the same group-range logic as CharacterPreview::applyEquipment to
|
||||
// keep other-player rendering consistent with the local character preview.
|
||||
// Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves,
|
||||
// 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md).
|
||||
std::unordered_set<uint16_t> geosets;
|
||||
// Body parts (group 0: IDs 0-99, some models use up to 27)
|
||||
for (uint16_t i = 0; i <= 99; i++) geosets.insert(i);
|
||||
|
|
@ -6915,8 +6919,6 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
|
|||
uint8_t hairStyleId = static_cast<uint8_t>((st.appearanceBytes >> 16) & 0xFF);
|
||||
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
|
||||
geosets.insert(static_cast<uint16_t>(200 + st.facialFeatures + 1));
|
||||
geosets.insert(401); // Body joint patches (knees)
|
||||
geosets.insert(402); // Body joint patches (elbows)
|
||||
geosets.insert(701); // Ears
|
||||
geosets.insert(902); // Kneepads
|
||||
geosets.insert(2002); // Bare feet mesh
|
||||
|
|
@ -6924,39 +6926,47 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
|
|||
const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7;
|
||||
const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9;
|
||||
|
||||
// Chest/Shirt/Robe (invType 4,5,20)
|
||||
// Per-group defaults — overridden below when equipment provides a geoset value.
|
||||
uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves)
|
||||
uint16_t geosetBoots = 502; // Bare shins (group 5, no boots)
|
||||
uint16_t geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves)
|
||||
uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings)
|
||||
|
||||
// Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8
|
||||
{
|
||||
uint32_t did = findDisplayIdByInvType({4, 5, 20});
|
||||
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
|
||||
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 501 + gg1 : 501));
|
||||
|
||||
if (gg1 > 0) geosetSleeves = static_cast<uint16_t>(801 + gg1);
|
||||
// Robe kilt → leg group 13
|
||||
uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field);
|
||||
if (gg3 > 0) geosets.insert(static_cast<uint16_t>(1301 + gg3));
|
||||
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
|
||||
}
|
||||
|
||||
// Legs (invType 7)
|
||||
// Legs (invType 7) → leg group 13
|
||||
{
|
||||
uint32_t did = findDisplayIdByInvType({7});
|
||||
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
|
||||
if (geosets.count(1302) == 0 && geosets.count(1303) == 0) {
|
||||
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 1301 + gg1 : 1301));
|
||||
}
|
||||
if (gg1 > 0) geosetPants = static_cast<uint16_t>(1301 + gg1);
|
||||
}
|
||||
|
||||
// Feet (invType 8): 401/402 are body patches (always on), 403+ are boot meshes
|
||||
// Feet/Boots (invType 8) → shin group 5
|
||||
{
|
||||
uint32_t did = findDisplayIdByInvType({8});
|
||||
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
|
||||
if (gg1 > 0) geosets.insert(static_cast<uint16_t>(402 + gg1));
|
||||
if (gg1 > 0) geosetBoots = static_cast<uint16_t>(501 + gg1);
|
||||
}
|
||||
|
||||
// Hands (invType 10)
|
||||
// Hands/Gloves (invType 10) → forearm group 4
|
||||
{
|
||||
uint32_t did = findDisplayIdByInvType({10});
|
||||
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
|
||||
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 301 + gg1 : 301));
|
||||
if (gg1 > 0) geosetGloves = static_cast<uint16_t>(401 + gg1);
|
||||
}
|
||||
|
||||
geosets.insert(geosetGloves);
|
||||
geosets.insert(geosetBoots);
|
||||
geosets.insert(geosetSleeves);
|
||||
geosets.insert(geosetPants);
|
||||
// Back/Cloak (invType 16)
|
||||
geosets.insert(hasInvType({16}) ? 1502 : 1501);
|
||||
// Tabard (invType 19)
|
||||
|
|
|
|||
|
|
@ -1956,12 +1956,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
|
||||
queryItemInfo(itemId, 0);
|
||||
if (showInChat) {
|
||||
std::string itemName = "item #" + std::to_string(itemId);
|
||||
uint32_t quality = 1; // white default
|
||||
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
|
||||
if (!info->name.empty()) itemName = info->name;
|
||||
quality = info->quality;
|
||||
}
|
||||
// Item info already cached — emit immediately.
|
||||
std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name;
|
||||
uint32_t quality = info->quality;
|
||||
std::string link = buildItemLink(itemId, quality, itemName);
|
||||
std::string msg = "Received: " + link;
|
||||
if (count > 1) msg += " x" + std::to_string(count);
|
||||
|
|
@ -1970,8 +1968,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
if (auto* sfx = renderer->getUiSoundManager())
|
||||
sfx->playLootItem();
|
||||
}
|
||||
if (itemLootCallback_) {
|
||||
itemLootCallback_(itemId, count, quality, itemName);
|
||||
if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName);
|
||||
} else {
|
||||
// Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE.
|
||||
pendingItemPushNotifs_.push_back({itemId, count});
|
||||
}
|
||||
}
|
||||
LOG_INFO("Item push: itemId=", itemId, " count=", count,
|
||||
|
|
@ -2252,9 +2252,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
lastInteractedGoGuid_ = 0;
|
||||
// Cancel craft queue on cast failure
|
||||
// Cancel craft queue and spell queue on cast failure
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
|
||||
int playerPowerType = -1;
|
||||
if (auto pe = entityManager.getEntity(playerGuid)) {
|
||||
|
|
@ -2265,6 +2267,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
std::string errMsg = reason ? reason
|
||||
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
|
||||
addUIError(errMsg);
|
||||
if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId);
|
||||
MessageChatData msg;
|
||||
msg.type = ChatType::SYSTEM;
|
||||
msg.language = ChatLanguage::UNIVERSAL;
|
||||
|
|
@ -2645,25 +2648,31 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
break;
|
||||
}
|
||||
case Opcode::SMSG_CORPSE_RECLAIM_DELAY: {
|
||||
// uint32 delayMs before player can reclaim corpse
|
||||
// uint32 delayMs before player can reclaim corpse (PvP deaths)
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
uint32_t delayMs = packet.readUInt32();
|
||||
uint32_t delaySec = (delayMs + 999) / 1000;
|
||||
addSystemChatMessage("You can reclaim your corpse in " +
|
||||
std::to_string(delaySec) + " seconds.");
|
||||
LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms");
|
||||
auto nowMs = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
corpseReclaimAvailableMs_ = nowMs + delayMs;
|
||||
LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_DEATH_RELEASE_LOC: {
|
||||
// uint32 mapId + float x + float y + float z — corpse/spirit healer position
|
||||
// uint32 mapId + float x + float y + float z
|
||||
// This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location.
|
||||
// The corpse remains at the death position (already cached when health dropped to 0,
|
||||
// and updated when the corpse object arrives via SMSG_UPDATE_OBJECT).
|
||||
// Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse()
|
||||
// by making it check distance to the graveyard instead of the real corpse.
|
||||
if (packet.getSize() - packet.getReadPos() >= 16) {
|
||||
corpseMapId_ = packet.readUInt32();
|
||||
corpseX_ = packet.readFloat();
|
||||
corpseY_ = packet.readFloat();
|
||||
corpseZ_ = packet.readFloat();
|
||||
LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_,
|
||||
" x=", corpseX_, " y=", corpseY_, " z=", corpseZ_);
|
||||
uint32_t relMapId = packet.readUInt32();
|
||||
float relX = packet.readFloat();
|
||||
float relY = packet.readFloat();
|
||||
float relZ = packet.readFloat();
|
||||
LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId,
|
||||
" x=", relX, " y=", relY, " z=", relZ);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -3240,9 +3249,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
}
|
||||
break;
|
||||
case Opcode::SMSG_ATTACKSWING_NOTSTANDING:
|
||||
case Opcode::SMSG_ATTACKSWING_CANT_ATTACK:
|
||||
autoAttackOutOfRange_ = false;
|
||||
autoAttackOutOfRangeTime_ = 0.0f;
|
||||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||||
addSystemChatMessage("You need to stand up to fight.");
|
||||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||||
}
|
||||
break;
|
||||
case Opcode::SMSG_ATTACKSWING_CANT_ATTACK:
|
||||
// Target is permanently non-attackable (critter, civilian, already dead, etc.).
|
||||
// Stop the auto-attack loop so the client doesn't spam the server.
|
||||
stopAutoAttack();
|
||||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||||
addSystemChatMessage("You can't attack that.");
|
||||
autoAttackRangeWarnCooldown_ = 1.25f;
|
||||
}
|
||||
break;
|
||||
case Opcode::SMSG_ATTACKERSTATEUPDATE:
|
||||
handleAttackerStateUpdate(packet);
|
||||
|
|
@ -3347,6 +3368,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
lastInteractedGoGuid_ = 0;
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||||
if (auto* ssm = renderer->getSpellSoundManager()) {
|
||||
ssm->stopPrecast();
|
||||
|
|
@ -4006,9 +4031,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
}
|
||||
worldStateMapId_ = packet.readUInt32();
|
||||
worldStateZoneId_ = packet.readUInt32();
|
||||
// WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent
|
||||
// WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format
|
||||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||||
bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle");
|
||||
bool isWotLKFormat = isActiveExpansion("wotlk");
|
||||
if (isWotLKFormat && remaining >= 6) {
|
||||
packet.readUInt32(); // areaId (WotLK only)
|
||||
}
|
||||
|
|
@ -4166,7 +4191,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
if (delayMs == 0) break;
|
||||
float delaySec = delayMs / 1000.0f;
|
||||
if (caster == playerGuid) {
|
||||
if (casting) castTimeRemaining += delaySec;
|
||||
if (casting) {
|
||||
castTimeRemaining += delaySec;
|
||||
castTimeTotal += delaySec; // keep progress percentage correct
|
||||
}
|
||||
} else {
|
||||
auto it = unitCastStates_.find(caster);
|
||||
if (it != unitCastStates_.end() && it->second.casting) {
|
||||
|
|
@ -4411,9 +4439,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
ActionBarSlot slot;
|
||||
switch (type) {
|
||||
case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break;
|
||||
case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break;
|
||||
case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break;
|
||||
default: continue; // macro or unknown — leave as-is
|
||||
case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item
|
||||
case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item
|
||||
case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions)
|
||||
default: continue; // unknown — leave as-is
|
||||
}
|
||||
actionBar[i] = slot;
|
||||
}
|
||||
|
|
@ -7310,8 +7339,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
|
||||
// ---- Pre-resurrect state ----
|
||||
case Opcode::SMSG_PRE_RESURRECT: {
|
||||
// packed GUID of the player to enter pre-resurrect
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
// SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect.
|
||||
// Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock),
|
||||
// or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept.
|
||||
uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (targetGuid == playerGuid || targetGuid == 0) {
|
||||
selfResAvailable_ = true;
|
||||
LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x",
|
||||
std::hex, targetGuid, std::dec, ")");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -9077,9 +9113,14 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
|
|||
lastInteractedGoGuid_ = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
castTimeTotal = 0.0f;
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
playerDead_ = false;
|
||||
releasedSpirit_ = false;
|
||||
corpseGuid_ = 0;
|
||||
corpseReclaimAvailableMs_ = 0;
|
||||
targetGuid = 0;
|
||||
focusGuid = 0;
|
||||
lastTargetGuid = 0;
|
||||
|
|
@ -9186,6 +9227,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
|||
movementInfo.jumpXYSpeed = 0.0f;
|
||||
resurrectPending_ = false;
|
||||
resurrectRequestPending_ = false;
|
||||
selfResAvailable_ = false;
|
||||
onTaxiFlight_ = false;
|
||||
taxiMountActive_ = false;
|
||||
taxiActivatePending_ = false;
|
||||
|
|
@ -10685,6 +10727,21 @@ void GameHandler::sendMovement(Opcode opcode) {
|
|||
}
|
||||
}
|
||||
|
||||
// Cancel any timed (non-channeled) cast the moment the player starts moving.
|
||||
// Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server.
|
||||
// Turning (MSG_MOVE_START_TURN_*) is allowed while casting.
|
||||
if (casting && !castIsChannel) {
|
||||
const bool isPositionalMove =
|
||||
opcode == Opcode::MSG_MOVE_START_FORWARD ||
|
||||
opcode == Opcode::MSG_MOVE_START_BACKWARD ||
|
||||
opcode == Opcode::MSG_MOVE_START_STRAFE_LEFT ||
|
||||
opcode == Opcode::MSG_MOVE_START_STRAFE_RIGHT ||
|
||||
opcode == Opcode::MSG_MOVE_JUMP;
|
||||
if (isPositionalMove) {
|
||||
cancelCast();
|
||||
}
|
||||
}
|
||||
|
||||
// Update movement flags based on opcode
|
||||
switch (opcode) {
|
||||
case Opcode::MSG_MOVE_START_FORWARD:
|
||||
|
|
@ -10978,9 +11035,11 @@ void GameHandler::forceClearTaxiAndMovementState() {
|
|||
vehicleId_ = 0;
|
||||
resurrectPending_ = false;
|
||||
resurrectRequestPending_ = false;
|
||||
selfResAvailable_ = false;
|
||||
playerDead_ = false;
|
||||
releasedSpirit_ = false;
|
||||
corpseGuid_ = 0;
|
||||
corpseReclaimAvailableMs_ = 0;
|
||||
repopPending_ = false;
|
||||
pendingSpiritHealerGuid_ = 0;
|
||||
resurrectCasterGuid_ = 0;
|
||||
|
|
@ -11619,6 +11678,13 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
|
|||
auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT));
|
||||
auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY));
|
||||
auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY));
|
||||
const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF)
|
||||
? static_cast<uint16_t>(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu;
|
||||
auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end();
|
||||
auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end();
|
||||
auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end();
|
||||
auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end();
|
||||
auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end();
|
||||
if (entryIt != block.fields.end() && entryIt->second != 0) {
|
||||
// Preserve existing info when doing partial updates
|
||||
OnlineItemInfo info = onlineItems_.count(block.guid)
|
||||
|
|
@ -11627,6 +11693,11 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
|
|||
if (stackIt != block.fields.end()) info.stackCount = stackIt->second;
|
||||
if (durIt != block.fields.end()) info.curDurability = durIt->second;
|
||||
if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second;
|
||||
if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second;
|
||||
if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second;
|
||||
if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second;
|
||||
if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second;
|
||||
if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second;
|
||||
bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end());
|
||||
onlineItems_[block.guid] = info;
|
||||
if (isNew) newItemCreated = true;
|
||||
|
|
@ -11878,6 +11949,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
|
|||
} else if (wasDead && !nowDead) {
|
||||
playerDead_ = false;
|
||||
releasedSpirit_ = false;
|
||||
selfResAvailable_ = false;
|
||||
LOG_INFO("Player resurrected (dynamic flags)");
|
||||
}
|
||||
} else if (entity->getType() == ObjectType::UNIT) {
|
||||
|
|
@ -12159,8 +12231,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
|
|||
playerDead_ = false;
|
||||
repopPending_ = false;
|
||||
resurrectPending_ = false;
|
||||
selfResAvailable_ = false;
|
||||
corpseMapId_ = 0; // corpse reclaimed
|
||||
corpseGuid_ = 0;
|
||||
corpseReclaimAvailableMs_ = 0;
|
||||
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
|
||||
if (ghostStateCallback_) ghostStateCallback_(false);
|
||||
}
|
||||
|
|
@ -12208,6 +12282,15 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
|
|||
const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY);
|
||||
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
|
||||
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
|
||||
// ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset
|
||||
// across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8).
|
||||
// Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12).
|
||||
const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF;
|
||||
const uint16_t itemPermEnchField = itemEnchBase;
|
||||
const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF;
|
||||
const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF;
|
||||
const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF;
|
||||
const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF;
|
||||
|
||||
auto it = onlineItems_.find(block.guid);
|
||||
bool isItemInInventory = (it != onlineItems_.end());
|
||||
|
|
@ -12220,14 +12303,61 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
|
|||
}
|
||||
} else if (key == itemDurField && isItemInInventory) {
|
||||
if (it->second.curDurability != val) {
|
||||
const uint32_t prevDur = it->second.curDurability;
|
||||
it->second.curDurability = val;
|
||||
inventoryChanged = true;
|
||||
// Warn once when durability drops below 20% for an equipped item.
|
||||
const uint32_t maxDur = it->second.maxDurability;
|
||||
if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) {
|
||||
// Check if this item is in an equip slot (not bag inventory).
|
||||
bool isEquipped = false;
|
||||
for (uint64_t slotGuid : equipSlotGuids_) {
|
||||
if (slotGuid == block.guid) { isEquipped = true; break; }
|
||||
}
|
||||
if (isEquipped) {
|
||||
std::string itemName;
|
||||
const auto* info = getItemInfo(it->second.entry);
|
||||
if (info) itemName = info->name;
|
||||
char buf[128];
|
||||
if (!itemName.empty())
|
||||
std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str());
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "An equipped item is about to break!");
|
||||
addUIError(buf);
|
||||
addSystemChatMessage(buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key == itemMaxDurField && isItemInInventory) {
|
||||
if (it->second.maxDurability != val) {
|
||||
it->second.maxDurability = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) {
|
||||
if (it->second.permanentEnchantId != val) {
|
||||
it->second.permanentEnchantId = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) {
|
||||
if (it->second.temporaryEnchantId != val) {
|
||||
it->second.temporaryEnchantId = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) {
|
||||
if (it->second.socketEnchantIds[0] != val) {
|
||||
it->second.socketEnchantIds[0] = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) {
|
||||
if (it->second.socketEnchantIds[1] != val) {
|
||||
it->second.socketEnchantIds[1] = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
} else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) {
|
||||
if (it->second.socketEnchantIds[2] != val) {
|
||||
it->second.socketEnchantIds[2] = val;
|
||||
inventoryChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update container slot GUIDs on bag content changes
|
||||
|
|
@ -13895,7 +14025,7 @@ void GameHandler::stopCasting() {
|
|||
socket->send(packet);
|
||||
}
|
||||
|
||||
// Reset casting state
|
||||
// Reset casting state and clear any queued spell so it doesn't fire later
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
|
|
@ -13903,6 +14033,10 @@ void GameHandler::stopCasting() {
|
|||
lastInteractedGoGuid_ = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
castTimeTotal = 0.0f;
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
|
||||
LOG_INFO("Cancelled spell cast");
|
||||
}
|
||||
|
|
@ -13916,7 +14050,12 @@ void GameHandler::releaseSpirit() {
|
|||
}
|
||||
auto packet = RepopRequestPacket::build();
|
||||
socket->send(packet);
|
||||
releasedSpirit_ = true;
|
||||
// Do NOT set releasedSpirit_ = true here. Setting it optimistically races
|
||||
// with PLAYER_FLAGS field updates that arrive before the server processes
|
||||
// CMSG_REPOP_REQUEST: the PLAYER_FLAGS handler sees wasGhost=true/nowGhost=false
|
||||
// and fires the "ghost cleared" path, wiping corpseMapId_/corpseGuid_.
|
||||
// Let the server drive ghost state via PLAYER_FLAGS_GHOST (field update path).
|
||||
selfResAvailable_ = false; // self-res window closes when spirit is released
|
||||
repopPending_ = true;
|
||||
lastRepopRequestMs_ = static_cast<uint64_t>(now);
|
||||
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)");
|
||||
|
|
@ -13924,26 +14063,47 @@ void GameHandler::releaseSpirit() {
|
|||
}
|
||||
|
||||
bool GameHandler::canReclaimCorpse() const {
|
||||
if (!releasedSpirit_ || corpseMapId_ == 0) return false;
|
||||
// Only if ghost is on the same map as their corpse
|
||||
// Need: ghost state + corpse object GUID (required by CMSG_RECLAIM_CORPSE) +
|
||||
// corpse map known + same map + within 40 yards.
|
||||
if (!releasedSpirit_ || corpseGuid_ == 0 || corpseMapId_ == 0) return false;
|
||||
if (currentMapId_ != corpseMapId_) return false;
|
||||
// movementInfo.x/y are canonical (x=north=server_y, y=west=server_x).
|
||||
// corpseX_/Y_ are raw server coords (x=west, y=north).
|
||||
// Convert corpse to canonical before comparing.
|
||||
float dx = movementInfo.x - corpseY_; // canonical north - server.y
|
||||
float dy = movementInfo.y - corpseX_; // canonical west - server.x
|
||||
float dz = movementInfo.z - corpseZ_;
|
||||
return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f);
|
||||
}
|
||||
|
||||
float GameHandler::getCorpseReclaimDelaySec() const {
|
||||
if (corpseReclaimAvailableMs_ == 0) return 0.0f;
|
||||
auto nowMs = static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
if (nowMs >= corpseReclaimAvailableMs_) return 0.0f;
|
||||
return static_cast<float>(corpseReclaimAvailableMs_ - nowMs) / 1000.0f;
|
||||
}
|
||||
|
||||
void GameHandler::reclaimCorpse() {
|
||||
if (!canReclaimCorpse() || !socket) return;
|
||||
// Reclaim expects the corpse object guid when known; fallback to player guid.
|
||||
uint64_t reclaimGuid = (corpseGuid_ != 0) ? corpseGuid_ : playerGuid;
|
||||
auto packet = ReclaimCorpsePacket::build(reclaimGuid);
|
||||
// CMSG_RECLAIM_CORPSE requires the corpse object's own GUID.
|
||||
// Servers look up the corpse by this GUID; sending the player GUID silently fails.
|
||||
if (corpseGuid_ == 0) {
|
||||
LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim");
|
||||
return;
|
||||
}
|
||||
auto packet = ReclaimCorpsePacket::build(corpseGuid_);
|
||||
socket->send(packet);
|
||||
LOG_INFO("Sent CMSG_RECLAIM_CORPSE for guid=0x", std::hex, reclaimGuid, std::dec,
|
||||
(corpseGuid_ == 0 ? " (fallback player guid)" : ""));
|
||||
LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec);
|
||||
}
|
||||
|
||||
void GameHandler::useSelfRes() {
|
||||
if (!selfResAvailable_ || !socket) return;
|
||||
// CMSG_SELF_RES: empty body — server confirms resurrection via SMSG_UPDATE_OBJECT.
|
||||
network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES));
|
||||
socket->send(pkt);
|
||||
selfResAvailable_ = false;
|
||||
LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)");
|
||||
}
|
||||
|
||||
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
|
||||
|
|
@ -14336,6 +14496,25 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) {
|
|||
rebuildOnlineInventory();
|
||||
maybeDetectVisibleItemLayout();
|
||||
|
||||
// Flush any deferred loot notifications waiting on this item's name/quality.
|
||||
for (auto it = pendingItemPushNotifs_.begin(); it != pendingItemPushNotifs_.end(); ) {
|
||||
if (it->itemId == data.entry) {
|
||||
std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name;
|
||||
std::string link = buildItemLink(data.entry, data.quality, itemName);
|
||||
std::string msg = "Received: " + link;
|
||||
if (it->count > 1) msg += " x" + std::to_string(it->count);
|
||||
addSystemChatMessage(msg);
|
||||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||||
if (auto* sfx = renderer->getUiSoundManager())
|
||||
sfx->playLootItem();
|
||||
}
|
||||
if (itemLootCallback_) itemLootCallback_(data.entry, it->count, data.quality, itemName);
|
||||
it = pendingItemPushNotifs_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// Selectively re-emit only players whose equipment references this item entry
|
||||
const uint32_t resolvedEntry = data.entry;
|
||||
for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) {
|
||||
|
|
@ -17891,7 +18070,17 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (casting) return; // Already casting
|
||||
if (casting) {
|
||||
// Spell queue: if we're within 400ms of the cast completing (and not channeling),
|
||||
// store the spell so it fires automatically when the cast finishes.
|
||||
if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) {
|
||||
queuedSpellId_ = spellId;
|
||||
queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid;
|
||||
LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f,
|
||||
"ms remaining)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hearthstone: cast spell directly (server checks item in inventory)
|
||||
// Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which
|
||||
|
|
@ -17993,9 +18182,11 @@ void GameHandler::cancelCast() {
|
|||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
// Cancel craft queue when player manually cancels cast
|
||||
// Cancel craft queue and spell queue when player manually cancels cast
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
}
|
||||
|
||||
void GameHandler::startCraftQueue(uint32_t spellId, int count) {
|
||||
|
|
@ -18104,6 +18295,24 @@ void GameHandler::dismissPet() {
|
|||
socket->send(packet);
|
||||
}
|
||||
|
||||
void GameHandler::togglePetSpellAutocast(uint32_t spellId) {
|
||||
if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return;
|
||||
bool currentlyOn = petAutocastSpells_.count(spellId) != 0;
|
||||
uint8_t newState = currentlyOn ? 0 : 1;
|
||||
// CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1)
|
||||
network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST));
|
||||
pkt.writeUInt64(petGuid_);
|
||||
pkt.writeUInt32(spellId);
|
||||
pkt.writeUInt8(newState);
|
||||
socket->send(pkt);
|
||||
// Optimistically update local state; server will confirm via SMSG_PET_SPELLS
|
||||
if (newState)
|
||||
petAutocastSpells_.insert(spellId);
|
||||
else
|
||||
petAutocastSpells_.erase(spellId);
|
||||
LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState);
|
||||
}
|
||||
|
||||
void GameHandler::renamePet(const std::string& newName) {
|
||||
if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return;
|
||||
if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars
|
||||
|
|
@ -18269,6 +18478,10 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
|
|||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
lastInteractedGoGuid_ = 0;
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
|
||||
// Stop precast sound — spell failed before completing
|
||||
if (auto* renderer = core::Application::getInstance().getRenderer()) {
|
||||
|
|
@ -18441,6 +18654,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
|
|||
if (spellCastAnimCallback_) {
|
||||
spellCastAnimCallback_(playerGuid, false, false);
|
||||
}
|
||||
|
||||
// Spell queue: fire the next queued spell now that casting has ended
|
||||
if (queuedSpellId_ != 0) {
|
||||
uint32_t nextSpell = queuedSpellId_;
|
||||
uint64_t nextTarget = queuedSpellTarget_;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
LOG_INFO("Spell queue: firing queued spellId=", nextSpell);
|
||||
castSpell(nextSpell, nextTarget);
|
||||
}
|
||||
} else {
|
||||
if (spellCastAnimCallback_) {
|
||||
// End cast animation on other unit
|
||||
|
|
@ -19664,14 +19887,12 @@ void GameHandler::interactWithGameObject(uint64_t guid) {
|
|||
void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
|
||||
if (guid == 0) return;
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
bool turtleMode = isActiveExpansion("turtle");
|
||||
|
||||
// Rate-limit to prevent spamming the server
|
||||
static uint64_t lastInteractGuid = 0;
|
||||
static std::chrono::steady_clock::time_point lastInteractTime{};
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
// Keep duplicate suppression, but allow quick retry clicks.
|
||||
int64_t minRepeatMs = turtleMode ? 150 : 150;
|
||||
constexpr int64_t minRepeatMs = 150;
|
||||
if (guid == lastInteractGuid &&
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastInteractTime).count() < minRepeatMs) {
|
||||
return;
|
||||
|
|
@ -20944,6 +21165,26 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
|
|||
}
|
||||
}
|
||||
|
||||
void GameHandler::openItemBySlot(int backpackIndex) {
|
||||
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
||||
if (inventory.getBackpackSlot(backpackIndex).empty()) return;
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
auto packet = OpenItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex));
|
||||
LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex));
|
||||
socket->send(packet);
|
||||
}
|
||||
|
||||
void GameHandler::openItemInBag(int bagIndex, int slotIndex) {
|
||||
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
||||
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
||||
if (inventory.getBagSlot(bagIndex, slotIndex).empty()) return;
|
||||
if (state != WorldState::IN_WORLD || !socket) return;
|
||||
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
|
||||
auto packet = OpenItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex));
|
||||
LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex);
|
||||
socket->send(packet);
|
||||
}
|
||||
|
||||
void GameHandler::useItemById(uint32_t itemId) {
|
||||
if (itemId == 0) return;
|
||||
LOG_DEBUG("useItemById: searching for itemId=", itemId);
|
||||
|
|
@ -21963,6 +22204,14 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
|
|||
hostileAttackers_.clear();
|
||||
stopAutoAttack();
|
||||
tabCycleStale = true;
|
||||
casting = false;
|
||||
castIsChannel = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
|
||||
if (socket) {
|
||||
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
|
||||
|
|
@ -22021,6 +22270,10 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
|
|||
pendingGameObjectInteractGuid_ = 0;
|
||||
lastInteractedGoGuid_ = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
craftQueueSpellId_ = 0;
|
||||
craftQueueRemaining_ = 0;
|
||||
queuedSpellId_ = 0;
|
||||
queuedSpellTarget_ = 0;
|
||||
|
||||
// Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready
|
||||
if (socket) {
|
||||
|
|
@ -23345,6 +23598,21 @@ std::string GameHandler::getCharacterConfigDir() {
|
|||
return dir;
|
||||
}
|
||||
|
||||
static const std::string EMPTY_MACRO_TEXT;
|
||||
|
||||
const std::string& GameHandler::getMacroText(uint32_t macroId) const {
|
||||
auto it = macros_.find(macroId);
|
||||
return (it != macros_.end()) ? it->second : EMPTY_MACRO_TEXT;
|
||||
}
|
||||
|
||||
void GameHandler::setMacroText(uint32_t macroId, const std::string& text) {
|
||||
if (text.empty())
|
||||
macros_.erase(macroId);
|
||||
else
|
||||
macros_[macroId] = text;
|
||||
saveCharacterConfig();
|
||||
}
|
||||
|
||||
void GameHandler::saveCharacterConfig() {
|
||||
const Character* ch = getActiveCharacter();
|
||||
if (!ch || ch->name.empty()) return;
|
||||
|
|
@ -23371,6 +23639,21 @@ void GameHandler::saveCharacterConfig() {
|
|||
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
|
||||
}
|
||||
|
||||
// Save client-side macro text (escape newlines as \n literal)
|
||||
for (const auto& [id, text] : macros_) {
|
||||
if (!text.empty()) {
|
||||
std::string escaped;
|
||||
escaped.reserve(text.size());
|
||||
for (char c : text) {
|
||||
if (c == '\n') { escaped += "\\n"; }
|
||||
else if (c == '\r') { /* skip CR */ }
|
||||
else if (c == '\\') { escaped += "\\\\"; }
|
||||
else { escaped += c; }
|
||||
}
|
||||
out << "macro_" << id << "_text=" << escaped << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Save quest log
|
||||
out << "quest_log_count=" << questLog_.size() << "\n";
|
||||
for (size_t i = 0; i < questLog_.size(); i++) {
|
||||
|
|
@ -23411,6 +23694,28 @@ void GameHandler::loadCharacterConfig() {
|
|||
try { savedGender = std::stoi(val); } catch (...) {}
|
||||
} else if (key == "use_female_model") {
|
||||
try { savedUseFemaleModel = std::stoi(val); } catch (...) {}
|
||||
} else if (key.rfind("macro_", 0) == 0) {
|
||||
// Parse macro_N_text
|
||||
size_t firstUnder = 6; // length of "macro_"
|
||||
size_t secondUnder = key.find('_', firstUnder);
|
||||
if (secondUnder == std::string::npos) continue;
|
||||
uint32_t macroId = 0;
|
||||
try { macroId = static_cast<uint32_t>(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; }
|
||||
if (key.substr(secondUnder + 1) == "text" && !val.empty()) {
|
||||
// Unescape \n and \\ sequences
|
||||
std::string unescaped;
|
||||
unescaped.reserve(val.size());
|
||||
for (size_t i = 0; i < val.size(); ++i) {
|
||||
if (val[i] == '\\' && i + 1 < val.size()) {
|
||||
if (val[i+1] == 'n') { unescaped += '\n'; ++i; }
|
||||
else if (val[i+1] == '\\') { unescaped += '\\'; ++i; }
|
||||
else { unescaped += val[i]; }
|
||||
} else {
|
||||
unescaped += val[i];
|
||||
}
|
||||
}
|
||||
macros_[macroId] = std::move(unescaped);
|
||||
}
|
||||
} else if (key.rfind("action_bar_", 0) == 0) {
|
||||
// Parse action_bar_N_type or action_bar_N_id
|
||||
size_t firstUnderscore = 11; // length of "action_bar_"
|
||||
|
|
@ -24487,28 +24792,33 @@ void GameHandler::resetTradeState() {
|
|||
}
|
||||
|
||||
void GameHandler::handleTradeStatusExtended(network::Packet& packet) {
|
||||
// WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format:
|
||||
// uint8 isSelfState (1 = my trade window, 0 = peer's)
|
||||
// uint32 tradeId
|
||||
// uint32 slotCount (7: 6 normal + 1 extra for enchanting)
|
||||
// Per slot (up to slotCount):
|
||||
// uint8 slotIndex
|
||||
// uint32 itemId
|
||||
// uint32 displayId
|
||||
// uint32 stackCount
|
||||
// uint8 isWrapped
|
||||
// uint64 giftCreatorGuid
|
||||
// uint32 enchantId (and several more enchant/stat fields)
|
||||
// ... (complex; we parse only the essential fields)
|
||||
// uint64 coins (gold offered by the sender of this message)
|
||||
|
||||
size_t rem = packet.getSize() - packet.getReadPos();
|
||||
if (rem < 9) return;
|
||||
// SMSG_TRADE_STATUS_EXTENDED format differs by expansion:
|
||||
//
|
||||
// Classic/TBC:
|
||||
// uint8 isSelf + uint32 slotCount + [slots] + uint64 coins
|
||||
// Per slot tail (after isWrapped): giftCreatorGuid(8) + enchants(24) +
|
||||
// randomPropertyId(4) + suffixFactor(4) + durability(4) + maxDurability(4) = 48 bytes
|
||||
//
|
||||
// WotLK 3.3.5a adds:
|
||||
// uint32 tradeId (after isSelf, before slotCount)
|
||||
// Per slot: + createPlayedTime(4) at end of trail → trail = 52 bytes
|
||||
//
|
||||
// Minimum: isSelf(1) + [tradeId(4)] + slotCount(4) = 5 or 9 bytes
|
||||
const bool isWotLK = isActiveExpansion("wotlk");
|
||||
size_t minHdr = isWotLK ? 9u : 5u;
|
||||
if (packet.getSize() - packet.getReadPos() < minHdr) return;
|
||||
|
||||
uint8_t isSelf = packet.readUInt8();
|
||||
uint32_t tradeId = packet.readUInt32(); (void)tradeId;
|
||||
if (isWotLK) {
|
||||
/*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field
|
||||
}
|
||||
uint32_t slotCount = packet.readUInt32();
|
||||
|
||||
// Per-slot tail bytes after isWrapped:
|
||||
// Classic/TBC: giftCreatorGuid(8) + enchants(24) + stats(16) = 48
|
||||
// WotLK: same + createPlayedTime(4) = 52
|
||||
const size_t SLOT_TRAIL = isWotLK ? 52u : 48u;
|
||||
|
||||
auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_;
|
||||
|
||||
for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) {
|
||||
|
|
@ -24521,12 +24831,6 @@ void GameHandler::handleTradeStatusExtended(network::Packet& packet) {
|
|||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||||
isWrapped = (packet.readUInt8() != 0);
|
||||
}
|
||||
// AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped:
|
||||
// giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12)
|
||||
// + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24]
|
||||
// + randomPropertyId (4) + suffixFactor (4)
|
||||
// + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes
|
||||
constexpr size_t SLOT_TRAIL = 52;
|
||||
if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) {
|
||||
packet.setReadPos(packet.getReadPos() + SLOT_TRAIL);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "game/inventory.hpp"
|
||||
#include "core/logger.hpp"
|
||||
#include <algorithm>
|
||||
|
||||
namespace wowee {
|
||||
namespace game {
|
||||
|
|
@ -185,6 +186,44 @@ bool Inventory::addItem(const ItemDef& item) {
|
|||
return true;
|
||||
}
|
||||
|
||||
void Inventory::sortBags() {
|
||||
// Collect all items from backpack and equip bags into a flat list.
|
||||
std::vector<ItemDef> items;
|
||||
items.reserve(BACKPACK_SLOTS + NUM_BAG_SLOTS * MAX_BAG_SIZE);
|
||||
|
||||
for (int i = 0; i < BACKPACK_SLOTS; ++i) {
|
||||
if (!backpack[i].empty())
|
||||
items.push_back(backpack[i].item);
|
||||
}
|
||||
for (int b = 0; b < NUM_BAG_SLOTS; ++b) {
|
||||
for (int s = 0; s < bags[b].size; ++s) {
|
||||
if (!bags[b].slots[s].empty())
|
||||
items.push_back(bags[b].slots[s].item);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: quality descending → itemId ascending → stackCount descending.
|
||||
std::stable_sort(items.begin(), items.end(), [](const ItemDef& a, const ItemDef& b) {
|
||||
if (a.quality != b.quality)
|
||||
return static_cast<int>(a.quality) > static_cast<int>(b.quality);
|
||||
if (a.itemId != b.itemId)
|
||||
return a.itemId < b.itemId;
|
||||
return a.stackCount > b.stackCount;
|
||||
});
|
||||
|
||||
// Write sorted items back, filling backpack first then equip bags.
|
||||
int idx = 0;
|
||||
int n = static_cast<int>(items.size());
|
||||
|
||||
for (int i = 0; i < BACKPACK_SLOTS; ++i)
|
||||
backpack[i].item = (idx < n) ? items[idx++] : ItemDef{};
|
||||
|
||||
for (int b = 0; b < NUM_BAG_SLOTS; ++b) {
|
||||
for (int s = 0; s < bags[b].size; ++s)
|
||||
bags[b].slots[s].item = (idx < n) ? items[idx++] : ItemDef{};
|
||||
}
|
||||
}
|
||||
|
||||
void Inventory::populateTestItems() {
|
||||
// Equipment
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1403,15 +1403,14 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
|
|||
SpellGoMissEntry m;
|
||||
m.targetGuid = packet.readUInt64(); // full GUID in TBC
|
||||
m.missType = packet.readUInt8();
|
||||
if (m.missType == 11) {
|
||||
if (packet.getReadPos() + 5 > packet.getSize()) {
|
||||
if (m.missType == 11) { // SPELL_MISS_REFLECT
|
||||
if (packet.getReadPos() + 1 > packet.getSize()) {
|
||||
LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i,
|
||||
"/", (int)rawMissCount);
|
||||
truncatedTargets = true;
|
||||
break;
|
||||
}
|
||||
(void)packet.readUInt32();
|
||||
(void)packet.readUInt8();
|
||||
(void)packet.readUInt8(); // reflectResult
|
||||
}
|
||||
if (i < storedMissLimit) {
|
||||
data.missTargets.push_back(m);
|
||||
|
|
|
|||
|
|
@ -3891,46 +3891,54 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
|
|||
|
||||
const uint8_t rawMissCount = packet.readUInt8();
|
||||
if (rawMissCount > 128) {
|
||||
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ")");
|
||||
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount,
|
||||
") spell=", data.spellId, " hits=", (int)data.hitCount,
|
||||
" remaining=", packet.getSize() - packet.getReadPos());
|
||||
}
|
||||
const uint8_t storedMissLimit = std::min<uint8_t>(rawMissCount, 128);
|
||||
|
||||
data.missTargets.reserve(storedMissLimit);
|
||||
for (uint16_t i = 0; i < rawMissCount; ++i) {
|
||||
// Each miss entry: packed GUID(1-8 bytes) + missType(1 byte).
|
||||
// REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult.
|
||||
// REFLECT additionally appends uint8 reflectResult.
|
||||
if (!hasFullPackedGuid(packet)) {
|
||||
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount);
|
||||
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount,
|
||||
" spell=", data.spellId, " hits=", (int)data.hitCount);
|
||||
truncatedTargets = true;
|
||||
break;
|
||||
}
|
||||
SpellGoMissEntry m;
|
||||
m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK
|
||||
if (packet.getSize() - packet.getReadPos() < 1) {
|
||||
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount);
|
||||
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount,
|
||||
" spell=", data.spellId);
|
||||
truncatedTargets = true;
|
||||
break;
|
||||
}
|
||||
m.missType = packet.readUInt8();
|
||||
if (m.missType == 11) {
|
||||
if (packet.getSize() - packet.getReadPos() < 5) {
|
||||
if (m.missType == 11) { // SPELL_MISS_REFLECT
|
||||
if (packet.getSize() - packet.getReadPos() < 1) {
|
||||
LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount);
|
||||
truncatedTargets = true;
|
||||
break;
|
||||
}
|
||||
(void)packet.readUInt32();
|
||||
(void)packet.readUInt8();
|
||||
(void)packet.readUInt8(); // reflectResult
|
||||
}
|
||||
if (i < storedMissLimit) {
|
||||
data.missTargets.push_back(m);
|
||||
}
|
||||
}
|
||||
if (truncatedTargets) {
|
||||
packet.setReadPos(startPos);
|
||||
return false;
|
||||
}
|
||||
data.missCount = static_cast<uint8_t>(data.missTargets.size());
|
||||
|
||||
// If miss targets were truncated, salvage the successfully-parsed hit data
|
||||
// rather than discarding the entire spell. The server already applied effects;
|
||||
// we just need the hit list for UI feedback (combat text, health bars).
|
||||
if (truncatedTargets) {
|
||||
LOG_DEBUG("Spell go: salvaging ", (int)data.hitCount, " hits despite miss truncation");
|
||||
packet.setReadPos(packet.getSize()); // consume remaining bytes
|
||||
return true;
|
||||
}
|
||||
|
||||
// WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that
|
||||
// any trailing fields after the target section are not misaligned for
|
||||
// ground-targeted or AoE spells. Same layout as SpellStartParser.
|
||||
|
|
@ -4271,6 +4279,13 @@ network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64
|
|||
return packet;
|
||||
}
|
||||
|
||||
network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) {
|
||||
network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM));
|
||||
packet.writeUInt8(bagIndex);
|
||||
packet.writeUInt8(slotIndex);
|
||||
return packet;
|
||||
}
|
||||
|
||||
network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) {
|
||||
network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM));
|
||||
packet.writeUInt8(srcBag);
|
||||
|
|
|
|||
|
|
@ -4008,15 +4008,68 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran
|
|||
}
|
||||
|
||||
void M2Renderer::removeInstance(uint32_t instanceId) {
|
||||
for (auto it = instances.begin(); it != instances.end(); ++it) {
|
||||
if (it->id == instanceId) {
|
||||
destroyInstanceBones(*it);
|
||||
instances.erase(it);
|
||||
rebuildSpatialIndex();
|
||||
return;
|
||||
auto idxIt = instanceIndexById.find(instanceId);
|
||||
if (idxIt == instanceIndexById.end()) return;
|
||||
size_t idx = idxIt->second;
|
||||
if (idx >= instances.size()) return;
|
||||
|
||||
auto& inst = instances[idx];
|
||||
|
||||
// Remove from spatial grid incrementally (same pattern as the move-update path)
|
||||
GridCell minCell = toCell(inst.worldBoundsMin);
|
||||
GridCell maxCell = toCell(inst.worldBoundsMax);
|
||||
for (int z = minCell.z; z <= maxCell.z; z++) {
|
||||
for (int y = minCell.y; y <= maxCell.y; y++) {
|
||||
for (int x = minCell.x; x <= maxCell.x; x++) {
|
||||
auto gIt = spatialGrid.find(GridCell{x, y, z});
|
||||
if (gIt != spatialGrid.end()) {
|
||||
auto& vec = gIt->second;
|
||||
vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from dedup map
|
||||
if (!inst.cachedIsGroundDetail) {
|
||||
DedupKey dk{inst.modelId,
|
||||
static_cast<int32_t>(std::round(inst.position.x * 10.0f)),
|
||||
static_cast<int32_t>(std::round(inst.position.y * 10.0f)),
|
||||
static_cast<int32_t>(std::round(inst.position.z * 10.0f))};
|
||||
instanceDedupMap_.erase(dk);
|
||||
}
|
||||
|
||||
destroyInstanceBones(inst);
|
||||
|
||||
// Swap-remove: move last element to the hole and pop_back to avoid O(n) shift
|
||||
instanceIndexById.erase(instanceId);
|
||||
if (idx < instances.size() - 1) {
|
||||
uint32_t movedId = instances.back().id;
|
||||
instances[idx] = std::move(instances.back());
|
||||
instances.pop_back();
|
||||
instanceIndexById[movedId] = idx;
|
||||
} else {
|
||||
instances.pop_back();
|
||||
}
|
||||
|
||||
// Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.)
|
||||
// These are small vectors of indices that are rebuilt cheaply.
|
||||
smokeInstanceIndices_.clear();
|
||||
portalInstanceIndices_.clear();
|
||||
animatedInstanceIndices_.clear();
|
||||
particleOnlyInstanceIndices_.clear();
|
||||
particleInstanceIndices_.clear();
|
||||
for (size_t i = 0; i < instances.size(); i++) {
|
||||
auto& ri = instances[i];
|
||||
if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i);
|
||||
if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i);
|
||||
if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i);
|
||||
if (ri.cachedHasAnimation && !ri.cachedDisableAnimation)
|
||||
animatedInstanceIndices_.push_back(i);
|
||||
else if (ri.cachedHasParticleEmitters)
|
||||
particleOnlyInstanceIndices_.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) {
|
||||
for (auto& inst : instances) {
|
||||
|
|
|
|||
|
|
@ -1142,10 +1142,14 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
|
|||
};
|
||||
|
||||
// Color source: final render pass layout is PRESENT_SRC.
|
||||
// srcAccessMask must be COLOR_ATTACHMENT_WRITE (not 0) so that GPU cache flushes
|
||||
// happen before the transfer read. Using srcAccessMask=0 with BOTTOM_OF_PIPE
|
||||
// causes VK_ERROR_DEVICE_LOST on strict drivers (AMD/Mali) because color writes
|
||||
// are not made visible to the transfer unit before the copy begins.
|
||||
barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
0, VK_ACCESS_TRANSFER_READ_BIT,
|
||||
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
||||
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT,
|
||||
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
||||
barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT,
|
||||
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1019,7 +1019,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
|
|||
float contentH = rows * (slotSize + 4.0f) + 10.0f;
|
||||
if (bagIndex < 0) {
|
||||
int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns;
|
||||
contentH += 25.0f; // money display for backpack
|
||||
contentH += 36.0f; // separator + sort button + money display
|
||||
contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots
|
||||
}
|
||||
float gridW = columns * (slotSize + 4.0f) + 30.0f;
|
||||
|
|
@ -1094,9 +1094,21 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
|
|||
}
|
||||
}
|
||||
|
||||
// Money display at bottom of backpack
|
||||
if (bagIndex < 0 && moneyCopper > 0) {
|
||||
// Footer for backpack: sort button + money display
|
||||
if (bagIndex < 0) {
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
|
||||
// Sort Bags button — client-side reorder by quality/type
|
||||
if (ImGui::SmallButton("Sort Bags")) {
|
||||
inventory.sortBags();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Sort all bag slots by quality (highest first),\nthen by item ID, then by stack size.");
|
||||
}
|
||||
|
||||
if (moneyCopper > 0) {
|
||||
ImGui::SameLine();
|
||||
uint64_t gold = moneyCopper / 10000;
|
||||
uint64_t silver = (moneyCopper / 100) % 100;
|
||||
uint64_t copper = moneyCopper % 100;
|
||||
|
|
@ -1105,6 +1117,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
|
|||
static_cast<unsigned long long>(silver),
|
||||
static_cast<unsigned long long>(copper));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
|
@ -2342,9 +2355,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
gameHandler_->offerQuestFromItem(iGuid, item.startQuestId);
|
||||
} else if (item.inventoryType > 0) {
|
||||
gameHandler_->autoEquipItemBySlot(backpackIndex);
|
||||
} else {
|
||||
// itemClass==1 (Container) with inventoryType==0 means a lockbox;
|
||||
// use CMSG_OPEN_ITEM so the server checks keyring automatically.
|
||||
auto* info = gameHandler_->getItemInfo(item.itemId);
|
||||
if (info && info->valid && info->itemClass == 1) {
|
||||
gameHandler_->openItemBySlot(backpackIndex);
|
||||
} else {
|
||||
gameHandler_->useItemBySlot(backpackIndex);
|
||||
}
|
||||
}
|
||||
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
|
||||
LOG_INFO("Right-click bag item: name='", item.name,
|
||||
"' inventoryType=", (int)item.inventoryType,
|
||||
|
|
@ -2355,11 +2375,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
gameHandler_->offerQuestFromItem(iGuid, item.startQuestId);
|
||||
} else if (item.inventoryType > 0) {
|
||||
gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex);
|
||||
} else {
|
||||
auto* info = gameHandler_->getItemInfo(item.itemId);
|
||||
if (info && info->valid && info->itemClass == 1) {
|
||||
gameHandler_->openItemInBag(bagIndex, bagSlotIndex);
|
||||
} else {
|
||||
gameHandler_->useItemInBag(bagIndex, bagSlotIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shift+left-click: insert item link into chat input
|
||||
if (ImGui::IsItemHovered() && !holdingItem &&
|
||||
|
|
@ -2388,12 +2413,36 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
|
|||
if (ImGui::IsItemHovered() && !holdingItem) {
|
||||
// Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise
|
||||
const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory;
|
||||
renderItemTooltip(item, tooltipInv);
|
||||
uint64_t slotGuid = 0;
|
||||
if (kind == SlotKind::EQUIPMENT && gameHandler_)
|
||||
slotGuid = gameHandler_->getEquipSlotGuid(static_cast<int>(equipSlot));
|
||||
renderItemTooltip(item, tooltipInv, slotGuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory, uint64_t itemGuid) {
|
||||
// Shared SpellItemEnchantment name lookup — used for socket gems, permanent and temp enchants.
|
||||
static std::unordered_map<uint32_t, std::string> s_enchLookupB;
|
||||
static bool s_enchLookupLoadedB = false;
|
||||
if (!s_enchLookupLoadedB && assetManager_) {
|
||||
s_enchLookupLoadedB = true;
|
||||
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
|
||||
if (dbc && dbc->isLoaded()) {
|
||||
const auto* lay = pipeline::getActiveDBCLayout()
|
||||
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
|
||||
uint32_t nf = lay ? lay->field("Name") : 8u;
|
||||
if (nf == 0xFFFFFFFF) nf = 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 || nf >= fc) continue;
|
||||
std::string en = dbc->getString(r, nf);
|
||||
if (!en.empty()) s_enchLookupB[eid] = std::move(en);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory) {
|
||||
ImGui::BeginTooltip();
|
||||
|
||||
ImVec4 qColor = getQualityColor(item.quality);
|
||||
|
|
@ -2778,39 +2827,33 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
|
|||
{ 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } },
|
||||
{ 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } },
|
||||
};
|
||||
// Get socket gem enchant IDs for this item (filled from item update fields)
|
||||
std::array<uint32_t, 3> sockGems{};
|
||||
if (itemGuid != 0 && gameHandler_)
|
||||
sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid);
|
||||
|
||||
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) {
|
||||
if (sockGems[i] != 0) {
|
||||
auto git = s_enchLookupB.find(sockGems[i]);
|
||||
if (git != s_enchLookupB.end())
|
||||
ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str());
|
||||
else
|
||||
ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]);
|
||||
} else {
|
||||
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())
|
||||
auto enchIt = s_enchLookupB.find(qi2->socketBonus);
|
||||
if (enchIt != s_enchLookupB.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);
|
||||
|
|
@ -2902,6 +2945,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
|
|||
}
|
||||
}
|
||||
|
||||
// Weapon/armor enchant display for equipped items (reads from item update fields)
|
||||
if (itemGuid != 0 && gameHandler_) {
|
||||
auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid);
|
||||
if (permId != 0) {
|
||||
auto it2 = s_enchLookupB.find(permId);
|
||||
const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr;
|
||||
if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename);
|
||||
}
|
||||
if (tempId != 0) {
|
||||
auto it2 = s_enchLookupB.find(tempId);
|
||||
const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr;
|
||||
if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename);
|
||||
}
|
||||
}
|
||||
|
||||
// "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");
|
||||
|
|
@ -3054,7 +3112,28 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
|
|||
// ---------------------------------------------------------------------------
|
||||
// Tooltip overload for ItemQueryResponseData (used by loot window, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) {
|
||||
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory, uint64_t itemGuid) {
|
||||
// Shared SpellItemEnchantment name lookup — used for socket gems, socket bonus, and enchants.
|
||||
static std::unordered_map<uint32_t, std::string> s_enchLookup;
|
||||
static bool s_enchLookupLoaded = false;
|
||||
if (!s_enchLookupLoaded && assetManager_) {
|
||||
s_enchLookupLoaded = true;
|
||||
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
|
||||
if (dbc && dbc->isLoaded()) {
|
||||
const auto* lay = pipeline::getActiveDBCLayout()
|
||||
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
|
||||
uint32_t nf = lay ? lay->field("Name") : 8u;
|
||||
if (nf == 0xFFFFFFFF) nf = 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 || nf >= fc) continue;
|
||||
std::string en = dbc->getString(r, nf);
|
||||
if (!en.empty()) s_enchLookup[eid] = std::move(en);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::BeginTooltip();
|
||||
|
||||
ImVec4 qColor = getQualityColor(static_cast<game::ItemQuality>(info.quality));
|
||||
|
|
@ -3389,46 +3468,54 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
|
|||
{ 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } },
|
||||
{ 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } },
|
||||
};
|
||||
// Get socket gem enchant IDs for this item (filled from item update fields)
|
||||
std::array<uint32_t, 3> sockGems{};
|
||||
if (itemGuid != 0 && gameHandler_)
|
||||
sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid);
|
||||
|
||||
bool hasSocket = false;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (info.socketColor[i] == 0) continue;
|
||||
if (!hasSocket) { ImGui::Spacing(); hasSocket = true; }
|
||||
for (const auto& st : kSocketTypes) {
|
||||
if (info.socketColor[i] & st.mask) {
|
||||
if (sockGems[i] != 0) {
|
||||
auto git = s_enchLookup.find(sockGems[i]);
|
||||
if (git != s_enchLookup.end())
|
||||
ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str());
|
||||
else
|
||||
ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]);
|
||||
} else {
|
||||
ImGui::TextColored(st.col, "%s", st.label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasSocket && info.socketBonus != 0) {
|
||||
// Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc
|
||||
static std::unordered_map<uint32_t, std::string> s_enchantNames;
|
||||
static bool s_enchantNamesLoaded = false;
|
||||
if (!s_enchantNamesLoaded && assetManager_) {
|
||||
s_enchantNamesLoaded = 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_enchantNames[eid] = std::move(ename);
|
||||
}
|
||||
}
|
||||
}
|
||||
auto enchIt = s_enchantNames.find(info.socketBonus);
|
||||
if (enchIt != s_enchantNames.end())
|
||||
auto enchIt = s_enchLookup.find(info.socketBonus);
|
||||
if (enchIt != s_enchLookup.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)", info.socketBonus);
|
||||
}
|
||||
}
|
||||
|
||||
// Weapon/armor enchant display for equipped items
|
||||
if (itemGuid != 0 && gameHandler_) {
|
||||
auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid);
|
||||
if (permId != 0) {
|
||||
auto it2 = s_enchLookup.find(permId);
|
||||
const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr;
|
||||
if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename);
|
||||
}
|
||||
if (tempId != 0) {
|
||||
auto it2 = s_enchLookup.find(tempId);
|
||||
const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr;
|
||||
if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename);
|
||||
}
|
||||
}
|
||||
|
||||
// Item set membership
|
||||
if (info.itemSetId != 0) {
|
||||
// Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue