Fix spell targeting, item query parsing, loot UI, hair/skin textures, and attack animations

Fix spell cast target fallback using selected target instead of no-op tautology.
Fix SMSG_ITEM_QUERY_SINGLE_RESPONSE to always read 10 stat pairs (server sends
all 10 regardless of statsCount), fixing misaligned armor/stat reads. Fix XP gain
parser to read float groupRate + uint8 RAF instead of bogus uint32 groupBonus.
Add item icons and quality-colored names to loot window. Use actual character
appearance bytes for CharSections.dbc skin/face/hair lookups instead of hardcoded
defaults. Add weapon-type-aware attack animation selection (2H prioritizes anim 18).
Add readable spell cast failure messages and vendor sell hint.
This commit is contained in:
Kelsi 2026-02-06 15:41:29 -08:00
parent f0aad5e97f
commit 2ddef93f52
9 changed files with 338 additions and 54 deletions

View file

@ -65,5 +65,173 @@ struct SpellCooldownEntry {
uint32_t categoryCooldownMs;
};
/**
* Get human-readable spell cast failure reason (WoW 3.3.5a SpellCastResult)
*/
inline const char* getSpellCastResultString(uint8_t result) {
switch (result) {
case 0: return "Spell failed";
case 1: return "Affects dead target";
case 2: return "Already at full health";
case 3: return "Already at full mana";
case 4: return "Already at full power";
case 5: return "Already being tamed";
case 6: return "Already have charm";
case 7: return "Already have summon";
case 8: return "Already open";
case 9: return "Aura bounced";
case 10: return "Autopilot in use";
case 11: return "Bad implicit targets";
case 12: return "Bad targets";
case 13: return "Can't be charmed";
case 14: return "Can't be disenchanted";
case 15: return "Can't be disenchanted (skill)";
case 16: return "Can't be milled";
case 17: return "Can't be prospected";
case 18: return "Can't cast on tapped";
case 19: return "Can't duel while invisible";
case 20: return "Can't duel while stealthed";
case 21: return "Can't stealth";
case 22: return "Caster aurastate";
case 23: return "Caster dead";
case 24: return "Charmed";
case 25: return "Chest in use";
case 26: return "Confused";
case 27: return "Don't report";
case 28: return "Equipped item";
case 29: return "Equipped item (class)";
case 30: return "Equipped item (class2)";
case 31: return "Equipped item (level)";
case 32: return "Error";
case 33: return "Fizzle";
case 34: return "Fleeing";
case 35: return "Food too low level";
case 36: return "Highlighted rune needed";
case 37: return "Immune";
case 38: return "Interrupted";
case 39: return "Interrupted (combat)";
case 40: return "Invalid item";
case 41: return "Item already enchanted";
case 42: return "Item gone";
case 43: return "Item not found";
case 44: return "Item not ready";
case 45: return "Level requirement";
case 46: return "Line of sight";
case 47: return "Lowlevel";
case 48: return "Low castlevel";
case 49: return "Mainhand empty";
case 50: return "Moving";
case 51: return "Must be behind target";
case 52: return "Need ammo";
case 53: return "Need ammo pouch";
case 54: return "Need exotic ammo";
case 55: return "Need more items";
case 56: return "No path";
case 57: return "Not behind";
case 58: return "Not fishable";
case 59: return "Not flying";
case 60: return "Not here";
case 61: return "Not infront";
case 62: return "Not in control";
case 63: return "Not known";
case 64: return "Not mounted";
case 65: return "Not on taxi";
case 66: return "Not on transport";
case 67: return "Not ready";
case 68: return "Not shapeshift";
case 69: return "Not standing";
case 70: return "Not tradeable";
case 71: return "Not trading";
case 72: return "Not unsheathed";
case 73: return "Not while ghost";
case 74: return "Not while looting";
case 75: return "No charges remain";
case 76: return "No champion";
case 77: return "No combo points";
case 78: return "No dueling";
case 79: return "No endurance";
case 80: return "No fish";
case 81: return "No items while shapeshifted";
case 82: return "No mounts allowed here";
case 83: return "No pet";
case 84: return "No power";
case 85: return "Nothing to dispel";
case 86: return "Nothing to steal";
case 87: return "Only above water";
case 88: return "Only daytime";
case 89: return "Only indoors";
case 90: return "Only mounted";
case 91: return "Only nighttime";
case 92: return "Only outdoors";
case 93: return "Only shapeshift";
case 94: return "Only stealthed";
case 95: return "Only underwater";
case 96: return "Out of range";
case 97: return "Pacified";
case 98: return "Possessed";
case 99: return "Reagents";
case 100: return "Requires area";
case 101: return "Requires spell focus";
case 102: return "Rooted";
case 103: return "Silenced";
case 104: return "Spell in progress";
case 105: return "Spell learned";
case 106: return "Spell unavailable";
case 107: return "Stunned";
case 108: return "Targets dead";
case 109: return "Target not dead";
case 110: return "Target not in party";
case 111: return "Target not in raid";
case 112: return "Target friendly";
case 113: return "Target is player";
case 114: return "Target is player controlled";
case 115: return "Target not dead";
case 116: return "Target not in party";
case 117: return "Target not player";
case 118: return "Target no pockets";
case 119: return "Target no weapons";
case 120: return "Target out of range";
case 121: return "Target unskinnable";
case 122: return "Thirst satiated";
case 123: return "Too close";
case 124: return "Too many of item";
case 125: return "Totem category";
case 126: return "Totems";
case 127: return "Training points";
case 128: return "Try again";
case 129: return "Unit not behind";
case 130: return "Unit not infront";
case 131: return "Wrong pet food";
case 132: return "Not while fatigued";
case 133: return "Target not in instance";
case 134: return "Not while trading";
case 135: return "Target not in raid";
case 136: return "Target feign dead";
case 137: return "Disabled by power scaling";
case 138: return "Quest players only";
case 139: return "Not idle";
case 140: return "Not inactive";
case 141: return "Partial playtime";
case 142: return "No playtime";
case 143: return "Not in battleground";
case 144: return "Not in raid instance";
case 145: return "Only in arena";
case 146: return "Target locked to raid instance";
case 147: return "On use enchant";
case 148: return "Not on ground";
case 149: return "Custom error";
case 150: return "Can't open lock";
case 151: return "Wrong artifact equipped";
case 173: return "Not enough mana";
case 174: return "Not enough health";
case 175: return "Not enough holy power";
case 176: return "Not enough rage";
case 177: return "Not enough energy";
case 178: return "Not enough runes";
case 179: return "Not enough runic power";
default: return nullptr;
}
}
} // namespace game
} // namespace wowee

