Fix Turtle WoW compatibility: NPC spawning, quests, spells, realm display, and music

- Add TurtlePacketParsers with dedicated movement block parser (Classic format + transport timestamp)
- Fix quest giver status: read uint32 and translate vanilla enum values for Classic/Turtle
- Fix quest accept packet: remove trailing uint32 that vanilla servers reject
- Fix quest details parser: auto-detect vanilla vs WotLK format (informUnit field)
- Fix spellbook and action bar icons: fallback to WotLK DBC field indices when expansion layout fails
- Fix spell cast failure messages: translate vanilla SpellCastResult codes (+1 offset)
- Fix realm list: correct type values (6=RP, 8=RP-PvP) and population thresholds
- Fix music: disable looping for zone music, auto-advance to next random track when finished
- Add music anti-repeat: avoid playing the same track back-to-back
- Make TBC update block parsing resilient (keep parsed blocks on failure instead of aborting)
- Add right-click attack on hostile mobs
- Add name query diagnostic logging
This commit is contained in:
Kelsi 2026-02-17 05:27:03 -08:00
parent d850fe6fc0
commit 36fc1df706
12 changed files with 358 additions and 48 deletions

View file

@ -148,6 +148,15 @@ public:
return GossipMessageParser::parse(packet, data);
}
// --- Quest Giver Status ---
/** Read quest giver status from packet.
* WotLK: uint8, vanilla/classic: uint32 with different enum values.
* Returns the status value normalized to WotLK enum values. */
virtual uint8_t readQuestGiverStatus(network::Packet& packet) {
return packet.readUInt8();
}
// --- Destroy Object ---
/** Parse SMSG_DESTROY_OBJECT */
@ -294,13 +303,31 @@ public:
network::Packet buildMailDelete(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) override;
network::Packet buildItemQuery(uint32_t entry, uint64_t guid) override;
bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override;
uint8_t readQuestGiverStatus(network::Packet& packet) override;
};
/**
* Turtle WoW (build 7234) packet parsers.
*
* Turtle WoW is a heavily modified vanilla server that sends TBC-style
* movement blocks (moveFlags2, transport timestamps, 8 speeds including flight)
* while keeping all other Classic packet formats.
*
* Inherits all Classic overrides (charEnum, chat, gossip, mail, items, etc.)
* but delegates movement block parsing to TBC format.
*/
class TurtlePacketParsers : public ClassicPacketParsers {
public:
uint8_t movementFlags2Size() const override { return 0; }
bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override;
};
/**
* Factory function to create the right parser set for an expansion.
*/
inline std::unique_ptr<PacketParsers> createPacketParsers(const std::string& expansionId) {
if (expansionId == "classic" || expansionId == "turtle") return std::make_unique<ClassicPacketParsers>();
if (expansionId == "classic") return std::make_unique<ClassicPacketParsers>();
if (expansionId == "turtle") return std::make_unique<TurtlePacketParsers>();
if (expansionId == "tbc") return std::make_unique<TbcPacketParsers>();
return std::make_unique<WotlkPacketParsers>();
}

View file

@ -20,13 +20,14 @@ public:
uint32_t getZoneId(int tileX, int tileY) const;
const ZoneInfo* getZoneInfo(uint32_t zoneId) const;
std::string getRandomMusic(uint32_t zoneId) const;
std::string getRandomMusic(uint32_t zoneId);
std::vector<std::string> getAllMusicPaths() const;
private:
// tile key = tileX * 100 + tileY
std::unordered_map<int, uint32_t> tileToZone;
std::unordered_map<uint32_t, ZoneInfo> zones;
std::string lastPlayedMusic_;
};
} // namespace game

View file

