Fix quest flow regressions, tooltip compare stats, and M2 alpha-key handling

This commit is contained in:
Kelsi 2026-02-19 02:27:01 -08:00
parent 512d60c9be
commit 8d4d9b7169
6 changed files with 269 additions and 177 deletions

View file

@ -32,6 +32,7 @@ struct M2ModelGPU {
uint32_t indexStart = 0; // offset in indices (not bytes)
uint32_t indexCount = 0;
bool hasAlpha = false;
bool colorKeyBlack = false;
uint16_t textureAnimIndex = 0xFFFF; // 0xFFFF = no texture animation
uint16_t blendMode = 0; // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, etc.
uint16_t materialFlags = 0; // M2 material flags (0x01=Unlit, 0x04=TwoSided, 0x10=NoDepthWrite)
@ -366,9 +367,11 @@ private:
size_t approxBytes = 0;
uint64_t lastUse = 0;
bool hasAlpha = true;
bool colorKeyBlack = false;
};
std::unordered_map<std::string, TextureCacheEntry> textureCache;
std::unordered_map<GLuint, bool> textureHasAlphaById_;
std::unordered_map<GLuint, bool> textureColorKeyBlackById_;
size_t textureCacheBytes_ = 0;
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; // Default, overridden at init

View file

@ -4283,9 +4283,6 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride; // 25 quest slots
for (const auto& [key, val] : block.fields) {
if (key == ufPlayerXp) { playerXp_ = val; }
else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; }
@ -4299,31 +4296,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerMoneyCopper_ = val;
LOG_INFO("Money set from update fields: ", val, " copper");
}
// Parse quest log fields (stride varies by expansion: 5=WotLK, 3=Classic)
else if (key >= ufQuestStart && key < ufQuestEnd && (key - ufQuestStart) % qStride == 0) {
uint32_t questId = val;
if (questId != 0) {
// Check if quest is already in log
bool found = false;
for (auto& q : questLog_) {
if (q.questId == questId) {
found = true;
break;
}
}
if (!found) {
// Add quest to log and request quest details
QuestLogEntry entry;
entry.questId = questId;
entry.complete = false; // Will be updated by gossip or quest status packets
entry.title = "Quest #" + std::to_string(questId);
questLog_.push_back(entry);
LOG_INFO("Found quest in update fields: ", questId);
requestQuestQuery(questId);
}
}
}
// Do not synthesize quest-log entries from raw update-field slots.
// Slot layouts differ on some classic-family realms and can produce
// phantom "already accepted" quests that block quest acceptance.
}
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
@ -4608,38 +4583,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
}
}
}
// Scan quest log fields in VALUES updates too (server may re-send them
// after quest accept, abandon, or same-map repositions).
{
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride;
for (const auto& [key, val] : block.fields) {
if (key >= ufQuestStart && key < ufQuestEnd &&
(key - ufQuestStart) % qStride == 0) {
uint32_t qId = val;
if (qId != 0) {
bool found = false;
for (auto& q : questLog_) {
if (q.questId == qId) { found = true; break; }
}
if (!found) {
QuestLogEntry entry;
entry.questId = qId;
entry.complete = false;
entry.title = "Quest #" + std::to_string(qId);
questLog_.push_back(entry);
LOG_INFO("Quest found in VALUES update: ", qId);
requestQuestQuery(qId);
}
} else {
// Quest slot cleared — remove from log if present
uint16_t slot = (key - ufQuestStart) / qStride;
(void)slot; // slot index available if needed
}
}
}
}
// Do not auto-create quests from VALUES quest-log slot fields for the
// same reason as CREATE_OBJECT2 above (can be misaligned per realm).
if (applyInventoryFields(block.fields)) slotsChanged = true;
if (slotsChanged) rebuildOnlineInventory();
extractSkillFields(lastPlayerFields_);
@ -8722,22 +8667,7 @@ void GameHandler::selectGossipOption(uint32_t optionId) {
void GameHandler::selectGossipQuest(uint32_t questId) {
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
// Prefer current gossip icon semantics to choose flow.
// WotLK/classic gossip icon conventions commonly use:
// 2 = available (!), 4 = active/incomplete (?), 5 = completable (?)
const GossipQuestItem* gossipQuest = nullptr;
for (const auto& q : currentGossip.quests) {
if (q.questId == questId) {
gossipQuest = &q;
break;
}
}
const bool iconSaysAvailable = gossipQuest && gossipQuest->questIcon == 2;
const bool iconSaysActive = gossipQuest &&
(gossipQuest->questIcon == 4 || gossipQuest->questIcon == 5);
// Keep quest-log fallback for servers that don't use canonical icon values.
// Keep quest-log fallback for servers that don't provide stable icon semantics.
const QuestLogEntry* activeQuest = nullptr;
for (const auto& q : questLog_) {
if (q.questId == questId) {
@ -8746,7 +8676,25 @@ void GameHandler::selectGossipQuest(uint32_t questId) {
}
}
const bool shouldStartProgressFlow = iconSaysActive || (!iconSaysAvailable && activeQuest);
// Validate against server-auth quest slot fields to avoid stale local entries
// forcing turn-in flow for quests that are not actually accepted.
auto questInServerLogSlots = [&](uint32_t qid) -> bool {
if (qid == 0 || lastPlayerFields_.empty()) return false;
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5;
const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride;
for (const auto& [key, val] : lastPlayerFields_) {
if (key < ufQuestStart || key >= ufQuestEnd) continue;
if ((key - ufQuestStart) % qStride != 0) continue;
if (val == qid) return true;
}
return false;
};
const bool activeQuestConfirmedByServer = activeQuest && questInServerLogSlots(questId);
// Only trust server quest-log slots for deciding "already accepted" flow.
// Gossip icon values can differ across cores/expansions and misclassify
// available quests as active, which blocks acceptance.
const bool shouldStartProgressFlow = activeQuestConfirmedByServer;
if (shouldStartProgressFlow) {
pendingTurnInQuestId_ = questId;
pendingTurnInNpcGuid_ = currentGossip.npcGuid;

View file

@ -882,8 +882,40 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
uint32_t subClass = packet.readUInt32();
// Vanilla: NO SoundOverrideSubclass
(void)itemClass;
(void)subClass;
data.itemClass = itemClass;
data.subClass = subClass;
data.subclassName = "";
if (itemClass == 2) { // Weapon
switch (subClass) {
case 0: data.subclassName = "Axe"; break;
case 1: data.subclassName = "Axe"; break;
case 2: data.subclassName = "Bow"; break;
case 3: data.subclassName = "Gun"; break;
case 4: data.subclassName = "Mace"; break;
case 5: data.subclassName = "Mace"; break;
case 6: data.subclassName = "Polearm"; break;
case 7: data.subclassName = "Sword"; break;
case 8: data.subclassName = "Sword"; break;
case 10: data.subclassName = "Staff"; break;
case 13: data.subclassName = "Fist Weapon"; break;
case 15: data.subclassName = "Dagger"; break;
case 16: data.subclassName = "Thrown"; break;
case 18: data.subclassName = "Crossbow"; break;
case 19: data.subclassName = "Wand"; break;
case 20: data.subclassName = "Fishing Pole"; break;
default: data.subclassName = "Weapon"; break;
}
} else if (itemClass == 4) { // Armor
switch (subClass) {
case 0: data.subclassName = "Miscellaneous"; break;
case 1: data.subclassName = "Cloth"; break;
case 2: data.subclassName = "Leather"; break;
case 3: data.subclassName = "Mail"; break;
case 4: data.subclassName = "Plate"; break;
case 6: data.subclassName = "Shield"; break;
default: data.subclassName = "Armor"; break;
}
}
// 4 name strings
data.name = packet.readString();
@ -935,14 +967,34 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
// Vanilla: NO ScalingStatDistribution, NO ScalingStatValue
// Vanilla: 5 damage types (same count as WotLK)
bool haveWeaponDamage = false;
for (int i = 0; i < 5; i++) {
packet.readFloat(); // DamageMin
packet.readFloat(); // DamageMax
packet.readUInt32(); // DamageType
float dmgMin = packet.readFloat();
float dmgMax = packet.readFloat();
uint32_t damageType = packet.readUInt32();
if (!haveWeaponDamage && dmgMax > 0.0f) {
// Prefer physical damage (type 0) when present.
if (damageType == 0 || data.damageMax <= 0.0f) {
data.damageMin = dmgMin;
data.damageMax = dmgMax;
haveWeaponDamage = (damageType == 0);
}
}
}
data.armor = static_cast<int32_t>(packet.readUInt32());
// Remaining tail can vary by core. Read resistances + delay when present.
if (packet.getSize() - packet.getReadPos() >= 28) {
packet.readUInt32(); // HolyRes
packet.readUInt32(); // FireRes
packet.readUInt32(); // NatureRes
packet.readUInt32(); // FrostRes
packet.readUInt32(); // ShadowRes
packet.readUInt32(); // ArcaneRes
data.delayMs = packet.readUInt32();
}
data.valid = !data.name.empty();
LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality,
" invType=", data.inventoryType, " stack=", data.maxStack, ")");

View file

@ -325,6 +325,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
uniform sampler2D uTexture;
uniform bool uHasTexture;
uniform bool uAlphaTest;
uniform bool uColorKeyBlack;
uniform bool uUnlit;
uniform float uFadeAlpha;
@ -348,10 +349,16 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish
}
// Alpha test for leaves, fences, etc.
// Alpha test / alpha-key cutout for card textures.
if (uAlphaTest && texColor.a < 0.5) {
discard;
}
if (uAlphaTest && max(texColor.r, max(texColor.g, texColor.b)) < 0.06) {
discard;
}
if (uColorKeyBlack && max(texColor.r, max(texColor.g, texColor.b)) < 0.08) {
discard;
}
// Distance fade - discard nearly invisible fragments
float finalAlpha = texColor.a * uFadeAlpha;
@ -536,6 +543,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
in float vTile;
uniform sampler2D uTexture;
uniform vec2 uTileCount;
uniform bool uAlphaKey;
out vec4 FragColor;
void main() {
@ -555,6 +563,14 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) {
vec2 tileSize = vec2(1.0 / tilesX, 1.0 / tilesY);
vec2 uv = gl_PointCoord * tileSize + vec2(col, row) * tileSize;
vec4 texColor = texture(uTexture, uv);
// Alpha-key particle textures often encode transparency as near-black
// color without meaningful alpha.
if (uAlphaKey) {
float maxRgb = max(texColor.r, max(texColor.g, texColor.b));
if (maxRgb < 0.06 || texColor.a < 0.5) discard;
}
FragColor = texColor * vColor;
FragColor.a *= edgeFade;
if (FragColor.a < 0.01) discard;
@ -665,6 +681,7 @@ void M2Renderer::shutdown() {
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
textureHasAlphaById_.clear();
textureColorKeyBlackById_.clear();
if (whiteTexture != 0) {
glDeleteTextures(1, &whiteTexture);
whiteTexture = 0;
@ -1170,7 +1187,18 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
texFailed = !textureLoadFailed.empty() && textureLoadFailed[0];
}
bgpu.texture = tex;
bgpu.hasAlpha = (tex != 0 && tex != whiteTexture);
bool texHasAlpha = false;
if (tex != 0 && tex != whiteTexture) {
auto ait = textureHasAlphaById_.find(tex);
texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false;
}
bgpu.hasAlpha = texHasAlpha;
bool colorKeyBlack = false;
if (tex != 0 && tex != whiteTexture) {
auto cit = textureColorKeyBlackById_.find(tex);
colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false;
}
bgpu.colorKeyBlack = colorKeyBlack;
// textureCoordIndex is an index into a texture coord combo table, not directly
// a UV set selector. Most batches have index=0 (UV set 0). We always use UV set 0
// since we don't have the full combo table — dual-UV effects are rare edge cases.
@ -1218,7 +1246,18 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
bgpu.indexStart = 0;
bgpu.indexCount = gpuModel.indexCount;
bgpu.texture = allTextures.empty() ? whiteTexture : allTextures[0];
bgpu.hasAlpha = (bgpu.texture != 0 && bgpu.texture != whiteTexture);
bool texHasAlpha = false;
if (bgpu.texture != 0 && bgpu.texture != whiteTexture) {
auto ait = textureHasAlphaById_.find(bgpu.texture);
texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false;
}
bgpu.hasAlpha = texHasAlpha;
bool colorKeyBlack = false;
if (bgpu.texture != 0 && bgpu.texture != whiteTexture) {
auto cit = textureColorKeyBlackById_.find(bgpu.texture);
colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false;
}
bgpu.colorKeyBlack = colorKeyBlack;
gpuModel.batches.push_back(bgpu);
}
@ -1826,6 +1865,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
static GLuint lastBoundTexture = 0;
static bool lastHasTexture = false;
static bool lastAlphaTest = false;
static bool lastColorKeyBlack = false;
static bool lastUnlit = false;
static bool lastUseBones = false;
static bool lastInteriorDarken = false;
@ -1838,6 +1878,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
lastBoundTexture = 0;
lastHasTexture = false;
lastAlphaTest = false;
lastColorKeyBlack = false;
lastUnlit = false;
lastUseBones = false;
lastInteriorDarken = false;
@ -1849,6 +1890,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
// Set texture unit once per frame instead of per-batch
glActiveTexture(GL_TEXTURE0);
shader->setUniform("uTexture", 0); // Texture unit 0, set once per frame
shader->setUniform("uColorKeyBlack", false);
// Performance counters
uint32_t boneMatrixUploads = 0;
@ -1942,19 +1984,8 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
// Skip batches with zero opacity from texture weight tracks (should be invisible)
if (batch.batchOpacity < 0.01f) continue;
// Additive/mod batches (glow halos, light effects): collect as glow sprites
// instead of rendering the mesh geometry which appears as flat orange disks.
if (batch.blendMode >= 3) {
if (entry.distSq < 120.0f * 120.0f) { // Only render glow within 120 units
glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(batch.center, 1.0f));
GlowSprite gs;
gs.worldPos = worldPos;
gs.color = glm::vec4(1.0f, 0.75f, 0.35f, 0.85f);
gs.size = batch.glowSize * instance.scale;
glowSprites_.push_back(gs);
}
continue;
}
// Render additive/mod batches as authored geometry so alpha-cutout cards
// (e.g. candle flames) keep their original transparency/glow behavior.
// Compute UV offset for texture animation (only set uniform if changed)
glm::vec2 uvOffset(0.0f, 0.0f);
@ -2040,11 +2071,17 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
lastHasTexture = hasTexture;
}
bool alphaTest = (batch.blendMode == 1);
bool alphaTest = (batch.blendMode == 1) ||
(batch.blendMode >= 2 && !batch.hasAlpha);
if (alphaTest != lastAlphaTest) {
shader->setUniform("uAlphaTest", alphaTest);
lastAlphaTest = alphaTest;
}
bool colorKeyBlack = batch.colorKeyBlack;
if (colorKeyBlack != lastColorKeyBlack) {
shader->setUniform("uColorKeyBlack", colorKeyBlack);
lastColorKeyBlack = colorKeyBlack;
}
// Only bind texture if it changed (texture unit already set to GL_TEXTURE0)
if (hasTexture && batch.texture != lastBoundTexture) {
@ -2519,6 +2556,7 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj)
GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection");
GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture");
GLint tileLoc = glGetUniformLocation(m2ParticleShader_, "uTileCount");
GLint alphaKeyLoc = glGetUniformLocation(m2ParticleShader_, "uAlphaKey");
glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(proj));
glUniform1i(texLoc, 0);
@ -2532,6 +2570,7 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj)
// Use blend mode as specified by the emitter — don't override based on texture alpha.
// BlendType: 0=opaque, 1=alphaKey, 2=alpha, 3=add, 4=mod
uint8_t blendType = group.blendType;
glUniform1i(alphaKeyLoc, (blendType == 1) ? 1 : 0);
if (blendType == 3 || blendType == 4) {
glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive
} else {
@ -2819,6 +2858,15 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
return it->second.id;
}
auto containsToken = [](const std::string& haystack, const char* token) {
return haystack.find(token) != std::string::npos;
};
const bool colorKeyBlackHint =
containsToken(key, "candle") ||
containsToken(key, "flame") ||
containsToken(key, "fire") ||
containsToken(key, "torch");
// Load BLP texture
pipeline::BLPImage blp = assetManager->loadTexture(key);
if (!blp.isValid()) {
@ -2860,10 +2908,12 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
size_t base = static_cast<size_t>(blp.width) * static_cast<size_t>(blp.height) * 4ull;
e.approxBytes = base + (base / 3);
e.hasAlpha = hasAlpha;
e.colorKeyBlack = colorKeyBlackHint;
e.lastUse = ++textureCacheCounter_;
textureCacheBytes_ += e.approxBytes;
textureCache[key] = e;
textureHasAlphaById_[textureID] = hasAlpha;
textureColorKeyBlackById_[textureID] = colorKeyBlackHint;
LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")");
return textureID;