View file

@ -122,6 +122,7 @@ public:
void setTargetPosition(const glm::vec3* pos);
bool isMoving() const;
void triggerMeleeSwing();
void setEquippedWeaponType(uint32_t inventoryType) { equippedWeaponInvType_ = inventoryType; meleeAnimId = 0; }
// Selection circle for targeted entity
void setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color);
@ -254,6 +255,7 @@ private:
float meleeSwingCooldown = 0.0f;
float meleeAnimDurationMs = 0.0f;
uint32_t meleeAnimId = 0;
uint32_t equippedWeaponInvType_ = 0;
bool terrainEnabled = true;
bool terrainLoaded = false;

View file

@ -76,7 +76,9 @@ private:
// Item icon cache: displayInfoId -> GL texture
std::unordered_map<uint32_t, GLuint> iconCache_;
public:
GLuint getItemIcon(uint32_t displayInfoId);
private:
// Character model preview
std::unique_ptr<rendering::CharacterPreview> charPreview_;

View file

@ -787,6 +787,21 @@ void Application::spawnPlayerCharacter() {
std::string faceLowerTexturePath;
std::vector<std::string> underwearPaths;
// Extract appearance bytes for texture lookups
uint8_t charSkinId = 0, charFaceId = 0, charHairStyleId = 0, charHairColorId = 0;
if (gameHandler) {
const game::Character* activeChar = gameHandler->getActiveCharacter();
if (activeChar) {
charSkinId = activeChar->appearanceBytes & 0xFF;
charFaceId = (activeChar->appearanceBytes >> 8) & 0xFF;
charHairStyleId = (activeChar->appearanceBytes >> 16) & 0xFF;
charHairColorId = (activeChar->appearanceBytes >> 24) & 0xFF;
LOG_INFO("Appearance: skin=", (int)charSkinId, " face=", (int)charFaceId,
" hairStyle=", (int)charHairStyleId, " hairColor=", (int)charHairColorId);
}
}
std::string hairTexturePath;
if (useCharSections) {
auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc");
if (charSectionsDbc) {
@ -794,6 +809,7 @@ void Application::spawnPlayerCharacter() {
bool foundSkin = false;
bool foundUnderwear = false;
bool foundFaceLower = false;
bool foundHair = false;
for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
uint32_t raceId = charSectionsDbc->getUInt32(r, 1);
uint32_t sexId = charSectionsDbc->getUInt32(r, 2);
@ -803,23 +819,37 @@ void Application::spawnPlayerCharacter() {
if (raceId != targetRaceId || sexId != targetSexId) continue;
if (baseSection == 0 && !foundSkin && variationIndex == 0 && colorIndex == 0) {
// Section 0 = skin: match by colorIndex = skin byte
if (baseSection == 0 && !foundSkin && colorIndex == charSkinId) {
std::string tex1 = charSectionsDbc->getString(r, 4);
if (!tex1.empty()) {
bodySkinPath = tex1;
foundSkin = true;
LOG_INFO(" DBC body skin: ", bodySkinPath);
LOG_INFO(" DBC body skin: ", bodySkinPath, " (skin=", (int)charSkinId, ")");
}
} else if (baseSection == 3 && colorIndex == 0) {
(void)variationIndex;
} else if (baseSection == 1 && !foundFaceLower && variationIndex == 0 && colorIndex == 0) {
}
// Section 3 = hair: match variation=hairStyle, color=hairColor
else if (baseSection == 3 && !foundHair &&
variationIndex == charHairStyleId && colorIndex == charHairColorId) {
hairTexturePath = charSectionsDbc->getString(r, 4);
if (!hairTexturePath.empty()) {
foundHair = true;
LOG_INFO(" DBC hair texture: ", hairTexturePath,
" (style=", (int)charHairStyleId, " color=", (int)charHairColorId, ")");
}
}
// Section 1 = face lower: match variation=faceId
else if (baseSection == 1 && !foundFaceLower &&
variationIndex == charFaceId && colorIndex == charSkinId) {
std::string tex1 = charSectionsDbc->getString(r, 4);
if (!tex1.empty()) {
faceLowerTexturePath = tex1;
foundFaceLower = true;
LOG_INFO(" DBC face texture: ", faceLowerTexturePath);
}
} else if (baseSection == 4 && !foundUnderwear && variationIndex == 0 && colorIndex == 0) {
}
// Section 4 = underwear
else if (baseSection == 4 && !foundUnderwear && colorIndex == charSkinId) {
for (int f = 4; f <= 6; f++) {
std::string tex = charSectionsDbc->getString(r, f);
if (!tex.empty()) {
@ -829,36 +859,19 @@ void Application::spawnPlayerCharacter() {
}
foundUnderwear = true;
}
if (foundSkin && foundHair && foundFaceLower && foundUnderwear) break;
}
if (!foundHair) {
LOG_WARNING("No DBC hair match for style=", (int)charHairStyleId,
" color=", (int)charHairColorId,
" race=", targetRaceId, " sex=", targetSexId);
}
} else {
LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures");
}
// Look up hair texture from CharSections.dbc section 3
std::string hairTexturePath;
if (gameHandler) {
const game::Character* activeChar = gameHandler->getActiveCharacter();
if (activeChar) {
uint8_t hairStyleId = (activeChar->appearanceBytes >> 16) & 0xFF;
uint8_t hairColorId = (activeChar->appearanceBytes >> 24) & 0xFF;
for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
uint32_t raceId = charSectionsDbc->getUInt32(r, 1);
uint32_t sexId = charSectionsDbc->getUInt32(r, 2);
uint32_t section = charSectionsDbc->getUInt32(r, 3);
uint32_t variation = charSectionsDbc->getUInt32(r, 8);
uint32_t colorIdx = charSectionsDbc->getUInt32(r, 9);
if (raceId != targetRaceId || sexId != targetSexId) continue;
if (section != 3) continue;
if (variation != hairStyleId) continue;
if (colorIdx != hairColorId) continue;
hairTexturePath = charSectionsDbc->getString(r, 4);
LOG_INFO(" DBC hair texture: ", hairTexturePath,
" (style=", (int)hairStyleId, " color=", (int)hairColorId, ")");
break;
}
}
}
for (auto& tex : model.textures) {
if (tex.type == 1 && tex.filename.empty()) {
tex.filename = bodySkinPath;

View file

@ -3309,7 +3309,7 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
if (casting) return; // Already casting
uint64_t target = targetGuid != 0 ? targetGuid : targetGuid;
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
auto packet = CastSpellPacket::build(spellId, target, ++castCount);
socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
@ -3389,11 +3389,16 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Add system message about failed cast
// Add system message about failed cast with readable reason
const char* reason = getSpellCastResultString(data.result);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")";
if (reason) {
msg.message = reason;
} else {
msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")";
}
addLocalChatMessage(msg);
}
@ -4310,8 +4315,8 @@ void GameHandler::simulateXpGain(uint64_t victimGuid, uint32_t totalXp) {
packet.writeUInt64(victimGuid);
packet.writeUInt32(totalXp);
packet.writeUInt8(0); // kill XP
packet.writeFloat(0.0f);
packet.writeUInt32(0); // group bonus
packet.writeFloat(1.0f); // group rate (1.0 = solo, no bonus)
packet.writeUInt8(0); // RAF flag
handleXpGain(packet);
}

View file

@ -1326,16 +1326,19 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
data.containerSlots = packet.readUInt32();
uint32_t statsCount = packet.readUInt32();
for (uint32_t i = 0; i < statsCount && i < 10; i++) {
// Server always sends 10 stat pairs; statsCount tells how many are meaningful
for (uint32_t i = 0; i < 10; i++) {
uint32_t statType = packet.readUInt32();
int32_t statValue = static_cast<int32_t>(packet.readUInt32());
switch (statType) {
case 3: data.agility = statValue; break;
case 4: data.strength = statValue; break;
case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break;
default: break;
if (i < statsCount) {
switch (statType) {
case 3: data.agility = statValue; break;
case 4: data.strength = statValue; break;
case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break;
default: break;
}
}
}
@ -1585,9 +1588,13 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) {
data.totalXp = packet.readUInt32();
data.type = packet.readUInt8();
if (data.type == 0) {
// Kill XP: has group bonus float (unused) + group bonus uint32
packet.readFloat();
data.groupBonus = packet.readUInt32();
// Kill XP: float groupRate (1.0 = solo) + uint8 RAF flag
float groupRate = packet.readFloat();
packet.readUInt8(); // RAF bonus flag
// Group bonus = total - (total / rate); only if grouped (rate > 1)
if (groupRate > 1.0f) {
data.groupBonus = data.totalXp - static_cast<uint32_t>(data.totalXp / groupRate);
}
}
LOG_INFO("XP gain: ", data.totalXp, " xp (type=", static_cast<int>(data.type), ")");
return data.totalXp > 0;

View file

@ -391,9 +391,26 @@ uint32_t Renderer::resolveMeleeAnimId() {
return 0.0f;
};
// Prefer weapon attacks (1H=17, 2H=18) over unarmed (16); 19-21 are other variants
const uint32_t attackCandidates[] = {17, 18, 16, 19, 20, 21};
for (uint32_t id : attackCandidates) {
// Select animation priority based on equipped weapon type
// WoW inventory types: 17 = 2H weapon, 13/21 = 1H, 0 = unarmed
// WoW anim IDs: 16 = unarmed, 17 = 1H attack, 18 = 2H attack
const uint32_t* attackCandidates;
size_t candidateCount;
static const uint32_t candidates2H[] = {18, 17, 16, 19, 20, 21};
static const uint32_t candidates1H[] = {17, 18, 16, 19, 20, 21};
static const uint32_t candidatesUnarmed[] = {16, 17, 18, 19, 20, 21};
if (equippedWeaponInvType_ == 17) { // INVTYPE_2HWEAPON
attackCandidates = candidates2H;
candidateCount = 6;
} else if (equippedWeaponInvType_ == 0) {
attackCandidates = candidatesUnarmed;
candidateCount = 6;
} else {
attackCandidates = candidates1H;
candidateCount = 6;
}
for (size_t ci = 0; ci < candidateCount; ci++) {
uint32_t id = attackCandidates[ci];
if (characterRenderer->hasAnimation(characterInstanceId, id)) {
meleeAnimId = id;
meleeAnimDurationMs = findDuration(id);

View file

@ -149,6 +149,12 @@ void GameScreen::render(game::GameHandler& gameHandler) {
core::Application::getInstance().loadEquippedWeapons();
gameHandler.notifyEquipmentChanged();
inventoryScreen.markPreviewDirty();
// Update renderer weapon type for animation selection
auto* r = core::Application::getInstance().getRenderer();
if (r) {
const auto& mh = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND);
r->setEquippedWeaponType(mh.empty() ? 0 : mh.item.inventoryType);
}
}
// Update renderer face-target position and selection circle
@ -1723,14 +1729,73 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
ImGui::Separator();
}
// Items
// Items with icons and labels
constexpr float iconSize = 32.0f;
for (const auto& item : loot.items) {
ImGui::PushID(item.slotIndex);
char label[64];
snprintf(label, sizeof(label), "Item %u (x%u)", item.itemId, item.count);
if (ImGui::Selectable(label)) {
// Get item info for name and quality
const auto* info = gameHandler.getItemInfo(item.itemId);
std::string itemName = info && !info->name.empty()
? info->name
: "Item #" + std::to_string(item.itemId);
game::ItemQuality quality = info
? static_cast<game::ItemQuality>(info->quality)
: game::ItemQuality::COMMON;
ImVec4 qColor = InventoryScreen::getQualityColor(quality);
// Get item icon
uint32_t displayId = item.displayInfoId;
if (displayId == 0 && info) displayId = info->displayInfoId;
GLuint iconTex = inventoryScreen.getItemIcon(displayId);
ImVec2 cursor = ImGui::GetCursorScreenPos();
float rowH = std::max(iconSize, ImGui::GetTextLineHeight() * 2.0f);
// Invisible selectable for click handling
if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) {
gameHandler.lootItem(item.slotIndex);
}
bool hovered = ImGui::IsItemHovered();
ImDrawList* drawList = ImGui::GetWindowDrawList();
// Draw hover highlight
if (hovered) {
drawList->AddRectFilled(cursor,
ImVec2(cursor.x + ImGui::GetContentRegionAvail().x + iconSize + 8.0f,
cursor.y + rowH),
IM_COL32(255, 255, 255, 30));
}
// Draw icon
if (iconTex) {
drawList->AddImage((ImTextureID)(uintptr_t)iconTex,
cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize));
drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize),
ImGui::ColorConvertFloat4ToU32(qColor));
} else {
drawList->AddRectFilled(cursor,
ImVec2(cursor.x + iconSize, cursor.y + iconSize),
IM_COL32(40, 40, 50, 200));
drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize),
IM_COL32(80, 80, 80, 200));
}
// Draw item name
float textX = cursor.x + iconSize + 6.0f;
float textY = cursor.y + 2.0f;
drawList->AddText(ImVec2(textX, textY),
ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str());
// Draw count if > 1
if (item.count > 1) {
char countStr[32];
snprintf(countStr, sizeof(countStr), "x%u", item.count);
float countY = textY + ImGui::GetTextLineHeight();
drawList->AddText(ImVec2(textX, countY), IM_COL32(200, 200, 200, 220), countStr);
}
ImGui::PopID();
}
@ -1924,6 +1989,9 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
ImGui::Text("Your money: %ug %us %uc", mg, ms, mc);
ImGui::Separator();
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell");
ImGui::Separator();
if (vendor.items.empty()) {
ImGui::TextDisabled("This vendor has nothing for sale.");
} else {

View file

@ -642,7 +642,9 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
// Stats panel
ImGui::Spacing();
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
renderStatsPanel(inventory, gameHandler.getPlayerLevel());
ImGui::End();