@ -158,7 +158,7 @@ void MusicManager::crossfadeTo(const std::string& mpqPath, float fadeMs) {
fadeDuration = fadeMs / 1000.0f;
AudioEngine::instance().stopMusic();
} else {
playMusic(mpqPath);
playMusic(mpqPath, false);
}
}
@ -173,7 +173,7 @@ void MusicManager::crossfadeToFile(const std::string& filePath, float fadeMs) {
fadeDuration = fadeMs / 1000.0f;
AudioEngine::instance().stopMusic();
} else {
playFilePath(filePath);
playFilePath(filePath, false);
}
}
@ -190,9 +190,9 @@ void MusicManager::update(float deltaTime) {
// Start new track after brief pause
crossfading = false;
if (pendingIsFile) {
playFilePath(pendingTrack);
playFilePath(pendingTrack, false);
} else {
playMusic(pendingTrack);
playMusic(pendingTrack, false);
}
pendingTrack.clear();
pendingIsFile = false;

View file

@ -1213,23 +1213,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleGameObjectQueryResponse(packet);
break;
case Opcode::SMSG_QUESTGIVER_STATUS: {
// uint64 npcGuid + uint8 status
if (packet.getSize() - packet.getReadPos() >= 9) {
uint64_t npcGuid = packet.readUInt64();
uint8_t status = packet.readUInt8();
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
LOG_DEBUG("SMSG_QUESTGIVER_STATUS: guid=0x", std::hex, npcGuid, std::dec, " status=", (int)status);
}
break;
}
case Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE: {
// uint32 count, then count * (uint64 guid + uint8 status)
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t count = packet.readUInt32();
for (uint32_t i = 0; i < count; ++i) {
if (packet.getSize() - packet.getReadPos() < 9) break;
uint64_t npcGuid = packet.readUInt64();
uint8_t status = packet.readUInt8();
uint8_t status = packetParsers_->readQuestGiverStatus(packet);
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
}
LOG_DEBUG("SMSG_QUESTGIVER_STATUS_MULTIPLE: ", count, " entries");
@ -5416,8 +5414,13 @@ void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
void GameHandler::queryPlayerName(uint64_t guid) {
if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return;
if (state != WorldState::IN_WORLD || !socket) return;
if (state != WorldState::IN_WORLD || !socket) {
LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec,
" state=", worldStateName(state), " socket=", (socket ? "yes" : "no"));
return;
}
LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec);
pendingNameQueries.insert(guid);
auto packet = NameQueryPacket::build(guid);
socket->send(packet);
@ -5460,6 +5463,10 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) {
pendingNameQueries.erase(data.guid);
LOG_INFO("Name query response: guid=0x", std::hex, data.guid, std::dec,
" found=", (int)data.found, " name='", data.name, "'",
" race=", (int)data.race, " class=", (int)data.classId);
if (data.isValid()) {
playerNameCache[data.guid] = data.name;
// Update entity name

View file

@ -316,8 +316,12 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo
bool ClassicPacketParsers::parseCastFailed(network::Packet& packet, CastFailedData& data) {
data.castCount = 0;
data.spellId = packet.readUInt32();
data.result = packet.readUInt8();
LOG_INFO("[Classic] Cast failed: spell=", data.spellId, " result=", (int)data.result);
uint8_t vanillaResult = packet.readUInt8();
// Vanilla enum starts at 0=AFFECTING_COMBAT (no SUCCESS entry).
// WotLK enum starts at 0=SUCCESS, 1=AFFECTING_COMBAT.
// Shift +1 to align with WotLK result strings.
data.result = vanillaResult + 1;
LOG_DEBUG("[Classic] Cast failed: spell=", data.spellId, " vanillaResult=", (int)vanillaResult);
return true;
}
@ -945,5 +949,210 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
return true;
}
// ============================================================================
// Turtle WoW (build 7234) parseMovementBlock
//
// Turtle WoW is a heavily modified vanilla (1.12.1) server. Through hex dump
// analysis the wire format is nearly identical to Classic with one key addition:
//
// LIVING section:
// moveFlags u32 (NO moveFlags2 — confirmed by position alignment)
// time u32
// position 4×float
// transport guarded by moveFlags & 0x02000000 (Classic flag)
// packed GUID + 4 floats + u32 timestamp (TBC-style addition)
// pitch guarded by SWIMMING (0x200000)
// fallTime u32
// jump data guarded by JUMPING (0x2000)
// splineElev guarded by 0x04000000
// speeds 6 floats (walk/run/runBack/swim/swimBack/turnRate)
// spline guarded by 0x00400000 (Classic flag) OR 0x08000000 (TBC flag)
//
// Tail (same as Classic):
// LOWGUID → 1×u32
// HIGHGUID → 1×u32
//
// The ONLY confirmed difference from pure Classic is:
// Transport data includes a u32 timestamp after the 4 transport floats
// (Classic omits this; TBC/WotLK include it). Without this, entities on
// transports cause a 4-byte desync that cascades to later blocks.
// ============================================================================
namespace TurtleMoveFlags {
constexpr uint32_t ONTRANSPORT = 0x02000000; // Classic transport flag
constexpr uint32_t JUMPING = 0x00002000;
constexpr uint32_t SWIMMING = 0x00200000;
constexpr uint32_t SPLINE_ELEVATION = 0x04000000;
constexpr uint32_t SPLINE_CLASSIC = 0x00400000; // Classic spline enabled
constexpr uint32_t SPLINE_TBC = 0x08000000; // TBC spline enabled
}
bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
uint8_t updateFlags = packet.readUInt8();
block.updateFlags = static_cast<uint16_t>(updateFlags);
LOG_DEBUG(" [Turtle] UpdateFlags: 0x", std::hex, (int)updateFlags, std::dec);
const uint8_t UPDATEFLAG_LIVING = 0x20;
const uint8_t UPDATEFLAG_HAS_POSITION = 0x40;
const uint8_t UPDATEFLAG_HAS_TARGET = 0x04;
const uint8_t UPDATEFLAG_TRANSPORT = 0x02;
const uint8_t UPDATEFLAG_LOWGUID = 0x08;
const uint8_t UPDATEFLAG_HIGHGUID = 0x10;
if (updateFlags & UPDATEFLAG_LIVING) {
size_t livingStart = packet.getReadPos();
uint32_t moveFlags = packet.readUInt32();
// Turtle: NO moveFlags2 (confirmed by hex dump — positions are only correct without it)
/*uint32_t time =*/ packet.readUInt32();
// Position
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
block.orientation = packet.readFloat();
block.hasMovement = true;
LOG_DEBUG(" [Turtle] LIVING: (", block.x, ", ", block.y, ", ", block.z,
"), o=", block.orientation, " moveFlags=0x", std::hex, moveFlags, std::dec);
// Transport — Classic flag position 0x02000000
if (moveFlags & TurtleMoveFlags::ONTRANSPORT) {
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
block.transportO = packet.readFloat();
/*uint32_t transportTime =*/ packet.readUInt32(); // Turtle adds TBC-style timestamp
}
// Pitch (swimming only, Classic-style)
if (moveFlags & TurtleMoveFlags::SWIMMING) {
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
/*uint32_t fallTime =*/ packet.readUInt32();
// Jump data
if (moveFlags & TurtleMoveFlags::JUMPING) {
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
/*float jumpXYSpeed =*/ packet.readFloat();
}
// Spline elevation
if (moveFlags & TurtleMoveFlags::SPLINE_ELEVATION) {
/*float splineElevation =*/ packet.readFloat();
}
// Turtle: 6 speeds (same as Classic — no flight speeds)
float walkSpeed = packet.readFloat();
float runSpeed = packet.readFloat();
float runBackSpeed = packet.readFloat();
float swimSpeed = packet.readFloat();
float swimBackSpeed = packet.readFloat();
float turnRate = packet.readFloat();
block.runSpeed = runSpeed;
LOG_DEBUG(" [Turtle] Speeds: walk=", walkSpeed, " run=", runSpeed,
" runBack=", runBackSpeed, " swim=", swimSpeed,
" swimBack=", swimBackSpeed, " turn=", turnRate);
// Spline data — check both Classic (0x00400000) and TBC (0x08000000) flag positions
bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) ||
(moveFlags & TurtleMoveFlags::SPLINE_TBC);
if (hasSpline) {
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [Turtle] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) {
packet.readFloat(); packet.readFloat(); packet.readFloat();
} else if (splineFlags & 0x00020000) {
packet.readUInt64();
} else if (splineFlags & 0x00040000) {
packet.readFloat();
}
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
if (pointCount > 256) {
LOG_WARNING(" [Turtle] Spline pointCount=", pointCount, " exceeds max, capping");
pointCount = 0;
}
for (uint32_t i = 0; i < pointCount; i++) {
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
// End point
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
LOG_DEBUG(" [Turtle] LIVING block consumed ", packet.getReadPos() - livingStart,
" bytes, readPos now=", packet.getReadPos());
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
block.orientation = packet.readFloat();
block.hasMovement = true;
LOG_DEBUG(" [Turtle] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")");
}
// Target GUID
if (updateFlags & UPDATEFLAG_HAS_TARGET) {
/*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
// Transport time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
/*uint32_t transportTime =*/ packet.readUInt32();
}
// Low GUID — Classic-style: 1×u32 (NOT TBC's 2×u32)
if (updateFlags & UPDATEFLAG_LOWGUID) {
/*uint32_t lowGuid =*/ packet.readUInt32();
}
// High GUID — 1×u32
if (updateFlags & UPDATEFLAG_HIGHGUID) {
/*uint32_t highGuid =*/ packet.readUInt32();
}
return true;
}
// ============================================================================
// Classic/Vanilla quest giver status
//
// Vanilla sends status as uint32 with different enum values:
// 0=NONE, 1=UNAVAILABLE, 2=CHAT, 3=INCOMPLETE, 4=REWARD_REP, 5=AVAILABLE
// WotLK uses uint8 with:
// 0=NONE, 1=UNAVAILABLE, 5=INCOMPLETE, 6=REWARD_REP, 7=AVAILABLE_LOW, 8=AVAILABLE, 10=REWARD
//
// Read uint32, translate to WotLK enum values.
// ============================================================================
uint8_t ClassicPacketParsers::readQuestGiverStatus(network::Packet& packet) {
uint32_t vanillaStatus = packet.readUInt32();
switch (vanillaStatus) {
case 0: return 0; // NONE
case 1: return 1; // UNAVAILABLE
case 2: return 0; // CHAT → NONE (no marker)
case 3: return 5; // INCOMPLETE → WotLK INCOMPLETE
case 4: return 6; // REWARD_REP → WotLK REWARD_REP
case 5: return 8; // AVAILABLE → WotLK AVAILABLE
case 6: return 10; // REWARD → WotLK REWARD
default: return 0;
}
}
} // namespace game
} // namespace wowee