View file

@ -763,32 +763,50 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName);
}
}
if (info->damageMax > 0.0f) {
ImGui::Text("%.0f - %.0f Damage", info->damageMin, info->damageMax);
if (info->delayMs > 0) {
float speed = static_cast<float>(info->delayMs) / 1000.0f;
float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed;
ImGui::Text("Speed %.2f", speed);
ImGui::Text("%.1f damage per second", dps);
auto isWeaponInventoryType = [](uint32_t invType) {
switch (invType) {
case 13: // One-Hand
case 15: // Ranged
case 17: // Two-Hand
case 21: // Main Hand
case 25: // Thrown
case 26: // Ranged Right
return true;
default:
return false;
}
}
if (info->armor > 0) ImGui::Text("%d Armor", info->armor);
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
auto renderStat = [&](int32_t val, const char* name) {
if (val > 0) ImGui::TextColored(green, "+%d %s", val, name);
else if (val < 0) ImGui::TextColored(ImVec4(1, 0.2f, 0.2f, 1), "%d %s", val, name);
};
renderStat(info->stamina, "Stamina");
renderStat(info->strength, "Strength");
renderStat(info->agility, "Agility");
renderStat(info->intellect, "Intellect");
renderStat(info->spirit, "Spirit");
const bool isWeapon = isWeaponInventoryType(info->inventoryType);
if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) {
float speed = static_cast<float>(info->delayMs) / 1000.0f;
float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed;
ImGui::Text("%.1f DPS", dps);
}
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
auto appendBonus = [](std::string& out, int32_t val, const char* shortName) {
if (val <= 0) return;
if (!out.empty()) out += " ";
out += "+" + std::to_string(val) + " ";
out += shortName;
};
std::string bonusLine;
appendBonus(bonusLine, info->strength, "Str");
appendBonus(bonusLine, info->agility, "Agi");
appendBonus(bonusLine, info->stamina, "Sta");
appendBonus(bonusLine, info->intellect, "Int");
appendBonus(bonusLine, info->spirit, "Spi");
if (!bonusLine.empty()) {
ImGui::TextColored(green, "%s", bonusLine.c_str());
}
if (!isWeapon && info->armor > 0) {
ImGui::Text("%d Armor", info->armor);
}
if (info->sellPrice > 0) {
uint32_t g = info->sellPrice / 10000;
uint32_t s = (info->sellPrice / 100) % 100;
uint32_t c = info->sellPrice % 100;
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c);
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
}
if (ImGui::GetIO().KeyShift && info->inventoryType > 0) {
@ -801,15 +819,24 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::SameLine();
}
ImGui::TextColored(InventoryScreen::getQualityColor(eq->item.quality), "%s", eq->item.name.c_str());
if (eq->item.damageMax > 0.0f) {
ImGui::Text("%.0f - %.0f Damage", eq->item.damageMin, eq->item.damageMax);
if (isWeaponInventoryType(eq->item.inventoryType) &&
eq->item.damageMax > 0.0f && eq->item.delayMs > 0) {
float speed = static_cast<float>(eq->item.delayMs) / 1000.0f;
float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed;
ImGui::Text("%.1f DPS", dps);
}
if (!isWeaponInventoryType(eq->item.inventoryType) && eq->item.armor > 0) {
ImGui::Text("%d Armor", eq->item.armor);
}
std::string eqBonusLine;
appendBonus(eqBonusLine, eq->item.strength, "Str");
appendBonus(eqBonusLine, eq->item.agility, "Agi");
appendBonus(eqBonusLine, eq->item.stamina, "Sta");
appendBonus(eqBonusLine, eq->item.intellect, "Int");
appendBonus(eqBonusLine, eq->item.spirit, "Spi");
if (!eqBonusLine.empty()) {
ImGui::TextColored(green, "%s", eqBonusLine.c_str());
}
if (eq->item.armor > 0) ImGui::Text("%d Armor", eq->item.armor);
renderStat(eq->item.stamina, "Stamina");
renderStat(eq->item.strength, "Strength");
renderStat(eq->item.agility, "Agility");
renderStat(eq->item.intellect, "Intellect");
renderStat(eq->item.spirit, "Spirit");
}
}
ImGui::EndTooltip();
@ -1002,6 +1029,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
for (const auto& msg : chatHistory) {
if (!shouldShowMessage(msg, activeChatTab_)) continue;
std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler);
ImVec4 color = getChatTypeColor(msg.type);
@ -1027,7 +1055,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
}
renderTextWithLinks(msg.message, color);
renderTextWithLinks(processedMessage, color);
} else if (msg.type == game::ChatType::TEXT_EMOTE) {
if (!tsPrefix.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 0.6f, 0.6f, 1.0f));
@ -1035,7 +1063,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
}
renderTextWithLinks(msg.message, color);
renderTextWithLinks(processedMessage, color);
} else if (!msg.senderName.empty()) {
if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_YELL) {
std::string prefix = tsPrefix + msg.senderName + " says: ";
@ -1043,7 +1071,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
renderTextWithLinks(msg.message, color);
renderTextWithLinks(processedMessage, color);
} else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) {
int chIdx = gameHandler.getChannelIndex(msg.channelName);
std::string chDisplay = chIdx > 0
@ -1054,14 +1082,14 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
renderTextWithLinks(msg.message, color);
renderTextWithLinks(processedMessage, color);
} else {
std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": ";
ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
renderTextWithLinks(msg.message, color);
renderTextWithLinks(processedMessage, color);
}
} else {
std::string prefix = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] ";
@ -1069,7 +1097,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
ImGui::TextWrapped("%s", prefix.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, 0);
renderTextWithLinks(msg.message, color);
renderTextWithLinks(processedMessage, color);
}
}

