feat: resolve title names from CharTitles.dbc in SMSG_TITLE_EARNED

Previously SMSG_TITLE_EARNED only showed the numeric bit index.
Now it lazy-loads CharTitles.dbc and formats the full title string
with the player's name (e.g. "Title earned: Commander Kelsi!").

- Add CharTitles layout to WotLK (TitleBit=36) and TBC (TitleBit=20) layouts
- loadTitleNameCache() maps each titleBit to its English title string
- SMSG_TITLE_EARNED substitutes %s placeholder with local player's name
- Falls back to "Title earned (bit N)!" if DBC is unavailable
This commit is contained in:
Kelsi 2026-03-12 19:05:54 -07:00
parent cd01d07a91
commit 81b95b4af7
4 changed files with 69 additions and 6 deletions

View file

@ -31,6 +31,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"CharTitles": { "ID": 0, "Title": 2, "TitleBit": 20 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,

View file

@ -31,6 +31,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"CharTitles": { "ID": 0, "Title": 2, "TitleBit": 36 },
"Achievement": { "ID": 0, "Title": 4, "Description": 21, "Points": 39 },
"AchievementCriteria": { "ID": 0, "AchievementID": 1, "Quantity": 4, "Description": 9 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },

View file

@ -2694,6 +2694,12 @@ private:
std::unordered_map<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;
// Title cache: maps titleBit → title string (lazy-loaded from CharTitles.dbc)
// The strings use "%s" as a player-name placeholder (e.g. "Commander %s", "%s the Explorer").
std::unordered_map<uint32_t, std::string> titleNameCache_;
bool titleNameCacheLoaded_ = false;
void loadTitleNameCache();
// Achievement caches (lazy-loaded from Achievement.dbc on first earned event)
std::unordered_map<uint32_t, std::string> achievementNameCache_;
std::unordered_map<uint32_t, std::string> achievementDescCache_;

View file

@ -2095,12 +2095,40 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) break;
uint32_t titleBit = packet.readUInt32();
uint32_t isLost = packet.readUInt32();
char buf[128];
std::snprintf(buf, sizeof(buf),
isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!",
titleBit);
addSystemChatMessage(buf);
LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost);
loadTitleNameCache();
// Format the title string using the player's own name
std::string titleStr;
auto tit = titleNameCache_.find(titleBit);
if (tit != titleNameCache_.end() && !tit->second.empty()) {
// Title strings contain "%s" as a player-name placeholder.
// Replace it with the local player's name if known.
auto nameIt = playerNameCache.find(playerGuid);
const std::string& pName = (nameIt != playerNameCache.end())
? nameIt->second : "you";
const std::string& fmt = tit->second;
size_t pos = fmt.find("%s");
if (pos != std::string::npos) {
titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2);
} else {
titleStr = fmt;
}
}
std::string msg;
if (!titleStr.empty()) {
msg = isLost ? ("Title removed: " + titleStr + ".")
: ("Title earned: " + titleStr + "!");
} else {
char buf[64];
std::snprintf(buf, sizeof(buf),
isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!",
titleBit);
msg = buf;
}
addSystemChatMessage(msg);
LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost,
" title='", titleStr, "'");
break;
}
@ -20539,6 +20567,33 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT
// PackedTime date — uint32 bitfield (seconds since epoch)
// uint32 realmFirst — how many on realm also got it (0 = realm first)
// ---------------------------------------------------------------------------
void GameHandler::loadTitleNameCache() {
if (titleNameCacheLoaded_) return;
titleNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("CharTitles.dbc");
if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 5) return;
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("CharTitles") : nullptr;
uint32_t titleField = layout ? layout->field("Title") : 2;
uint32_t bitField = layout ? layout->field("TitleBit") : 36;
if (titleField == 0xFFFFFFFF) titleField = 2;
if (bitField == 0xFFFFFFFF) bitField = static_cast<uint32_t>(dbc->getFieldCount() - 1);
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t bit = dbc->getUInt32(i, bitField);
if (bit == 0) continue;
std::string name = dbc->getString(i, titleField);
if (!name.empty()) titleNameCache_[bit] = std::move(name);
}
LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC");
}
void GameHandler::loadAchievementNameCache() {
if (achievementNameCacheLoaded_) return;
achievementNameCacheLoaded_ = true;