View file

@ -440,8 +440,9 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa
}
if (!ok) {
LOG_ERROR("Failed to parse update block ", i + 1);
return false;
LOG_WARNING("Failed to parse update block ", i + 1, " of ", data.blockCount,
" — keeping ", data.blocks.size(), " parsed blocks");
break;
}
data.blocks.push_back(block);
}

View file

@ -2802,16 +2802,26 @@ network::Packet QuestgiverAcceptQuestPacket::build(uint64_t npcGuid, uint32_t qu
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST));
packet.writeUInt64(npcGuid);
packet.writeUInt32(questId);
packet.writeUInt32(0); // unused
return packet;
}
bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) {
if (packet.getSize() < 28) return false;
if (packet.getSize() < 20) return false;
data.npcGuid = packet.readUInt64();
// WotLK has informUnit(u64) before questId; Vanilla/TBC do not.
// Detect: try WotLK first (read informUnit + questId), then check if title
// string looks valid. If not, rewind and try vanilla (questId directly).
size_t preInform = packet.getReadPos();
/*informUnit*/ packet.readUInt64();
data.questId = packet.readUInt32();
data.title = packet.readString();
if (data.title.empty() || data.questId > 100000) {
// Likely vanilla format — rewind past informUnit
packet.setReadPos(preInform);
data.questId = packet.readUInt32();
data.title = packet.readString();
}
data.details = packet.readString();
data.objectives = packet.readString();

View file

@ -430,14 +430,26 @@ const ZoneInfo* ZoneManager::getZoneInfo(uint32_t zoneId) const {
return nullptr;
}
std::string ZoneManager::getRandomMusic(uint32_t zoneId) const {
std::string ZoneManager::getRandomMusic(uint32_t zoneId) {
auto it = zones.find(zoneId);
if (it == zones.end() || it->second.musicPaths.empty()) {
return "";
}
const auto& paths = it->second.musicPaths;
return paths[std::rand() % paths.size()];
if (paths.size() == 1) {
lastPlayedMusic_ = paths[0];
return paths[0];
}
// Avoid playing the same track back-to-back
std::string pick;
for (int attempts = 0; attempts < 5; ++attempts) {
pick = paths[std::rand() % paths.size()];
if (pick != lastPlayedMusic_) break;
}
lastPlayedMusic_ = pick;
return pick;
}
std::vector<std::string> ZoneManager::getAllMusicPaths() const {

View file

@ -2286,6 +2286,16 @@ void Renderer::update(float deltaTime) {
}
musicManager->update(deltaTime);
// When a track finishes, pick a new random track from the current zone
if (!musicManager->isPlaying() && !inTavern_ && !inBlacksmith_ &&
currentZoneId != 0 && musicSwitchCooldown_ <= 0.0f) {
std::string music = zoneManager->getRandomMusic(currentZoneId);
if (!music.empty()) {
playZoneMusic(music);
musicSwitchCooldown_ = 2.0f;
}
}
}
// Update performance HUD

View file

@ -1328,6 +1328,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
bool allowSpiritInteract = (gameHandler.isPlayerDead() || gameHandler.isPlayerGhost()) && isSpiritNpc();
if (!unit->isHostile() && (unit->isInteractable() || allowSpiritInteract)) {
gameHandler.interactWithNpc(target->getGuid());
} else if (unit->isHostile()) {
gameHandler.startAutoAttack(target->getGuid());
}
}
} else if (target->getType() == game::ObjectType::GAMEOBJECT) {
@ -2967,16 +2969,29 @@ GLuint GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) {
}
}
// Load Spell.dbc: field 133 = SpellIconID
// Load Spell.dbc: SpellIconID field
auto spellDbc = am->loadDBC("Spell.dbc");
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
if (spellDbc && spellDbc->isLoaded() && spellDbc->getFieldCount() > 133) {
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
uint32_t id = spellDbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
uint32_t iconId = spellDbc->getUInt32(i, spellL ? (*spellL)["IconID"] : 133);
if (id > 0 && iconId > 0) {
spellIconIds_[id] = iconId;
if (spellDbc && spellDbc->isLoaded()) {
uint32_t fieldCount = spellDbc->getFieldCount();
// Try expansion layout first
auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) {
spellIconIds_.clear();
if (iconField >= fieldCount) return;
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
uint32_t id = spellDbc->getUInt32(i, idField);
uint32_t iconId = spellDbc->getUInt32(i, iconField);
if (id > 0 && iconId > 0) {
spellIconIds_[id] = iconId;
}
}
};
if (spellL) {
tryLoadIcons((*spellL)["ID"], (*spellL)["IconID"]);
}
// Fallback to WotLK field 133 if expansion layout yielded nothing
if (spellIconIds_.empty() && fieldCount > 133) {
tryLoadIcons(0, 133);
}
}
}