View file

@ -1571,51 +1571,53 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
}
if (item.damageMax > 0.0f) {
ImGui::Text("%.0f - %.0f Damage", item.damageMin, item.damageMax);
if (item.delayMs > 0) {
float speed = static_cast<float>(item.delayMs) / 1000.0f;
float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed;
ImGui::Text("Speed %.2f", speed);
ImGui::Text("%.1f damage per second", dps);
}
}
// Armor
if (item.armor > 0) {
ImGui::Text("%d Armor", item.armor);
}
// Stats with "Equip:" prefix style
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
ImVec4 red(1.0f, 0.2f, 0.2f, 1.0f);
auto renderStat = [&](int32_t val, const char* name) {
if (val > 0) {
ImGui::TextColored(green, "+%d %s", val, name);
} else if (val < 0) {
ImGui::TextColored(red, "%d %s", val, name);
auto isWeaponInventoryType = [](uint32_t invType) {
switch (invType) {
case 13: // One-Hand
case 15: // Ranged
case 17: // Two-Hand
case 21: // Main Hand
case 25: // Thrown
case 26: // Ranged Right
return true;
default:
return false;
}
};
const bool isWeapon = isWeaponInventoryType(item.inventoryType);
renderStat(item.stamina, "Stamina");
renderStat(item.strength, "Strength");
renderStat(item.agility, "Agility");
renderStat(item.intellect, "Intellect");
renderStat(item.spirit, "Spirit");
// Stack info
if (item.maxStack > 1) {
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Stack: %u/%u", item.stackCount, item.maxStack);
// Compact stats view for weapons: DPS + condensed stat bonuses.
// Non-weapons keep armor/sell info visible.
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) {
float speed = static_cast<float>(item.delayMs) / 1000.0f;
float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed;
ImGui::Text("%.1f DPS", dps);
}
auto appendBonus = [](std::string& out, int32_t val, const char* shortName) {
if (val <= 0) return;
if (!out.empty()) out += " ";
out += "+" + std::to_string(val) + " ";
out += shortName;
};
std::string bonusLine;
appendBonus(bonusLine, item.strength, "Str");
appendBonus(bonusLine, item.agility, "Agi");
appendBonus(bonusLine, item.stamina, "Sta");
appendBonus(bonusLine, item.intellect, "Int");
appendBonus(bonusLine, item.spirit, "Spi");
if (!bonusLine.empty()) {
ImGui::TextColored(green, "%s", bonusLine.c_str());
}
// Sell price
if (!isWeapon && item.armor > 0) {
ImGui::Text("%d Armor", item.armor);
}
if (item.sellPrice > 0) {
uint32_t g = item.sellPrice / 10000;
uint32_t s = (item.sellPrice / 100) % 100;
uint32_t c = item.sellPrice % 100;
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell Price: %ug %us %uc", g, s, c);
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
}
// Shift-hover comparison with currently equipped equivalent.
@ -1630,15 +1632,24 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str());
if (eq->item.damageMax > 0.0f) {
ImGui::Text("%.0f - %.0f Damage", eq->item.damageMin, eq->item.damageMax);
if (isWeaponInventoryType(eq->item.inventoryType) &&
eq->item.damageMax > 0.0f && eq->item.delayMs > 0) {
float speed = static_cast<float>(eq->item.delayMs) / 1000.0f;
float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed;
ImGui::Text("%.1f DPS", dps);
}
if (!isWeaponInventoryType(eq->item.inventoryType) && eq->item.armor > 0) {
ImGui::Text("%d Armor", eq->item.armor);
}
std::string eqBonusLine;
appendBonus(eqBonusLine, eq->item.strength, "Str");
appendBonus(eqBonusLine, eq->item.agility, "Agi");
appendBonus(eqBonusLine, eq->item.stamina, "Sta");
appendBonus(eqBonusLine, eq->item.intellect, "Int");
appendBonus(eqBonusLine, eq->item.spirit, "Spi");
if (!eqBonusLine.empty()) {
ImGui::TextColored(green, "%s", eqBonusLine.c_str());
}
if (eq->item.armor > 0) ImGui::Text("%d Armor", eq->item.armor);
renderStat(eq->item.stamina, "Stamina");
renderStat(eq->item.strength, "Strength");
renderStat(eq->item.agility, "Agility");
renderStat(eq->item.intellect, "Intellect");
renderStat(eq->item.spirit, "Spirit");
}
}