View file

@ -127,8 +127,8 @@ void RealmScreen::render(auth::AuthHandler& authHandler) {
ImGui::TableSetColumnIndex(1);
if (realm.icon == 0) ImGui::Text("Normal");
else if (realm.icon == 1) ImGui::Text("PvP");
else if (realm.icon == 4) ImGui::Text("RP");
else if (realm.icon == 6) ImGui::Text("RP-PvP");
else if (realm.icon == 6) ImGui::Text("RP");
else if (realm.icon == 8) ImGui::Text("RP-PvP");
else ImGui::Text("Type %d", realm.icon);
// Population column
@ -136,8 +136,8 @@ void RealmScreen::render(auth::AuthHandler& authHandler) {
ImVec4 popColor = getPopulationColor(realm.population);
ImGui::PushStyleColor(ImGuiCol_Text, popColor);
if (realm.population < 0.5f) ImGui::Text("Low");
else if (realm.population < 1.0f) ImGui::Text("Medium");
else if (realm.population < 2.0f) ImGui::Text("High");
else if (realm.population < 1.5f) ImGui::Text("Medium");
else if (realm.population < 2.5f) ImGui::Text("High");
else ImGui::Text("Full");
ImGui::PopStyleColor();
@ -238,9 +238,9 @@ const char* RealmScreen::getRealmStatus(uint8_t flags) const {
ImVec4 RealmScreen::getPopulationColor(float population) const {
if (population < 0.5f) {
return ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - Low
} else if (population < 1.0f) {
} else if (population < 1.5f) {
return ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // Yellow - Medium
} else if (population < 2.0f) {
} else if (population < 2.5f) {
return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange - High
} else {
return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red - Full

View file

@ -29,28 +29,46 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
return;
}
// WoW 3.3.5a Spell.dbc fields (0-based):
// 0 = SpellID, 4 = Attributes, 133 = SpellIconID, 136 = SpellName_enUS, 153 = RankText_enUS
// Try expansion-specific layout first, then fall back to WotLK hardcoded indices
// if the DBC is from WotLK MPQs but the active expansion uses different field offsets.
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t spellId = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
if (spellId == 0) continue;
SpellInfo info;
info.spellId = spellId;
info.attributes = dbc->getUInt32(i, spellL ? (*spellL)["Attributes"] : 4);
info.iconId = dbc->getUInt32(i, spellL ? (*spellL)["IconID"] : 133);
info.name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
info.rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField,
uint32_t nameField, uint32_t rankField, const char* label) {
spellData.clear();
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t spellId = dbc->getUInt32(i, idField);
if (spellId == 0) continue;
if (!info.name.empty()) {
spellData[spellId] = std::move(info);
SpellInfo info;
info.spellId = spellId;
info.attributes = dbc->getUInt32(i, attrField);
info.iconId = dbc->getUInt32(i, iconField);
info.name = dbc->getString(i, nameField);
info.rank = dbc->getString(i, rankField);
if (!info.name.empty()) {
spellData[spellId] = std::move(info);
}
}
LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc (", label, ")");
};
// Try active expansion layout
if (spellL) {
tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"],
(*spellL)["Name"], (*spellL)["Rank"], "expansion layout");
}
dbcLoaded = true;
LOG_INFO("Spellbook: Loaded ", spellData.size(), " spells from Spell.dbc");
// If layout failed or loaded 0 spells, try WotLK hardcoded indices
// (binary DBC may be from WotLK MPQs regardless of active expansion)
if (spellData.empty() && fieldCount >= 200) {
LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)");
tryLoad(0, 4, 133, 136, 153, "WotLK fallback");
}
dbcLoaded = !spellData.empty();
}
void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {