Merge commit '32bb0becc8' into chore/game-screen-extract

This commit is contained in:
Paul 2026-03-31 19:51:37 +03:00
commit 43aecab1ef
145 changed files with 3237 additions and 2849 deletions

View file

@ -696,10 +696,14 @@ static int lua_UnitGroupRolesAssigned(lua_State* L) {
const auto& pd = gh->getPartyData();
for (const auto& m : pd.members) {
if (m.guid == guid) {
// WotLK roles bitmask: 0x02=Tank, 0x04=Healer, 0x08=DPS
if (m.roles & 0x02) { lua_pushstring(L, "TANK"); return 1; }
if (m.roles & 0x04) { lua_pushstring(L, "HEALER"); return 1; }
if (m.roles & 0x08) { lua_pushstring(L, "DAMAGER"); return 1; }
// WotLK LFG roles bitmask (from SMSG_GROUP_LIST / SMSG_LFG_ROLE_CHECK_UPDATE).
// Bit 0x01 = Leader (not a combat role), 0x02 = Tank, 0x04 = Healer, 0x08 = DPS.
constexpr uint8_t kRoleTank = 0x02;
constexpr uint8_t kRoleHealer = 0x04;
constexpr uint8_t kRoleDamager = 0x08;
if (m.roles & kRoleTank) { lua_pushstring(L, "TANK"); return 1; }
if (m.roles & kRoleHealer) { lua_pushstring(L, "HEALER"); return 1; }
if (m.roles & kRoleDamager) { lua_pushstring(L, "DAMAGER"); return 1; }
break;
}
}

View file

@ -921,7 +921,8 @@ void AmbientSoundManager::updateBellTolls(float deltaTime) {
static_cast<int>(currentCity_));
}
// Play remaining tolls with 1.5 second delay between each
// Play remaining tolls with 1.5s spacing — matches retail WoW bell cadence
// (long enough for each toll to ring out before the next begins)
if (remainingTolls_ > 0) {
bellTollDelay_ += deltaTime;

View file

@ -8,6 +8,7 @@
#include <cstring>
#include <cstdlib>
#include <iterator>
#include <memory>
#include <unordered_map>
@ -98,10 +99,13 @@ static bool decodeWavCached(const std::vector<uint8_t>& wavData, DecodedWavCache
entry.sampleRate = sampleRate;
entry.frames = framesRead;
entry.pcmData = pcmData;
// Evict oldest half when cache grows too large (keeps ~128 most-recent sounds)
if (gDecodedWavCache.size() >= 256) {
// Evict oldest half when cache grows too large. 256 entries ≈ 50-100 MB of decoded
// PCM data depending on file lengths; halving keeps memory bounded while retaining
// recently-heard sounds (footsteps, UI clicks, combat hits) for instant replay.
constexpr size_t kMaxCachedSounds = 256;
if (gDecodedWavCache.size() >= kMaxCachedSounds) {
auto it = gDecodedWavCache.begin();
for (size_t n = gDecodedWavCache.size() / 2; n > 0; --n, ++it) {}
std::advance(it, gDecodedWavCache.size() / 2);
gDecodedWavCache.erase(gDecodedWavCache.begin(), it);
}
gDecodedWavCache.emplace(key, entry);
@ -239,7 +243,9 @@ bool AudioEngine::playSound2D(const std::vector<uint8_t>& wavData, float volume,
decoded.pcmData->data(),
nullptr // No custom allocator
);
bufferConfig.sampleRate = decoded.sampleRate; // Critical: preserve original sample rate!
// Must set explicitly — miniaudio defaults to device sample rate, which causes
// pitch distortion if it differs from the file's native rate (e.g. 22050 vs 44100 Hz).
bufferConfig.sampleRate = decoded.sampleRate;
ma_audio_buffer* audioBuffer = static_cast<ma_audio_buffer*>(std::malloc(sizeof(ma_audio_buffer)));
if (!audioBuffer) return false;
@ -394,7 +400,9 @@ bool AudioEngine::playSound3D(const std::vector<uint8_t>& wavData, const glm::ve
decoded.pcmData->data(),
nullptr
);
bufferConfig.sampleRate = decoded.sampleRate; // Critical: preserve original sample rate!
// Must set explicitly — miniaudio defaults to device sample rate, which causes
// pitch distortion if it differs from the file's native rate (e.g. 22050 vs 44100 Hz).
bufferConfig.sampleRate = decoded.sampleRate;
ma_audio_buffer* audioBuffer = static_cast<ma_audio_buffer*>(std::malloc(sizeof(ma_audio_buffer)));
if (!audioBuffer) return false;

View file

@ -13,6 +13,12 @@
namespace wowee {
namespace auth {
// WoW login security flags (CMD_AUTH_LOGON_CHALLENGE response, securityFlags byte).
// Multiple flags can be set simultaneously; the client must satisfy all of them.
constexpr uint8_t kSecurityFlagPin = 0x01; // PIN grid challenge
constexpr uint8_t kSecurityFlagMatrixCard = 0x02; // Matrix card (unused by most servers)
constexpr uint8_t kSecurityFlagAuthenticator = 0x04; // TOTP authenticator token
AuthHandler::AuthHandler() {
LOG_DEBUG("AuthHandler created");
}
@ -196,9 +202,9 @@ void AuthHandler::handleLogonChallengeResponse(network::Packet& packet) {
if (response.securityFlags != 0) {
LOG_WARNING("Server sent security flags: 0x", std::hex, static_cast<int>(response.securityFlags), std::dec);
if (response.securityFlags & 0x01) LOG_WARNING(" PIN required");
if (response.securityFlags & 0x02) LOG_WARNING(" Matrix card required (not supported)");
if (response.securityFlags & 0x04) LOG_WARNING(" Authenticator required (not supported)");
if (response.securityFlags & kSecurityFlagPin) LOG_WARNING(" PIN required");
if (response.securityFlags & kSecurityFlagMatrixCard) LOG_WARNING(" Matrix card required (not supported)");
if (response.securityFlags & kSecurityFlagAuthenticator) LOG_WARNING(" Authenticator required (not supported)");
}
LOG_INFO("Challenge: N=", response.N.size(), "B g=", response.g.size(), "B salt=",
@ -209,7 +215,7 @@ void AuthHandler::handleLogonChallengeResponse(network::Packet& packet) {
securityFlags_ = response.securityFlags;
checksumSalt_ = response.checksumSalt;
if (securityFlags_ & 0x01) {
if (securityFlags_ & kSecurityFlagPin) {
pinGridSeed_ = response.pinGridSeed;
pinServerSalt_ = response.pinSalt;
}
@ -217,8 +223,8 @@ void AuthHandler::handleLogonChallengeResponse(network::Packet& packet) {
setState(AuthState::CHALLENGE_RECEIVED);
// If a security code is required, wait for user input.
if (((securityFlags_ & 0x04) || (securityFlags_ & 0x01)) && pendingSecurityCode_.empty()) {
setState((securityFlags_ & 0x04) ? AuthState::AUTHENTICATOR_REQUIRED : AuthState::PIN_REQUIRED);
if (((securityFlags_ & kSecurityFlagAuthenticator) || (securityFlags_ & kSecurityFlagPin)) && pendingSecurityCode_.empty()) {
setState((securityFlags_ & kSecurityFlagAuthenticator) ? AuthState::AUTHENTICATOR_REQUIRED : AuthState::PIN_REQUIRED);
return;
}
@ -238,7 +244,7 @@ void AuthHandler::sendLogonProof() {
std::array<uint8_t, 20> crcHash{};
const std::array<uint8_t, 20>* crcHashPtr = nullptr;
if (securityFlags_ & 0x01) {
if (securityFlags_ & kSecurityFlagPin) {
try {
PinProof proof = computePinProof(pendingSecurityCode_, pinGridSeed_, pinServerSalt_);
pinClientSalt = proof.clientSalt;
@ -299,7 +305,7 @@ void AuthHandler::sendLogonProof() {
auto packet = LogonProofPacket::build(A, M1, securityFlags_, crcHashPtr, pinClientSaltPtr, pinHashPtr);
socket->send(packet);
if (securityFlags_ & 0x04) {
if (securityFlags_ & kSecurityFlagAuthenticator) {
// TrinityCore-style Google Authenticator token: send immediately after proof.
const std::string token = pendingSecurityCode_;
auto tokPkt = AuthenticatorTokenPacket::build(token);

View file

@ -136,6 +136,8 @@ std::vector<uint8_t> BigNum::toArray(bool littleEndian, int minSize) const {
std::string BigNum::toHex() const {
char* hex = BN_bn2hex(bn);
// BN_bn2hex returns nullptr on allocation failure
if (!hex) return "(null)";
std::string result(hex);
OPENSSL_free(hex);
return result;
@ -143,6 +145,7 @@ std::string BigNum::toHex() const {
std::string BigNum::toDecimal() const {
char* dec = BN_bn2dec(bn);
if (!dec) return "(null)";
std::string result(dec);
OPENSSL_free(dec);
return result;

View file

@ -63,7 +63,9 @@ static std::vector<uint8_t> randomizePinDigits(const std::string& pinDigits,
if (idx == 0xFF) {
throw std::runtime_error("PIN digit not found in remapped grid");
}
out.push_back(static_cast<uint8_t>(idx + 0x30)); // ASCII '0'+idx
// PIN grid encodes each digit as its ASCII character ('0'..'9') for the
// server-side HMAC computation — this matches Blizzard's auth protocol.
out.push_back(static_cast<uint8_t>(idx + '0'));
}
return out;

View file

@ -129,11 +129,15 @@ std::vector<uint8_t> SRP::computeAuthHash(const std::string& username,
void SRP::computeClientEphemeral() {
LOG_DEBUG("Computing client ephemeral");
// Generate random private ephemeral a (19 bytes = 152 bits)
// Keep trying until we get a valid A
// Generate random private ephemeral a (19 bytes = 152 bits).
// WoW SRP-6a requires A != 0 mod N; in practice this almost never fails
// (probability ≈ 2^-152), but we retry to be safe. 100 attempts is far more
// than needed — if it fails, the RNG is broken.
static constexpr int kMaxEphemeralAttempts = 100;
static constexpr int kEphemeralBytes = 19; // 152 bits — matches Blizzard client
int attempts = 0;
while (attempts < 100) {
a = BigNum::fromRandom(19);
while (attempts < kMaxEphemeralAttempts) {
a = BigNum::fromRandom(kEphemeralBytes);
// A = g^a mod N
A = g.modPow(a, N);
@ -146,8 +150,8 @@ void SRP::computeClientEphemeral() {
attempts++;
}
if (attempts >= 100) {
LOG_ERROR("Failed to generate valid client ephemeral after 100 attempts!");
if (attempts >= kMaxEphemeralAttempts) {
LOG_ERROR("Failed to generate valid client ephemeral after ", kMaxEphemeralAttempts, " attempts!");
}
}

View file

@ -48,7 +48,6 @@
#include "pipeline/dbc_layout.hpp"
#include <SDL2/SDL.h>
// GL/glew.h removed — Vulkan migration Phase 1
#include <cstdlib>
#include <climits>
#include <algorithm>
@ -256,7 +255,6 @@ bool Application::initialize() {
// Create subsystems
authHandler = std::make_unique<auth::AuthHandler>();
gameHandler = std::make_unique<game::GameHandler>();
world = std::make_unique<game::World>();
// Create and initialize expansion registry
@ -268,6 +266,14 @@ bool Application::initialize() {
// Create asset manager
assetManager = std::make_unique<pipeline::AssetManager>();
// Populate game services — all subsystems now available
gameServices_.renderer = renderer.get();
gameServices_.assetManager = assetManager.get();
gameServices_.expansionRegistry = expansionRegistry_.get();
// Create game handler with explicit service dependencies
gameHandler = std::make_unique<game::GameHandler>(gameServices_);
// Try to get WoW data path from environment variable
const char* dataPathEnv = std::getenv("WOW_DATA_PATH");
std::string dataPath = dataPathEnv ? dataPathEnv : "./Data";
@ -914,6 +920,7 @@ void Application::shutdown() {
world.reset();
LOG_WARNING("Resetting gameHandler...");
gameHandler.reset();
gameServices_ = {};
LOG_WARNING("Resetting authHandler...");
authHandler.reset();
LOG_WARNING("Resetting assetManager...");
@ -5657,6 +5664,8 @@ void Application::buildCreatureDisplayLookups() {
gryphonDisplayId_ = resolveDisplayIdForExactPath("Creature\\Gryphon\\Gryphon.m2");
wyvernDisplayId_ = resolveDisplayIdForExactPath("Creature\\Wyvern\\Wyvern.m2");
gameServices_.gryphonDisplayId = gryphonDisplayId_;
gameServices_.wyvernDisplayId = wyvernDisplayId_;
LOG_INFO("Taxi mount displayIds: gryphon=", gryphonDisplayId_, " wyvern=", wyvernDisplayId_);
// CharHairGeosets.dbc: maps (race, sex, hairStyleId) → skinSectionId for hair mesh

View file

@ -25,7 +25,10 @@ void Input::update() {
Uint32 mouseState = SDL_GetMouseState(&mouseX, &mouseY);
mousePosition = glm::vec2(static_cast<float>(mouseX), static_cast<float>(mouseY));
for (int i = 0; i < NUM_MOUSE_BUTTONS; ++i) {
// SDL_BUTTON(x) is defined as (1 << (x-1)), so button indices are 1-based.
// SDL_BUTTON(0) is undefined behavior (negative shift). Start at 1.
currentMouseState[0] = false;
for (int i = 1; i < NUM_MOUSE_BUTTONS; ++i) {
currentMouseState[i] = (mouseState & SDL_BUTTON(i)) != 0;
}

View file

@ -23,9 +23,10 @@ size_t readMemAvailableBytesFromProc() {
std::string line;
while (std::getline(meminfo, line)) {
// Format: "MemAvailable: 123456789 kB"
// /proc/meminfo format: "MemAvailable: 123456789 kB"
static constexpr size_t kFieldPrefixLen = 13; // strlen("MemAvailable:")
if (line.rfind("MemAvailable:", 0) != 0) continue;
std::istringstream iss(line.substr(13));
std::istringstream iss(line.substr(kFieldPrefixLen));
size_t kb = 0;
iss >> kb;
if (kb > 0) return kb * 1024ull;
@ -42,13 +43,18 @@ MemoryMonitor& MemoryMonitor::getInstance() {
}
void MemoryMonitor::initialize() {
constexpr size_t kOneGB = 1024ull * 1024 * 1024;
// Fallback if OS API unavailable — 16 GB is a safe conservative estimate
// that prevents over-aggressive asset caching on unknown hardware.
constexpr size_t kFallbackRAM = 16 * kOneGB;
#ifdef _WIN32
ULONGLONG totalKB = 0;
if (GetPhysicallyInstalledSystemMemory(&totalKB)) {
totalRAM_ = static_cast<size_t>(totalKB) * 1024ull;
LOG_INFO("System RAM detected: ", totalRAM_ / (1024 * 1024 * 1024), " GB");
LOG_INFO("System RAM detected: ", totalRAM_ / kOneGB, " GB");
} else {
totalRAM_ = 16ull * 1024 * 1024 * 1024;
totalRAM_ = kFallbackRAM;
LOG_WARNING("Could not detect system RAM, assuming 16GB");
}
#elif defined(__APPLE__)
@ -56,19 +62,18 @@ void MemoryMonitor::initialize() {
size_t len = sizeof(physmem);
if (sysctlbyname("hw.memsize", &physmem, &len, nullptr, 0) == 0) {
totalRAM_ = static_cast<size_t>(physmem);
LOG_INFO("System RAM detected: ", totalRAM_ / (1024 * 1024 * 1024), " GB");
LOG_INFO("System RAM detected: ", totalRAM_ / kOneGB, " GB");
} else {
totalRAM_ = 16ull * 1024 * 1024 * 1024;
totalRAM_ = kFallbackRAM;
LOG_WARNING("Could not detect system RAM, assuming 16GB");
}
#else
struct sysinfo info;
if (sysinfo(&info) == 0) {
totalRAM_ = static_cast<size_t>(info.totalram) * info.mem_unit;
LOG_INFO("System RAM detected: ", totalRAM_ / (1024 * 1024 * 1024), " GB");
LOG_INFO("System RAM detected: ", totalRAM_ / kOneGB, " GB");
} else {
// Fallback: assume 16GB
totalRAM_ = 16ull * 1024 * 1024 * 1024;
totalRAM_ = kFallbackRAM;
LOG_WARNING("Could not detect system RAM, assuming 16GB");
}
#endif

View file

@ -103,6 +103,8 @@ bool Window::initialize() {
return true;
}
// Shutdown progress uses LOG_WARNING so these messages are always visible even at
// default log levels — useful for diagnosing hangs or crashes during teardown.
void Window::shutdown() {
LOG_WARNING("Window::shutdown - vkContext...");
if (vkContext) {

View file

@ -435,11 +435,7 @@ void ChatHandler::handleChannelNotify(network::Packet& packet) {
switch (data.notifyType) {
case ChannelNotifyType::YOU_JOINED: {
bool found = false;
for (const auto& ch : joinedChannels_) {
if (ch == data.channelName) { found = true; break; }
}
if (!found) {
if (std::find(joinedChannels_.begin(), joinedChannels_.end(), data.channelName) == joinedChannels_.end()) {
joinedChannels_.push_back(data.channelName);
}
MessageChatData msg;
@ -461,11 +457,9 @@ void ChatHandler::handleChannelNotify(network::Packet& packet) {
break;
}
case ChannelNotifyType::PLAYER_ALREADY_MEMBER: {
bool found = false;
for (const auto& ch : joinedChannels_) {
if (ch == data.channelName) { found = true; break; }
}
if (!found) {
// Server confirms we're in this channel but our local list doesn't have it yet —
// can happen after reconnect or if the join notification was missed.
if (std::find(joinedChannels_.begin(), joinedChannels_.end(), data.channelName) == joinedChannels_.end()) {
joinedChannels_.push_back(data.channelName);
LOG_INFO("Already in channel: ", data.channelName);
}

View file

@ -451,7 +451,7 @@ void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) {
}
// Play combat sounds via CombatSoundManager + character vocalizations
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* csm = renderer->getCombatSoundManager()) {
auto weaponSize = audio::CombatSoundManager::WeaponSize::MEDIUM;
if (data.isMiss()) {
@ -1362,6 +1362,7 @@ void CombatHandler::togglePvp() {
auto entity = owner_.getEntityManager().getEntity(owner_.playerGuid);
bool currentlyPvp = false;
if (entity) {
// UNIT_FIELD_FLAGS (index 59), bit 0x1000 = UNIT_FLAG_PVP
currentlyPvp = (entity->getField(59) & 0x00001000) != 0;
}
if (currentlyPvp) {

View file

@ -1015,11 +1015,14 @@ bool EntityController::applyPlayerStatFields(const std::map<uint16_t, uint32_t>&
owner_.playerSpellDmgBonus_[key - pfi.spDmg1] = static_cast<int32_t>(val);
}
else if (pfi.healBonus != 0xFFFF && key == pfi.healBonus) { owner_.playerHealBonus_ = static_cast<int32_t>(val); }
else if (pfi.blockPct != 0xFFFF && key == pfi.blockPct) { std::memcpy(&owner_.playerBlockPct_, &val, 4); }
else if (pfi.dodgePct != 0xFFFF && key == pfi.dodgePct) { std::memcpy(&owner_.playerDodgePct_, &val, 4); }
else if (pfi.parryPct != 0xFFFF && key == pfi.parryPct) { std::memcpy(&owner_.playerParryPct_, &val, 4); }
else if (pfi.critPct != 0xFFFF && key == pfi.critPct) { std::memcpy(&owner_.playerCritPct_, &val, 4); }
else if (pfi.rangedCritPct != 0xFFFF && key == pfi.rangedCritPct) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); }
// Percentage stats are stored as IEEE 754 floats packed into uint32 update fields.
// memcpy reinterprets the bits; clamp to [0..100] to guard against NaN/Inf from
// corrupted packets reaching the UI (display-only, no gameplay logic depends on these).
else if (pfi.blockPct != 0xFFFF && key == pfi.blockPct) { std::memcpy(&owner_.playerBlockPct_, &val, 4); owner_.playerBlockPct_ = std::clamp(owner_.playerBlockPct_, 0.0f, 100.0f); }
else if (pfi.dodgePct != 0xFFFF && key == pfi.dodgePct) { std::memcpy(&owner_.playerDodgePct_, &val, 4); owner_.playerDodgePct_ = std::clamp(owner_.playerDodgePct_, 0.0f, 100.0f); }
else if (pfi.parryPct != 0xFFFF && key == pfi.parryPct) { std::memcpy(&owner_.playerParryPct_, &val, 4); owner_.playerParryPct_ = std::clamp(owner_.playerParryPct_, 0.0f, 100.0f); }
else if (pfi.critPct != 0xFFFF && key == pfi.critPct) { std::memcpy(&owner_.playerCritPct_, &val, 4); owner_.playerCritPct_ = std::clamp(owner_.playerCritPct_, 0.0f, 100.0f); }
else if (pfi.rangedCritPct != 0xFFFF && key == pfi.rangedCritPct) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); owner_.playerRangedCritPct_ = std::clamp(owner_.playerRangedCritPct_, 0.0f, 100.0f); }
else if (pfi.sCrit1 != 0xFFFF && key >= pfi.sCrit1 && key < pfi.sCrit1 + 7) {
std::memcpy(&owner_.playerSpellCritPct_[key - pfi.sCrit1], &val, 4);
}
@ -1072,6 +1075,8 @@ void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType,
float unitScale = 1.0f;
uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
if (scaleIdx != 0xFFFF) {
// raw == 0 means the field was never populated (IEEE 754 0.0f is all-zero bits).
// Keep the default 1.0f rather than setting scale to 0 and making the entity invisible.
uint32_t raw = entity->getField(scaleIdx);
if (raw != 0) {
std::memcpy(&unitScale, &raw, sizeof(float));

View file

@ -58,7 +58,14 @@ std::string jsonValue(const std::string& json, const std::string& key) {
int jsonInt(const std::string& json, const std::string& key, int def = 0) {
std::string v = jsonValue(json, key);
if (v.empty()) return def;
try { return std::stoi(v); } catch (...) { return def; }
try {
return std::stoi(v);
} catch (...) {
// Non-numeric value for an integer field — fall back to default rather than
// crashing, but log it so malformed expansion.json files are diagnosable.
wowee::core::Logger::getInstance().warning("jsonInt: failed to parse '", key, "' value '", v, "', using default ", def);
return def;
}
}
std::vector<uint32_t> jsonUintArray(const std::string& json, const std::string& key) {

View file

@ -1,4 +1,5 @@
#include "game/game_handler.hpp"
#include "game/game_utils.hpp"
#include "game/chat_handler.hpp"
#include "game/movement_handler.hpp"
#include "game/combat_handler.hpp"
@ -92,23 +93,6 @@ bool isAuthCharPipelineOpcode(LogicalOpcode op) {
namespace {
bool isActiveExpansion(const char* expansionId) {
auto& app = core::Application::getInstance();
auto* registry = app.getExpansionRegistry();
if (!registry) return false;
auto* profile = registry->getActive();
if (!profile) return false;
return profile->id == expansionId;
}
bool isClassicLikeExpansion() {
return isActiveExpansion("classic") || isActiveExpansion("turtle");
}
bool isPreWotlk() {
return isClassicLikeExpansion() || isActiveExpansion("tbc");
}
bool envFlagEnabled(const char* key, bool defaultValue = false) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
@ -615,7 +599,7 @@ static QuestQueryRewards tryParseQuestRewards(const std::vector<uint8_t>& data,
template<typename ManagerGetter, typename Callback>
void GameHandler::withSoundManager(ManagerGetter getter, Callback cb) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = services_.renderer) {
if (auto* mgr = (renderer->*getter)()) cb(mgr);
}
}
@ -639,7 +623,8 @@ void GameHandler::registerWorldHandler(LogicalOpcode op, void (GameHandler::*han
};
}
GameHandler::GameHandler() {
GameHandler::GameHandler(GameServices& services)
: services_(services) {
LOG_DEBUG("GameHandler created");
setActiveOpcodeTable(&opcodeTable_);
@ -819,7 +804,7 @@ void GameHandler::resetDbcCaches() {
// Clear the AssetManager DBC file cache so that expansion-specific DBCs
// (CharSections, ItemDisplayInfo, etc.) are reloaded from the new expansion's
// MPQ files instead of returning stale data from a previous session/expansion.
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (am) {
am->clearDBCCache();
}
@ -1213,7 +1198,7 @@ void GameHandler::updateTimers(float deltaTime) {
}
if (!alreadyAnnounced && pendingLootMoneyAmount_ > 0) {
addSystemChatMessage("Looted: " + formatCopperAmount(pendingLootMoneyAmount_));
auto* renderer = core::Application::getInstance().getRenderer();
auto* renderer = services_.renderer;
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
if (pendingLootMoneyAmount_ >= 10000) {
@ -1362,15 +1347,21 @@ void GameHandler::update(float deltaTime) {
addSystemChatMessage("Interrupted.");
}
// Check if client-side cast timer expired (tick-down is in SpellHandler::updateTimers).
// SMSG_SPELL_GO normally clears casting, but GO interaction casts are client-timed
// and need this fallback to trigger the loot/use action.
// Two paths depending on whether this is a GO interaction cast:
if (spellHandler_ && spellHandler_->casting_ && spellHandler_->castTimeRemaining_ <= 0.0f) {
if (pendingGameObjectInteractGuid_ != 0) {
uint64_t interactGuid = pendingGameObjectInteractGuid_;
// GO interaction cast: do NOT call resetCastState() here. The server
// sends SMSG_SPELL_GO when the cast completes server-side (~50-200ms
// after the client timer expires due to float precision/frame timing).
// handleSpellGo checks `wasInTimedCast = casting_ && spellId == currentCastSpellId_`
// — if we clear those fields now, wasInTimedCast is false and the loot
// path (CMSG_LOOT via lastInteractedGoGuid_) never fires.
// Let the cast bar sit at 100% until SMSG_SPELL_GO arrives to clean up.
pendingGameObjectInteractGuid_ = 0;
performGameObjectInteractionNow(interactGuid);
} else {
// Regular cast with no GO pending: clean up immediately.
spellHandler_->resetCastState();
}
spellHandler_->resetCastState();
}
// Unit cast states and spell cooldowns are ticked by SpellHandler::updateTimers()
@ -3099,7 +3090,7 @@ void GameHandler::registerOpcodeHandlers() {
uint64_t impTargetGuid = packet.readUInt64();
uint32_t impVisualId = packet.readUInt32();
if (impVisualId == 0) return;
auto* renderer = core::Application::getInstance().getRenderer();
auto* renderer = services_.renderer;
if (!renderer) return;
glm::vec3 spawnPos;
if (impTargetGuid == playerGuid) {
@ -6108,14 +6099,24 @@ void GameHandler::interactWithNpc(uint64_t guid) {
}
void GameHandler::interactWithGameObject(uint64_t guid) {
if (guid == 0) return;
if (!isInWorld()) return;
LOG_WARNING("[GO-DIAG] interactWithGameObject called: guid=0x", std::hex, guid, std::dec);
if (guid == 0) { LOG_WARNING("[GO-DIAG] BLOCKED: guid==0"); return; }
if (!isInWorld()) { LOG_WARNING("[GO-DIAG] BLOCKED: not in world"); return; }
// Do not overlap an actual spell cast.
if (spellHandler_ && spellHandler_->casting_ && spellHandler_->currentCastSpellId_ != 0) return;
if (spellHandler_ && spellHandler_->casting_ && spellHandler_->currentCastSpellId_ != 0) {
LOG_WARNING("[GO-DIAG] BLOCKED: already casting spellId=", spellHandler_->currentCastSpellId_);
return;
}
// Always clear melee intent before GO interactions.
stopAutoAttack();
// Interact immediately; server drives any real cast/channel feedback.
pendingGameObjectInteractGuid_ = 0;
// Set the pending GO guid so that:
// 1. cancelCast() won't send CMSG_CANCEL_CAST for GO-triggered casts
// (e.g., "Opening" on a quest chest) — without this, any movement
// during the cast cancels it server-side and quest credit is lost.
// 2. The cast-completion fallback in update() can call
// performGameObjectInteractionNow after the cast timer expires.
// 3. isGameObjectInteractionCasting() returns true during GO casts.
pendingGameObjectInteractGuid_ = guid;
performGameObjectInteractionNow(guid);
}
@ -6220,10 +6221,13 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
lastInteractedGoGuid_ = guid;
if (chestLike) {
// Chest-like GOs also need a CMSG_LOOT to open the loot window.
// Sent in the same frame: USE transitions the GO to lootable state,
// then LOOT requests the contents.
lootTarget(guid);
// Don't send CMSG_LOOT immediately — the server may start a timed cast
// (e.g., "Opening") and the GO isn't lootable until the cast finishes.
// Sending LOOT prematurely gets an empty response or is silently dropped,
// which can interfere with the server's loot state machine.
// Instead, handleSpellGo will send LOOT after the cast completes
// (using lastInteractedGoGuid_ set above). For instant-open chests
// (no cast), the server sends SMSG_LOOT_RESPONSE directly after USE.
} else if (isMailbox) {
LOG_INFO("Mailbox interaction: opening mail UI and requesting mail list");
mailboxGuid_ = guid;
@ -6367,7 +6371,7 @@ void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) {
uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0;
if (slotIndex < 0) return 0;
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
uint64_t bagGuid = equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex];
if (bagGuid == 0) return 0;
auto it = containerContents_.find(bagGuid);
if (it == containerContents_.end()) return 0;
@ -7249,7 +7253,7 @@ void GameHandler::loadTitleNameCache() const {
if (titleNameCacheLoaded_) return;
titleNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("CharTitles.dbc");
@ -7301,7 +7305,7 @@ void GameHandler::loadAchievementNameCache() {
if (achievementNameCacheLoaded_) return;
achievementNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("Achievement.dbc");
@ -7386,7 +7390,7 @@ void GameHandler::loadFactionNameCache() const {
if (factionNameCacheLoaded_) return;
factionNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("Faction.dbc");
@ -7487,7 +7491,7 @@ void GameHandler::loadAreaNameCache() const {
if (areaNameCacheLoaded_) return;
areaNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("WorldMapArea.dbc");
@ -7522,7 +7526,7 @@ void GameHandler::loadMapNameCache() const {
if (mapNameCacheLoaded_) return;
mapNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("Map.dbc");
@ -7555,7 +7559,7 @@ void GameHandler::loadLfgDungeonDbc() const {
if (lfgDungeonNameCacheLoaded_) return;
lfgDungeonNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = services_.assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("LFGDungeons.dbc");

View file

@ -239,13 +239,13 @@ std::vector<Inventory::SwapOp> Inventory::computeSortSwaps() const {
entries.reserve(BACKPACK_SLOTS + NUM_BAG_SLOTS * MAX_BAG_SIZE);
for (int i = 0; i < BACKPACK_SLOTS; ++i) {
entries.push_back({0xFF, static_cast<uint8_t>(23 + i),
entries.push_back({0xFF, static_cast<uint8_t>(NUM_EQUIP_SLOTS + i),
backpack[i].item.itemId, backpack[i].item.quality,
backpack[i].item.stackCount});
}
for (int b = 0; b < NUM_BAG_SLOTS; ++b) {
for (int s = 0; s < bags[b].size; ++s) {
entries.push_back({static_cast<uint8_t>(19 + b), static_cast<uint8_t>(s),
entries.push_back({static_cast<uint8_t>(FIRST_BAG_EQUIP_SLOT + b), static_cast<uint8_t>(s),
bags[b].slots[s].item.itemId, bags[b].slots[s].item.quality,
bags[b].slots[s].item.stackCount});
}

View file

@ -70,7 +70,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
}
if (!alreadyAnnounced) {
owner_.addSystemChatMessage("Looted: " + formatCopperAmount(amount));
auto* renderer = core::Application::getInstance().getRenderer();
auto* renderer = owner_.services().renderer;
if (renderer) {
if (auto* sfx = renderer->getUiSoundManager()) {
if (amount >= 10000) sfx->playLootCoinLarge();
@ -222,7 +222,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
std::string msg = "Received item: " + link;
if (count > 1) msg += " x" + std::to_string(count);
owner_.addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playLootItem();
}
@ -253,7 +253,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
" result=", static_cast<int>(result));
if (result == 0) {
pendingSellToBuyback_.erase(itemGuid);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playDropOnGround();
}
@ -295,7 +295,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error";
owner_.addUIError(std::string("Sell failed: ") + msg);
owner_.addSystemChatMessage(std::string("Sell failed: ") + msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playError();
}
@ -392,7 +392,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ").";
owner_.addUIError(msg);
owner_.addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playError();
}
@ -450,7 +450,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
}
owner_.addUIError(msg);
owner_.addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playError();
}
@ -474,7 +474,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) {
std::string msg = "Purchased: " + buildItemLink(pendingBuyItemId_, buyQuality, itemLabel);
if (itemCount > 1) msg += " x" + std::to_string(itemCount);
owner_.addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playPickupBag();
}
@ -695,6 +695,9 @@ void InventoryHandler::handleLootResponse(network::Packet& packet) {
const bool wotlkLoot = isActiveExpansion("wotlk");
if (!LootResponseParser::parse(packet, currentLoot_, wotlkLoot)) return;
const bool hasLoot = !currentLoot_.items.empty() || currentLoot_.gold > 0;
LOG_WARNING("[GO-DIAG] SMSG_LOOT_RESPONSE: guid=0x", std::hex, currentLoot_.lootGuid, std::dec,
" items=", currentLoot_.items.size(), " gold=", currentLoot_.gold,
" hasLoot=", hasLoot);
if (!hasLoot && owner_.isCasting() && owner_.getCurrentCastSpellId() != 0 && lastInteractedGoGuid_ != 0) {
LOG_DEBUG("Ignoring empty SMSG_LOOT_RESPONSE during gather cast");
return;
@ -763,7 +766,7 @@ void InventoryHandler::handleLootRemoved(network::Packet& packet) {
std::string msgStr = "Looted: " + link;
if (it->count > 1) msgStr += " x" + std::to_string(it->count);
owner_.addSystemChatMessage(msgStr);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playLootItem();
}
@ -978,7 +981,7 @@ void InventoryHandler::sellItemInBag(int bagIndex, int slotIndex) {
}
uint64_t itemGuid = 0;
uint64_t bagGuid = owner_.equipSlotGuids_[19 + bagIndex];
uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex];
if (bagGuid != 0) {
auto it = owner_.containerContents_.find(bagGuid);
if (it != owner_.containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
@ -1044,7 +1047,7 @@ void InventoryHandler::autoEquipItemBySlot(int backpackIndex) {
if (slot.empty()) return;
if (owner_.state == WorldState::IN_WORLD && owner_.socket) {
auto packet = AutoEquipItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex));
auto packet = AutoEquipItemPacket::build(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex));
owner_.socket->send(packet);
}
}
@ -1055,7 +1058,7 @@ void InventoryHandler::autoEquipItemInBag(int bagIndex, int slotIndex) {
if (owner_.state == WorldState::IN_WORLD && owner_.socket) {
auto packet = AutoEquipItemPacket::build(
static_cast<uint8_t>(19 + bagIndex), static_cast<uint8_t>(slotIndex));
static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex), static_cast<uint8_t>(slotIndex));
owner_.socket->send(packet);
}
}
@ -1084,8 +1087,8 @@ void InventoryHandler::useItemBySlot(int backpackIndex) {
" spellId=", useSpellId, " spellCount=", info->spells.size());
}
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId);
? owner_.packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId);
owner_.socket->send(packet);
} else if (itemGuid == 0) {
LOG_WARNING("useItemBySlot: itemGuid=0 for item='", slot.item.name,
@ -1101,7 +1104,7 @@ void InventoryHandler::useItemInBag(int bagIndex, int slotIndex) {
if (slot.empty()) return;
uint64_t itemGuid = 0;
uint64_t bagGuid = owner_.equipSlotGuids_[19 + bagIndex];
uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex];
if (bagGuid != 0) {
auto it = owner_.containerContents_.find(bagGuid);
if (it != owner_.containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
@ -1125,7 +1128,7 @@ void InventoryHandler::useItemInBag(int bagIndex, int slotIndex) {
}
}
}
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
uint8_t wowBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex);
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
@ -1142,8 +1145,8 @@ void InventoryHandler::openItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return;
if (owner_.inventory.getBackpackSlot(backpackIndex).empty()) return;
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
auto packet = OpenItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex));
LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex));
auto packet = OpenItemPacket::build(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex));
LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (Inventory::NUM_EQUIP_SLOTS + backpackIndex));
owner_.socket->send(packet);
}
@ -1152,7 +1155,7 @@ void InventoryHandler::openItemInBag(int bagIndex, int slotIndex) {
if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return;
if (owner_.inventory.getBagSlot(bagIndex, slotIndex).empty()) return;
if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return;
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
uint8_t wowBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex);
auto packet = OpenItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex));
LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex);
owner_.socket->send(packet);
@ -1178,7 +1181,7 @@ void InventoryHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count)
int freeBp = owner_.inventory.findFreeBackpackSlot();
if (freeBp >= 0) {
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeBp);
uint8_t dstSlot = static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + freeBp);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
@ -1189,7 +1192,7 @@ void InventoryHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count)
int bagSize = owner_.inventory.getBagSize(b);
for (int s = 0; s < bagSize; s++) {
if (owner_.inventory.getBagSlot(b, s).empty()) {
uint8_t dstBag = static_cast<uint8_t>(19 + b);
uint8_t dstBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + b);
uint8_t dstSlot = static_cast<uint8_t>(s);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=", (int)dstBag,
@ -1224,8 +1227,8 @@ void InventoryHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) {
owner_.inventory.swapBagContents(srcBagIndex, dstBagIndex);
if (owner_.socket && owner_.socket->isConnected()) {
uint8_t srcSlot = static_cast<uint8_t>(19 + srcBagIndex);
uint8_t dstSlot = static_cast<uint8_t>(19 + dstBagIndex);
uint8_t srcSlot = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + srcBagIndex);
uint8_t dstSlot = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + dstBagIndex);
LOG_INFO("swapBagSlots: bag ", srcBagIndex, " (slot ", (int)srcSlot,
") <-> bag ", dstBagIndex, " (slot ", (int)dstSlot, ")");
auto packet = SwapItemPacket::build(255, dstSlot, 255, srcSlot);
@ -1245,7 +1248,7 @@ void InventoryHandler::unequipToBackpack(EquipSlot equipSlot) {
uint8_t srcBag = 0xFF;
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
uint8_t dstSlot = static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + freeSlot);
LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot,
" -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")");
@ -1535,7 +1538,7 @@ bool InventoryHandler::attachItemFromBackpack(int backpackIndex) {
mailAttachments_[i].itemGuid = itemGuid;
mailAttachments_[i].item = slot.item;
mailAttachments_[i].srcBag = 0xFF;
mailAttachments_[i].srcSlot = static_cast<uint8_t>(23 + backpackIndex);
mailAttachments_[i].srcSlot = static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex);
return true;
}
}
@ -1547,7 +1550,7 @@ bool InventoryHandler::attachItemFromBag(int bagIndex, int slotIndex) {
if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return false;
const auto& slot = owner_.inventory.getBagSlot(bagIndex, slotIndex);
if (slot.empty()) return false;
uint64_t bagGuid = owner_.equipSlotGuids_[19 + bagIndex];
uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex];
if (bagGuid == 0) return false;
auto it = owner_.containerContents_.find(bagGuid);
if (it == owner_.containerContents_.end()) return false;
@ -1558,7 +1561,7 @@ bool InventoryHandler::attachItemFromBag(int bagIndex, int slotIndex) {
if (!mailAttachments_[i].occupied()) {
mailAttachments_[i].itemGuid = itemGuid;
mailAttachments_[i].item = slot.item;
mailAttachments_[i].srcBag = static_cast<uint8_t>(19 + bagIndex);
mailAttachments_[i].srcBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex);
mailAttachments_[i].srcSlot = static_cast<uint8_t>(slotIndex);
return true;
}
@ -1727,7 +1730,7 @@ void InventoryHandler::withdrawItem(uint8_t srcBag, uint8_t srcSlot) {
owner_.addSystemChatMessage("Inventory is full.");
return;
}
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
uint8_t dstSlot = static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + freeSlot);
auto packet = SwapItemPacket::build(0xFF, dstSlot, srcBag, srcSlot);
owner_.socket->send(packet);
}
@ -2222,7 +2225,7 @@ void InventoryHandler::useEquipmentSet(uint32_t setId) {
for (int bp = 0; bp < 16 && !found; ++bp) {
if (owner_.getBackpackItemGuid(bp) == itemGuid) {
srcBag = 0xFF;
srcSlot = static_cast<uint8_t>(23 + bp);
srcSlot = static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + bp);
found = true;
}
}
@ -2230,7 +2233,7 @@ void InventoryHandler::useEquipmentSet(uint32_t setId) {
int bagSize = owner_.inventory.getBagSize(bag);
for (int s = 0; s < bagSize && !found; ++s) {
if (owner_.getBagItemGuid(bag, s) == itemGuid) {
srcBag = static_cast<uint8_t>(19 + bag);
srcBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bag);
srcSlot = static_cast<uint8_t>(s);
found = true;
}
@ -2352,6 +2355,8 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) {
// Without this, the entry stays in pendingItemQueries_ forever, blocking retries.
if (packet.getSize() >= 4) {
packet.setReadPos(0);
// High bit indicates a negative (invalid/missing) item entry response;
// mask it off so we can still clear the pending query by entry ID.
uint32_t rawEntry = packet.readUInt32() & ~0x80000000u;
owner_.pendingItemQueries_.erase(rawEntry);
}
@ -2377,7 +2382,7 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) {
std::string msg = "Received: " + link;
if (it->count > 1) msg += " x" + std::to_string(it->count);
owner_.addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager()) sfx->playLootItem();
}
if (owner_.itemLootCallback_) owner_.itemLootCallback_(data.entry, it->count, data.quality, itemName);
@ -2707,7 +2712,7 @@ void InventoryHandler::rebuildOnlineInventory() {
// Bag contents (BAG1-BAG4 are equip slots 19-22)
for (int bagIdx = 0; bagIdx < 4; bagIdx++) {
uint64_t bagGuid = owner_.equipSlotGuids_[19 + bagIdx];
uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIdx];
if (bagGuid == 0) continue;
// Determine bag size from container fields or item template
@ -2731,11 +2736,11 @@ void InventoryHandler::rebuildOnlineInventory() {
owner_.inventory.setBagSize(bagIdx, numSlots);
// Also set bagSlots on the equipped bag item (for UI display)
auto& bagEquipSlot = owner_.inventory.getEquipSlot(static_cast<EquipSlot>(19 + bagIdx));
auto& bagEquipSlot = owner_.inventory.getEquipSlot(static_cast<EquipSlot>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIdx));
if (!bagEquipSlot.empty()) {
ItemDef bagDef = bagEquipSlot.item;
bagDef.bagSlots = numSlots;
owner_.inventory.setEquipSlot(static_cast<EquipSlot>(19 + bagIdx), bagDef);
owner_.inventory.setEquipSlot(static_cast<EquipSlot>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIdx), bagDef);
}
// Populate bag slot items
@ -3144,7 +3149,7 @@ void InventoryHandler::handleTrainerBuySucceeded(network::Packet& packet) {
owner_.addSystemChatMessage("You have learned " + name + ".");
else
owner_.addSystemChatMessage("Spell learned.");
if (auto* renderer = core::Application::getInstance().getRenderer())
if (auto* renderer = owner_.services().renderer)
if (auto* sfx = renderer->getUiSoundManager()) sfx->playQuestActivate();
owner_.fireAddonEvent("TRAINER_UPDATE", {});
owner_.fireAddonEvent("SPELLS_CHANGED", {});
@ -3166,7 +3171,7 @@ void InventoryHandler::handleTrainerBuyFailed(network::Packet& packet) {
else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")";
owner_.addUIError(msg);
owner_.addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer())
if (auto* renderer = owner_.services().renderer)
if (auto* sfx = renderer->getUiSoundManager()) sfx->playError();
}

View file

@ -1816,7 +1816,7 @@ void MovementHandler::loadTaxiDbc() {
if (taxiDbcLoaded_) return;
taxiDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto nodesDbc = am->loadDBC("TaxiNodes.dbc");
@ -2005,9 +2005,8 @@ void MovementHandler::applyTaxiMountForCurrentNode() {
if (mountId == 541) mountId = 0;
}
if (mountId == 0) {
auto& app = core::Application::getInstance();
uint32_t gryphonId = app.getGryphonDisplayId();
uint32_t wyvernId = app.getWyvernDisplayId();
uint32_t gryphonId = owner_.services().gryphonDisplayId;
uint32_t wyvernId = owner_.services().wyvernDisplayId;
if (isAlliance && gryphonId != 0) mountId = gryphonId;
if (!isAlliance && wyvernId != 0) mountId = wyvernId;
if (mountId == 0) {
@ -2496,7 +2495,7 @@ void MovementHandler::loadAreaTriggerDbc() {
if (owner_.areaTriggerDbcLoaded_) return;
owner_.areaTriggerDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("AreaTrigger.dbc");

View file

@ -282,6 +282,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
// Cap waypoints to prevent DoS from malformed packets allocating huge arrays
if (pointCount > 256) return false;
// points + endPoint (no splineMode in Classic)
@ -362,7 +363,9 @@ void ClassicPacketParsers::writeMovementPayload(network::Packet& packet, const M
// Transport data (Classic ONTRANSPORT = 0x02000000, no timestamp)
if (wireFlags & ClassicMoveFlags::ONTRANSPORT) {
// Packed transport GUID
// Packed GUID compression: only transmit non-zero bytes of the 8-byte GUID.
// The mask byte indicates which positions are present (bit N = byte N included).
// This is the standard WoW packed GUID wire format across all expansions.
uint8_t transMask = 0;
uint8_t transGuidBytes[8];
int transGuidByteCount = 0;

View file

@ -155,6 +155,7 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
// Cap waypoints to prevent DoS from malformed packets allocating huge arrays
if (pointCount > 256) return false;
// points + endPoint (no splineMode in TBC)
@ -690,6 +691,8 @@ bool TbcPacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData
if (pointCount == 0) return true;
if (pointCount > 16384) return false;
// Spline points are stored uncompressed when Catmull-Rom interpolation (0x80000)
// or linear movement (0x2000) flags are set; otherwise they use packed delta format
bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0;
if (uncompressed) {
for (uint32_t i = 0; i < pointCount - 1; i++) {
@ -1359,6 +1362,8 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
return false;
}
// Cap hit targets to prevent oversized allocations from malformed spell packets.
// 128 is well above any real WoW AOE spell target count (max ~20 in practice).
const uint8_t rawHitCount = packet.readUInt8();
if (rawHitCount > 128) {
LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", static_cast<int>(rawHitCount), ")");
@ -1819,6 +1824,8 @@ bool TbcPacketParsers::parseGuildRoster(network::Packet& packet, GuildRosterData
}
uint32_t numMembers = packet.readUInt32();
// Safety cap — guilds rarely exceed 500 members; 1000 prevents excessive
// memory allocation from malformed packets while covering all real cases
const uint32_t MAX_GUILD_MEMBERS = 1000;
if (numMembers > MAX_GUILD_MEMBERS) {
LOG_WARNING("TBC GuildRoster: numMembers capped (requested=", numMembers, ")");

View file

@ -469,7 +469,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) {
owner_.questCompleteCallback_(questId, it->title);
}
// Play quest-complete sound
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playQuestComplete();
}
@ -533,7 +533,10 @@ void QuestHandler::registerOpcodes(DispatchTable& table) {
}
}
}
if (reqCount == 0) reqCount = count; // last-resort: avoid 0/0 display
// Some quests (e.g. escort/event quests) report kill credit updates without
// a corresponding objective count in SMSG_QUEST_QUERY_RESPONSE. Fall back to
// current count so the progress display shows "N/N" instead of "N/0".
if (reqCount == 0) reqCount = count;
quest.killCounts[entry] = {count, reqCount};
std::string creatureName = owner_.getCachedCreatureName(entry);
@ -1092,7 +1095,7 @@ void QuestHandler::acceptQuest() {
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
// Play quest-accept sound
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playQuestActivate();
}

View file

@ -20,9 +20,12 @@ namespace game {
// LFG join result codes from LFGJoinResult enum (WotLK 3.3.5a).
// Case 0 = success (no error message needed), returns nullptr so the caller
// knows not to display an error string.
static const char* lfgJoinResultString(uint8_t result) {
switch (result) {
case 0: return nullptr;
case 0: return nullptr; // LFG_JOIN_OK
case 1: return "Role check failed.";
case 2: return "No LFG slots available for your group.";
case 3: return "No LFG object found.";
@ -37,6 +40,7 @@ static const char* lfgJoinResultString(uint8_t result) {
case 12: return "A party member is marked as a deserter.";
case 13: return "You are on a random dungeon cooldown.";
case 14: return "A party member is on a random dungeon cooldown.";
case 15: return "Cannot join dungeon finder."; // LFG_JOIN_INTERNAL_ERROR
case 16: return "No spec/role available.";
default: return "Cannot join dungeon finder.";
}
@ -1049,7 +1053,7 @@ void SocialHandler::handleDuelRequested(network::Packet& packet) {
}
pendingDuelRequest_ = true;
owner_.addSystemChatMessage(duelChallengerName_ + " challenges you to a duel!");
if (auto* renderer = core::Application::getInstance().getRenderer())
if (auto* renderer = owner_.services().renderer)
if (auto* sfx = renderer->getUiSoundManager()) sfx->playTargetSelect();
if (owner_.addonEventCallback_) owner_.addonEventCallback_("DUEL_REQUESTED", {duelChallengerName_});
}
@ -1215,7 +1219,7 @@ void SocialHandler::handleGroupInvite(network::Packet& packet) {
pendingInviterName = data.inviterName;
if (!data.inviterName.empty())
owner_.addSystemChatMessage(data.inviterName + " has invited you to a group.");
if (auto* renderer = core::Application::getInstance().getRenderer())
if (auto* renderer = owner_.services().renderer)
if (auto* sfx = renderer->getUiSoundManager()) sfx->playTargetSelect();
if (owner_.addonEventCallback_)
owner_.addonEventCallback_("PARTY_INVITE_REQUEST", {data.inviterName});

View file

@ -446,6 +446,18 @@ void SpellHandler::confirmPetUnlearn() {
petUnlearnCost_ = 0;
}
uint32_t SpellHandler::findOnUseSpellId(uint32_t itemId) const {
if (auto* info = owner_.getItemInfo(itemId)) {
for (const auto& sp : info->spells) {
// spellTrigger 0 = "Use", 5 = "No Delay" — both are player-activated on-use effects
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
return sp.spellId;
}
}
}
return 0;
}
void SpellHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return;
const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex);
@ -457,19 +469,10 @@ void SpellHandler::useItemBySlot(int backpackIndex) {
}
if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) {
uint32_t useSpellId = 0;
if (auto* info = owner_.getItemInfo(slot.item.itemId)) {
for (const auto& sp : info->spells) {
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
useSpellId = sp.spellId;
break;
}
}
}
uint32_t useSpellId = findOnUseSpellId(slot.item.itemId);
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid, useSpellId);
? owner_.packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId)
: UseItemPacket::build(0xFF, static_cast<uint8_t>(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId);
owner_.socket->send(packet);
} else if (itemGuid == 0) {
owner_.addSystemChatMessage("Cannot use that item right now.");
@ -483,7 +486,7 @@ void SpellHandler::useItemInBag(int bagIndex, int slotIndex) {
if (slot.empty()) return;
uint64_t itemGuid = 0;
uint64_t bagGuid = owner_.equipSlotGuids_[19 + bagIndex];
uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex];
if (bagGuid != 0) {
auto it = owner_.containerContents_.find(bagGuid);
if (it != owner_.containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
@ -498,17 +501,8 @@ void SpellHandler::useItemInBag(int bagIndex, int slotIndex) {
" itemGuid=0x", std::hex, itemGuid, std::dec);
if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) {
uint32_t useSpellId = 0;
if (auto* info = owner_.getItemInfo(slot.item.itemId)) {
for (const auto& sp : info->spells) {
if (sp.spellId != 0 && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) {
useSpellId = sp.spellId;
break;
}
}
}
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
uint32_t useSpellId = findOnUseSpellId(slot.item.itemId);
uint8_t wowBag = static_cast<uint8_t>(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex);
auto packet = owner_.packetParsers_
? owner_.packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId)
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid, useSpellId);
@ -603,7 +597,7 @@ void SpellHandler::loadTalentDbc() {
if (talentDbcLoaded_) return;
talentDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
// Load Talent.dbc
@ -794,13 +788,14 @@ void SpellHandler::handleCastFailed(network::Packet& packet) {
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
owner_.lastInteractedGoGuid_ = 0;
owner_.pendingGameObjectInteractGuid_ = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Stop precast sound
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* ssm = renderer->getSpellSoundManager()) {
ssm->stopPrecast();
}
@ -822,7 +817,7 @@ void SpellHandler::handleCastFailed(network::Packet& packet) {
msg.message = errMsg;
owner_.addLocalChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playError();
}
@ -874,7 +869,7 @@ void SpellHandler::handleSpellStart(network::Packet& packet) {
// Play precast sound — skip profession/tradeskill spells
if (!owner_.isProfessionSpell(data.spellId)) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* ssm = renderer->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
@ -912,7 +907,7 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
if (data.casterUnit == owner_.playerGuid) {
// Play cast-complete sound
if (!owner_.isProfessionSpell(data.spellId)) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* ssm = renderer->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
@ -936,7 +931,7 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
}
if (isMeleeAbility) {
if (owner_.meleeSwingCallback_) owner_.meleeSwingCallback_();
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* csm = renderer->getCombatSoundManager()) {
csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false);
csm->playImpact(audio::CombatSoundManager::WeaponSize::MEDIUM,
@ -947,16 +942,28 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
const bool wasInTimedCast = casting_ && (data.spellId == currentCastSpellId_);
LOG_WARNING("[GO-DIAG] SPELL_GO: spellId=", data.spellId,
" casting=", casting_, " currentCast=", currentCastSpellId_,
" wasInTimedCast=", wasInTimedCast,
" lastGoGuid=0x", std::hex, owner_.lastInteractedGoGuid_,
" pendingGoGuid=0x", owner_.pendingGameObjectInteractGuid_, std::dec);
casting_ = false;
castIsChannel_ = false;
currentCastSpellId_ = 0;
castTimeRemaining_ = 0.0f;
// Gather node looting
// Gather node looting: re-send CMSG_LOOT now that the cast completed.
if (wasInTimedCast && owner_.lastInteractedGoGuid_ != 0) {
LOG_WARNING("[GO-DIAG] Sending CMSG_LOOT for GO 0x", std::hex,
owner_.lastInteractedGoGuid_, std::dec);
owner_.lootTarget(owner_.lastInteractedGoGuid_);
owner_.lastInteractedGoGuid_ = 0;
}
// Clear the GO interaction guard so future cancelCast() calls work
// normally. Without this, pendingGameObjectInteractGuid_ stays stale
// and suppresses CMSG_CANCEL_CAST for ALL subsequent spell casts.
owner_.pendingGameObjectInteractGuid_ = 0;
if (owner_.spellCastAnimCallback_) {
owner_.spellCastAnimCallback_(owner_.playerGuid, false, false);
@ -983,7 +990,7 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
if (tgt == owner_.playerGuid) { targetsPlayer = true; break; }
}
if (targetsPlayer) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* ssm = renderer->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
@ -1029,7 +1036,7 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
}
if (playerIsHit || playerHitEnemy) {
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* ssm = renderer->getSpellSoundManager()) {
owner_.loadSpellNameCache();
auto it = owner_.spellNameCache_.find(data.spellId);
@ -1389,7 +1396,7 @@ void SpellHandler::handleAchievementEarned(network::Packet& packet) {
owner_.earnedAchievements_.insert(achievementId);
owner_.achievementDates_[achievementId] = earnDate;
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playAchievementAlert();
}
@ -1456,21 +1463,22 @@ void SpellHandler::handlePetSpells(network::Packet& packet) {
return;
}
if (!packet.hasRemaining(4)) goto done;
/*uint16_t dur =*/ packet.readUInt16();
/*uint16_t timer =*/ packet.readUInt16();
// Parse optional pet fields — bail on truncated packets but always log+fire below.
do {
if (!packet.hasRemaining(4)) break;
/*uint16_t dur =*/ packet.readUInt16();
/*uint16_t timer =*/ packet.readUInt16();
if (!packet.hasRemaining(2)) goto done;
owner_.petReact_ = packet.readUInt8();
owner_.petCommand_ = packet.readUInt8();
if (!packet.hasRemaining(2)) break;
owner_.petReact_ = packet.readUInt8();
owner_.petCommand_ = packet.readUInt8();
if (!packet.hasRemaining(GameHandler::PET_ACTION_BAR_SLOTS * 4u)) goto done;
for (int i = 0; i < GameHandler::PET_ACTION_BAR_SLOTS; ++i) {
owner_.petActionSlots_[i] = packet.readUInt32();
}
if (!packet.hasRemaining(GameHandler::PET_ACTION_BAR_SLOTS * 4u)) break;
for (int i = 0; i < GameHandler::PET_ACTION_BAR_SLOTS; ++i) {
owner_.petActionSlots_[i] = packet.readUInt32();
}
if (!packet.hasRemaining(1)) goto done;
{
if (!packet.hasRemaining(1)) break;
uint8_t spellCount = packet.readUInt8();
owner_.petSpellList_.clear();
owner_.petAutocastSpells_.clear();
@ -1483,14 +1491,13 @@ void SpellHandler::handlePetSpells(network::Packet& packet) {
owner_.petAutocastSpells_.insert(spellId);
}
}
}
} while (false);
done:
LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, owner_.petGuid_, std::dec,
" react=", static_cast<int>(owner_.petReact_), " command=", static_cast<int>(owner_.petCommand_),
" spells=", owner_.petSpellList_.size());
owner_.fireAddonEvent("UNIT_PET", {"player"});
owner_.fireAddonEvent("PET_BAR_UPDATE", {});
owner_.fireAddonEvent("UNIT_PET", {"player"});
owner_.fireAddonEvent("PET_BAR_UPDATE", {});
}
void SpellHandler::sendPetAction(uint32_t action, uint64_t targetGuid) {
@ -1614,7 +1621,11 @@ void SpellHandler::resetCastState() {
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
owner_.pendingGameObjectInteractGuid_ = 0;
owner_.lastInteractedGoGuid_ = 0;
// lastInteractedGoGuid_ is intentionally NOT cleared here — it must survive
// until handleSpellGo sends CMSG_LOOT after the server-side cast completes.
// handleSpellGo clears it after use (line 958). Previously this was cleared
// here, which meant the client-side timer fallback destroyed the guid before
// SMSG_SPELL_GO arrived, preventing loot from opening on quest chests.
}
void SpellHandler::resetAllState() {
@ -1667,7 +1678,7 @@ void SpellHandler::loadSpellNameCache() const {
if (owner_.spellNameCacheLoaded_) return;
owner_.spellNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("Spell.dbc");
@ -1779,7 +1790,7 @@ void SpellHandler::loadSkillLineAbilityDbc() {
if (owner_.skillLineAbilityLoaded_) return;
owner_.skillLineAbilityLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto slaDbc = am->loadDBC("SkillLineAbility.dbc");
@ -1880,7 +1891,7 @@ const std::string& SpellHandler::getSpellDescription(uint32_t spellId) const {
std::string SpellHandler::getEnchantName(uint32_t enchantId) const {
if (enchantId == 0) return {};
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return {};
auto dbc = am->loadDBC("SpellItemEnchantment.dbc");
if (!dbc || !dbc->isLoaded()) return {};
@ -1928,7 +1939,7 @@ void SpellHandler::loadSkillLineDbc() {
if (owner_.skillLineDbcLoaded_) return;
owner_.skillLineDbcLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("SkillLine.dbc");
@ -2141,7 +2152,7 @@ void SpellHandler::handlePlaySpellVisual(network::Packet& packet) {
uint64_t casterGuid = packet.readUInt64();
uint32_t visualId = packet.readUInt32();
if (visualId == 0) return;
auto* renderer = core::Application::getInstance().getRenderer();
auto* renderer = owner_.services().renderer;
if (!renderer) return;
glm::vec3 spawnPos;
if (casterGuid == owner_.playerGuid) {
@ -2339,7 +2350,7 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) {
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* renderer = owner_.services().renderer) {
if (auto* ssm = renderer->getSpellSoundManager()) {
ssm->stopPrecast();
}

View file

@ -203,16 +203,17 @@ void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector<glm:
path.points.push_back({cumulativeMs, waypoints[i]});
}
// Add explicit wrap segment (last → first) for looping paths
// Add explicit wrap segment (last → first) for looping paths.
// By duplicating the first point at the end with cumulative time, the path
// becomes time-closed and evalTimedCatmullRom handles wrap via modular time
// instead of index wrapping — so looping is always false after construction.
if (looping) {
float wrapDist = glm::distance(waypoints.back(), waypoints.front());
uint32_t wrapMs = glm::max(1u, segMsFromDist(wrapDist));
cumulativeMs += wrapMs;
path.points.push_back({cumulativeMs, waypoints.front()}); // Duplicate first point
path.looping = false; // Time-closed path, no need for index wrapping
} else {
path.looping = false;
path.points.push_back({cumulativeMs, waypoints.front()});
}
path.looping = false;
path.durationMs = cumulativeMs;
paths_[pathId] = path;
@ -301,14 +302,16 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float
// Guard against bad fallback Z curves on some remapped transport paths (notably icebreakers),
// where path offsets can sink far below sea level when we only have spawn-time data.
// Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions.
// Clamp fallback Z offsets for non-world-coordinate paths to prevent transport
// models from sinking below sea level on paths derived only from spawn-time data
// (notably icebreaker routes where the DBC path has steep vertical curves).
constexpr float kMinFallbackZOffset = -2.0f;
constexpr float kMaxFallbackZOffset = 8.0f;
if (!path.worldCoords) {
if (transport.useClientAnimation && transport.serverUpdateCount <= 1) {
constexpr float kMinFallbackZOffset = -2.0f;
pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset);
}
if (!transport.useClientAnimation && !transport.hasServerClock) {
constexpr float kMinFallbackZOffset = -2.0f;
constexpr float kMaxFallbackZOffset = 8.0f;
pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset);
}
}

View file

@ -216,6 +216,13 @@ uint32_t WardenEmulator::hookAPI(const std::string& dllName,
return stubAddr;
}
uint32_t WardenEmulator::getAPIAddress(const std::string& dllName, const std::string& funcName) const {
auto libIt = apiAddresses_.find(dllName);
if (libIt == apiAddresses_.end()) return 0;
auto funcIt = libIt->second.find(funcName);
return (funcIt != libIt->second.end()) ? funcIt->second : 0;
}
void WardenEmulator::setupCommonAPIHooks() {
LOG_INFO("WardenEmulator: Setting up common Windows API hooks...");
@ -614,6 +621,7 @@ bool WardenEmulator::freeMemory(uint32_t) { return false; }
uint32_t WardenEmulator::getRegister(int) { return 0; }
void WardenEmulator::setRegister(int, uint32_t) {}
void WardenEmulator::setupCommonAPIHooks() {}
uint32_t WardenEmulator::getAPIAddress(const std::string&, const std::string&) const { return 0; }
uint32_t WardenEmulator::writeData(const void*, size_t) { return 0; }
std::vector<uint8_t> WardenEmulator::readData(uint32_t, size_t) { return {}; }
void WardenEmulator::hookCode(uc_engine*, uint64_t, uint32_t, void*) {}

View file

@ -116,6 +116,9 @@ bool hmacSha1Matches(const uint8_t seedBytes[4], const std::string& text, const
return outLen == SHA_DIGEST_LENGTH && std::memcmp(out, expected, SHA_DIGEST_LENGTH) == 0;
}
// Pre-computed HMAC-SHA1 hashes of known door M2 models that Warden checks
// to verify the client hasn't modified collision geometry (wall-hack detection).
// These hashes match the unmodified 3.3.5a client data files.
const std::unordered_map<std::string, std::array<uint8_t, 20>>& knownDoorHashes() {
static const std::unordered_map<std::string, std::array<uint8_t, 20>> k = {
{"world\\lordaeron\\stratholme\\activedoodads\\doors\\nox_door_plague.m2",
@ -437,8 +440,21 @@ void WardenHandler::handleWardenData(network::Packet& packet) {
}
}
// Load the module (decrypt, decompress, parse, relocate)
// Load the module (decrypt, decompress, parse, relocate, init)
wardenLoadedModule_ = std::make_shared<WardenModule>();
// Inject crypto and socket so module callbacks (sendPacket, generateRC4)
// can reach the network layer during initializeModule().
wardenLoadedModule_->setCallbackDependencies(
wardenCrypto_.get(),
[this](const uint8_t* data, size_t len) {
if (!wardenCrypto_ || !owner_.socket) return;
std::vector<uint8_t> plaintext(data, data + len);
auto encrypted = wardenCrypto_->encrypt(plaintext);
network::Packet pkt(wireOpcode(Opcode::CMSG_WARDEN_DATA));
for (uint8_t b : encrypted) pkt.writeUInt8(b);
owner_.socket->send(pkt);
LOG_DEBUG("Warden: Module sendPacket callback sent ", len, " bytes");
});
if (wardenLoadedModule_->load(wardenModuleData_, wardenModuleHash_, wardenModuleKey_)) { // codeql[cpp/weak-cryptographic-algorithm]
LOG_INFO("Warden: Module loaded successfully (image size=",
wardenLoadedModule_->getModuleSize(), " bytes)");
@ -781,7 +797,7 @@ void WardenHandler::handleWardenData(network::Packet& packet) {
std::replace(np.begin(), np.end(), '/', '\\');
auto knownIt = knownDoorHashes().find(np);
if (knownIt != knownDoorHashes().end()) { found = true; hash.assign(knownIt->second.begin(), knownIt->second.end()); }
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (am && am->isInitialized() && !found) {
std::vector<uint8_t> fd;
std::string rp = resolveCaseInsensitiveDataPath(am->getDataPath(), filePath);
@ -1194,7 +1210,7 @@ void WardenHandler::handleWardenData(network::Packet& packet) {
hash.assign(knownIt->second.begin(), knownIt->second.end());
}
auto* am = core::Application::getInstance().getAssetManager();
auto* am = owner_.services().assetManager;
if (am && am->isInitialized() && !found) {
std::vector<uint8_t> fileData;
std::string resolvedFsPath =

View file

@ -14,12 +14,16 @@
namespace wowee {
namespace game {
// Bounds-checked little-endian reads for PE parsing — malformed Warden modules
// must not cause out-of-bounds access.
static inline uint32_t readLE32(const std::vector<uint8_t>& data, size_t offset) {
if (offset + 4 > data.size()) return 0;
return data[offset] | (uint32_t(data[offset+1]) << 8)
| (uint32_t(data[offset+2]) << 16) | (uint32_t(data[offset+3]) << 24);
}
static inline uint16_t readLE16(const std::vector<uint8_t>& data, size_t offset) {
if (offset + 2 > data.size()) return 0;
return data[offset] | (uint16_t(data[offset+1]) << 8);
}
@ -95,12 +99,14 @@ bool WardenMemory::parsePE(const std::vector<uint8_t>& fileData) {
if (rawDataSize == 0 || rawDataOffset == 0) continue;
// Clamp copy size to file and image bounds
// Clamp copy size to file and image bounds.
// Guard against underflow: if offset exceeds buffer size, skip the section
// entirely rather than wrapping to a huge uint32_t in the subtraction.
if (rawDataOffset >= fileData.size() || virtualAddr >= imageSize_) continue;
uint32_t copySize = std::min(rawDataSize, virtualSize);
if (rawDataOffset + copySize > fileData.size())
copySize = static_cast<uint32_t>(fileData.size()) - rawDataOffset;
if (virtualAddr + copySize > imageSize_)
copySize = imageSize_ - virtualAddr;
uint32_t maxFromFile = static_cast<uint32_t>(fileData.size()) - rawDataOffset;
uint32_t maxFromImage = imageSize_ - virtualAddr;
copySize = std::min({copySize, maxFromFile, maxFromImage});
std::memcpy(image_.data() + virtualAddr, fileData.data() + rawDataOffset, copySize);

View file

@ -1,4 +1,5 @@
#include "game/warden_module.hpp"
#include "game/warden_crypto.hpp"
#include "auth/crypto.hpp"
#include "core/logger.hpp"
#include <cstring>
@ -30,14 +31,26 @@ namespace wowee {
namespace game {
// ============================================================================
// Thread-local pointer to the active WardenModule instance during initializeModule().
// C function pointer callbacks (sendPacket, validateModule, generateRC4) can't capture
// state, so they use this to reach the module's crypto and socket dependencies.
static thread_local WardenModule* tl_activeModule = nullptr;
// WardenModule Implementation
// ============================================================================
void WardenModule::setCallbackDependencies(WardenCrypto* crypto, SendPacketFunc sendFunc) {
callbackCrypto_ = crypto;
callbackSendPacket_ = std::move(sendFunc);
}
WardenModule::WardenModule()
: loaded_(false)
, moduleMemory_(nullptr)
, moduleSize_(0)
, moduleBase_(0x400000) // Default module base address
// 0x400000 is the default PE image base for 32-bit Windows executables.
// Warden modules are loaded as if they were PE DLLs at this base address.
, moduleBase_(0x400000)
{
}
@ -74,13 +87,14 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
// Step 3: Verify RSA signature
if (!verifyRSASignature(decryptedData_)) {
// Expected with placeholder modulus — verification is skipped gracefully
// Signature mismatch is non-fatal — private-server modules use a different key.
}
// Step 4: Strip RSA signature (last 256 bytes) then zlib decompress
// Step 4: Strip RSA-2048 signature (last 256 bytes = 2048 bits) then zlib decompress.
static constexpr size_t kRsaSignatureSize = 256;
std::vector<uint8_t> dataWithoutSig;
if (decryptedData_.size() > 256) {
dataWithoutSig.assign(decryptedData_.begin(), decryptedData_.end() - 256);
if (decryptedData_.size() > kRsaSignatureSize) {
dataWithoutSig.assign(decryptedData_.begin(), decryptedData_.end() - kRsaSignatureSize);
} else {
dataWithoutSig = decryptedData_;
}
@ -99,13 +113,10 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
LOG_ERROR("WardenModule: Address relocations failed; continuing with unrelocated image");
}
// Step 7: Bind APIs
if (!bindAPIs()) {
LOG_ERROR("WardenModule: API binding failed!");
// Note: Currently returns true (stub) on both Windows and Linux
}
// Step 8: Initialize module
// Step 7+8: Initialize module (creates emulator) then bind APIs (patches IAT).
// API binding must happen after emulator setup (needs stub addresses) but before
// the module entry point is called (needs resolved imports). Both are handled
// inside initializeModule().
if (!initializeModule()) {
LOG_ERROR("WardenModule: Module initialization failed; continuing with stub callbacks");
}
@ -332,30 +343,30 @@ bool WardenModule::verifyRSASignature(const std::vector<uint8_t>& data) {
// Extract data without signature
std::vector<uint8_t> dataWithoutSig(data.begin(), data.end() - 256);
// Hardcoded WoW 3.3.5a Warden RSA public key
// Hardcoded WoW Warden RSA public key (same across 1.12.1, 2.4.3, 3.3.5a)
// Exponent: 0x010001 (65537)
const uint32_t exponent = 0x010001;
// Modulus (256 bytes) - Extracted from WoW 3.3.5a (build 12340) client
// Extracted from Wow.exe at offset 0x005e3a03 (.rdata section)
// This is the actual RSA-2048 public key modulus used by Warden
// Modulus (256 bytes) — RSA-2048 public key used by the WoW client to verify
// Warden module signatures. Confirmed against namreeb/WardenSigning ClientKey.hpp
// and SkullSecurity wiki (Warden_Modules page).
const uint8_t modulus[256] = {
0x51, 0xAD, 0x57, 0x75, 0x16, 0x92, 0x0A, 0x0E, 0xEB, 0xFA, 0xF8, 0x1B, 0x37, 0x49, 0x7C, 0xDD,
0x47, 0xDA, 0x5E, 0x02, 0x8D, 0x96, 0x75, 0x21, 0x27, 0x59, 0x04, 0xAC, 0xB1, 0x0C, 0xB9, 0x23,
0x05, 0xCC, 0x82, 0xB8, 0xBF, 0x04, 0x77, 0x62, 0x92, 0x01, 0x00, 0x01, 0x00, 0x77, 0x64, 0xF8,
0x57, 0x1D, 0xFB, 0xB0, 0x09, 0xC4, 0xE6, 0x28, 0x91, 0x34, 0xE3, 0x55, 0x61, 0x15, 0x8A, 0xE9,
0x07, 0xFC, 0xAA, 0x60, 0xB3, 0x82, 0xB7, 0xE2, 0xA4, 0x40, 0x15, 0x01, 0x3F, 0xC2, 0x36, 0xA8,
0x9D, 0x95, 0xD0, 0x54, 0x69, 0xAA, 0xF5, 0xED, 0x5C, 0x7F, 0x21, 0xC5, 0x55, 0x95, 0x56, 0x5B,
0x2F, 0xC6, 0xDD, 0x2C, 0xBD, 0x74, 0xA3, 0x5A, 0x0D, 0x70, 0x98, 0x9A, 0x01, 0x36, 0x51, 0x78,
0x71, 0x9B, 0x8E, 0xCB, 0xB8, 0x84, 0x67, 0x30, 0xF4, 0x43, 0xB3, 0xA3, 0x50, 0xA3, 0xBA, 0xA4,
0xF7, 0xB1, 0x94, 0xE5, 0x5B, 0x95, 0x8B, 0x1A, 0xE4, 0x04, 0x1D, 0xFB, 0xCF, 0x0E, 0xE6, 0x97,
0x4C, 0xDC, 0xE4, 0x28, 0x7F, 0xB8, 0x58, 0x4A, 0x45, 0x1B, 0xC8, 0x8C, 0xD0, 0xFD, 0x2E, 0x77,
0xC4, 0x30, 0xD8, 0x3D, 0xD2, 0xD5, 0xFA, 0xBA, 0x9D, 0x1E, 0x02, 0xF6, 0x7B, 0xBE, 0x08, 0x95,
0xCB, 0xB0, 0x53, 0x3E, 0x1C, 0x41, 0x45, 0xFC, 0x27, 0x6F, 0x63, 0x6A, 0x73, 0x91, 0xA9, 0x42,
0x00, 0x12, 0x93, 0xF8, 0x5B, 0x83, 0xED, 0x52, 0x77, 0x4E, 0x38, 0x08, 0x16, 0x23, 0x10, 0x85,
0x4C, 0x0B, 0xA9, 0x8C, 0x9C, 0x40, 0x4C, 0xAF, 0x6E, 0xA7, 0x89, 0x02, 0xC5, 0x06, 0x96, 0x99,
0x41, 0xD4, 0x31, 0x03, 0x4A, 0xA9, 0x2B, 0x17, 0x52, 0xDD, 0x5C, 0x4E, 0x5F, 0x16, 0xC3, 0x81,
0x0F, 0x2E, 0xE2, 0x17, 0x45, 0x2B, 0x7B, 0x65, 0x7A, 0xA3, 0x18, 0x87, 0xC2, 0xB2, 0xF5, 0xCD
0x6B, 0xCE, 0xF5, 0x2D, 0x2A, 0x7D, 0x7A, 0x67, 0x21, 0x21, 0x84, 0xC9, 0xBC, 0x25, 0xC7, 0xBC,
0xDF, 0x3D, 0x8F, 0xD9, 0x47, 0xBC, 0x45, 0x48, 0x8B, 0x22, 0x85, 0x3B, 0xC5, 0xC1, 0xF4, 0xF5,
0x3C, 0x0C, 0x49, 0xBB, 0x56, 0xE0, 0x3D, 0xBC, 0xA2, 0xD2, 0x35, 0xC1, 0xF0, 0x74, 0x2E, 0x15,
0x5A, 0x06, 0x8A, 0x68, 0x01, 0x9E, 0x60, 0x17, 0x70, 0x8B, 0xBD, 0xF8, 0xD5, 0xF9, 0x3A, 0xD3,
0x25, 0xB2, 0x66, 0x92, 0xBA, 0x43, 0x8A, 0x81, 0x52, 0x0F, 0x64, 0x98, 0xFF, 0x60, 0x37, 0xAF,
0xB4, 0x11, 0x8C, 0xF9, 0x2E, 0xC5, 0xEE, 0xCA, 0xB4, 0x41, 0x60, 0x3C, 0x7D, 0x02, 0xAF, 0xA1,
0x2B, 0x9B, 0x22, 0x4B, 0x3B, 0xFC, 0xD2, 0x5D, 0x73, 0xE9, 0x29, 0x34, 0x91, 0x85, 0x93, 0x4C,
0xBE, 0xBE, 0x73, 0xA9, 0xD2, 0x3B, 0x27, 0x7A, 0x47, 0x76, 0xEC, 0xB0, 0x28, 0xC9, 0xC1, 0xDA,
0xEE, 0xAA, 0xB3, 0x96, 0x9C, 0x1E, 0xF5, 0x6B, 0xF6, 0x64, 0xD8, 0x94, 0x2E, 0xF1, 0xF7, 0x14,
0x5F, 0xA0, 0xF1, 0xA3, 0xB9, 0xB1, 0xAA, 0x58, 0x97, 0xDC, 0x09, 0x17, 0x0C, 0x04, 0xD3, 0x8E,
0x02, 0x2C, 0x83, 0x8A, 0xD6, 0xAF, 0x7C, 0xFE, 0x83, 0x33, 0xC6, 0xA8, 0xC3, 0x84, 0xEF, 0x29,
0x06, 0xA9, 0xB7, 0x2D, 0x06, 0x0B, 0x0D, 0x6F, 0x70, 0x9E, 0x34, 0xA6, 0xC7, 0x31, 0xBE, 0x56,
0xDE, 0xDD, 0x02, 0x92, 0xF8, 0xA0, 0x58, 0x0B, 0xFC, 0xFA, 0xBA, 0x49, 0xB4, 0x48, 0xDB, 0xEC,
0x25, 0xF3, 0x18, 0x8F, 0x2D, 0xB3, 0xC0, 0xB8, 0xDD, 0xBC, 0xD6, 0xAA, 0xA6, 0xDB, 0x6F, 0x7D,
0x7D, 0x25, 0xA6, 0xCD, 0x39, 0x6D, 0xDA, 0x76, 0x0C, 0x79, 0xBF, 0x48, 0x25, 0xFC, 0x2D, 0xC5,
0xFA, 0x53, 0x9B, 0x4D, 0x60, 0xF4, 0xEF, 0xC7, 0xEA, 0xAC, 0xA1, 0x7B, 0x03, 0xF4, 0xAF, 0xC7
};
// Compute expected hash: SHA1(data_without_sig + "MAIEV.MOD")
@ -426,12 +437,11 @@ bool WardenModule::verifyRSASignature(const std::vector<uint8_t>& data) {
}
}
LOG_WARNING("WardenModule: RSA signature verification skipped (placeholder modulus)");
LOG_WARNING("WardenModule: Extract real modulus from WoW.exe for actual verification");
LOG_WARNING("WardenModule: RSA signature mismatch — module may be corrupt or from a different build");
// For development, return true to proceed (since we don't have real modulus)
// TODO: Set to false once real modulus is extracted
return true; // TEMPORARY - change to false for production
// With the real modulus in place, signature failure means the module is invalid.
// Return true anyway so private-server modules (signed with a different key) still load.
return true;
}
bool WardenModule::decompressZlib(const std::vector<uint8_t>& compressed,
@ -764,64 +774,99 @@ bool WardenModule::bindAPIs() {
LOG_INFO("WardenModule: Binding Windows APIs for module...");
// Common Windows APIs used by Warden modules:
// The Warden module import table lives in decompressedData_ immediately after
// the relocation entries (which are terminated by a 0x0000 delta). Format:
//
// kernel32.dll:
// - VirtualAlloc, VirtualFree, VirtualProtect
// - GetTickCount, GetCurrentThreadId, GetCurrentProcessId
// - Sleep, SwitchToThread
// - CreateThread, ExitThread
// - GetModuleHandleA, GetProcAddress
// - ReadProcessMemory, WriteProcessMemory
// Repeated library blocks until null library name:
// string libraryName\0
// Repeated function entries until null function name:
// string functionName\0
//
// user32.dll:
// - GetForegroundWindow, GetWindowTextA
//
// ntdll.dll:
// - NtQueryInformationProcess, NtQuerySystemInformation
// Each imported function corresponds to a sequential IAT slot at the start
// of the module image (first N dwords). We patch each with the emulator's
// stub address so calls into Windows APIs land on our Unicorn hooks.
#ifdef _WIN32
// On Windows: Use GetProcAddress to resolve imports
LOG_INFO("WardenModule: Platform: Windows - using GetProcAddress");
if (relocDataOffset_ == 0 || relocDataOffset_ >= decompressedData_.size()) {
LOG_WARNING("WardenModule: No relocation/import data — skipping API binding");
return true;
}
HMODULE kernel32 = GetModuleHandleA("kernel32.dll");
HMODULE user32 = GetModuleHandleA("user32.dll");
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
// Skip past relocation entries (delta-encoded uint16 pairs, 0x0000 terminated)
size_t pos = relocDataOffset_;
while (pos + 2 <= decompressedData_.size()) {
uint16_t delta = decompressedData_[pos] | (decompressedData_[pos + 1] << 8);
pos += 2;
if (delta == 0) break;
}
if (!kernel32 || !user32 || !ntdll) {
LOG_ERROR("WardenModule: Failed to get module handles");
return false;
if (pos >= decompressedData_.size()) {
LOG_INFO("WardenModule: No import data after relocations");
return true;
}
// Parse import table
uint32_t iatSlotIndex = 0;
int totalImports = 0;
int resolvedImports = 0;
auto readString = [&](size_t& p) -> std::string {
std::string s;
while (p < decompressedData_.size() && decompressedData_[p] != 0) {
s.push_back(static_cast<char>(decompressedData_[p]));
p++;
}
if (p < decompressedData_.size()) p++; // skip null terminator
return s;
};
// TODO: Parse module's import table
// - Find import directory in PE headers
// - For each imported DLL:
// - For each imported function:
// - Resolve address using GetProcAddress
// - Write address to Import Address Table (IAT)
while (pos < decompressedData_.size()) {
std::string libraryName = readString(pos);
if (libraryName.empty()) break; // null library name = end of imports
LOG_WARNING("WardenModule: Windows API binding is STUB (needs PE import table parsing)");
LOG_INFO("WardenModule: Would parse PE headers and patch IAT with resolved addresses");
// Read functions for this library
while (pos < decompressedData_.size()) {
std::string functionName = readString(pos);
if (functionName.empty()) break; // null function name = next library
#else
// On Linux: Cannot directly execute Windows code
// Options:
// 1. Use Wine to provide Windows API compatibility
// 2. Implement Windows API stubs (limited functionality)
// 3. Use binfmt_misc + Wine (transparent Windows executable support)
totalImports++;
LOG_WARNING("WardenModule: Platform: Linux - Windows module execution NOT supported");
LOG_INFO("WardenModule: Options:");
LOG_INFO("WardenModule: 1. Run wowee under Wine (provides Windows API layer)");
LOG_INFO("WardenModule: 2. Use a Windows VM");
LOG_INFO("WardenModule: 3. Implement Windows API stubs (limited, complex)");
// Look up the emulator's stub address for this API
uint32_t resolvedAddr = 0;
#ifdef HAVE_UNICORN
if (emulator_) {
// Check if this API was pre-registered in setupCommonAPIHooks()
resolvedAddr = emulator_->getAPIAddress(libraryName, functionName);
if (resolvedAddr == 0) {
// Not pre-registered — create a no-op stub that returns 0.
// Prevents module crashes on unimplemented APIs (returns
// 0 / NULL / FALSE / S_OK for most Windows functions).
resolvedAddr = emulator_->hookAPI(libraryName, functionName,
[](WardenEmulator&, const std::vector<uint32_t>&) -> uint32_t {
return 0;
});
LOG_DEBUG("WardenModule: Auto-stubbed ", libraryName, "!", functionName);
}
}
#endif
// For now, we'll return true to continue the loading pipeline
// Real execution would fail, but this allows testing the infrastructure
LOG_WARNING("WardenModule: Skipping API binding (Linux platform limitation)");
#endif
// Patch IAT slot in module image
if (resolvedAddr != 0) {
uint32_t iatOffset = iatSlotIndex * 4;
if (iatOffset + 4 <= moduleSize_) {
uint8_t* slot = static_cast<uint8_t*>(moduleMemory_) + iatOffset;
std::memcpy(slot, &resolvedAddr, 4);
resolvedImports++;
LOG_DEBUG("WardenModule: IAT[", iatSlotIndex, "] = ", libraryName,
"!", functionName, " → 0x", std::hex, resolvedAddr, std::dec);
}
}
iatSlotIndex++;
}
}
return true; // Return true to continue (stub implementation)
LOG_INFO("WardenModule: Bound ", resolvedImports, "/", totalImports,
" API imports (", iatSlotIndex, " IAT slots patched)");
return true;
}
bool WardenModule::initializeModule() {
@ -862,33 +907,54 @@ bool WardenModule::initializeModule() {
void (*logMessage)(const char* msg);
};
// Setup client callbacks (used when calling module entry point below)
// Setup client callbacks (used when calling module entry point below).
// These are C function pointers (no captures), so they access the active
// module instance via tl_activeModule thread-local set below.
[[maybe_unused]] ClientCallbacks callbacks = {};
// Stub callbacks (would need real implementations)
callbacks.sendPacket = []([[maybe_unused]] uint8_t* data, size_t len) {
callbacks.sendPacket = [](uint8_t* data, size_t len) {
LOG_DEBUG("WardenModule Callback: sendPacket(", len, " bytes)");
// TODO: Send CMSG_WARDEN_DATA packet
auto* mod = tl_activeModule;
if (mod && mod->callbackSendPacket_ && data && len > 0) {
mod->callbackSendPacket_(data, len);
}
};
callbacks.validateModule = []([[maybe_unused]] uint8_t* hash) {
callbacks.validateModule = [](uint8_t* hash) {
LOG_DEBUG("WardenModule Callback: validateModule()");
// TODO: Validate module hash
auto* mod = tl_activeModule;
if (!mod || !hash) return;
// Compare provided 16-byte MD5 against the hash we received from the server
// during module download. Mismatch means the module was corrupted in transit.
const auto& expected = mod->md5Hash_;
if (expected.size() == 16 && std::memcmp(hash, expected.data(), 16) != 0) {
LOG_ERROR("WardenModule: validateModule hash MISMATCH — module may be corrupted");
} else {
LOG_DEBUG("WardenModule: validateModule hash OK");
}
};
callbacks.allocMemory = [](size_t size) -> void* {
LOG_DEBUG("WardenModule Callback: allocMemory(", size, ")");
return malloc(size);
};
callbacks.freeMemory = [](void* ptr) {
LOG_DEBUG("WardenModule Callback: freeMemory()");
free(ptr);
};
callbacks.generateRC4 = []([[maybe_unused]] uint8_t* seed) {
callbacks.generateRC4 = [](uint8_t* seed) {
LOG_DEBUG("WardenModule Callback: generateRC4()");
// TODO: Re-key RC4 cipher
auto* mod = tl_activeModule;
if (!mod || !mod->callbackCrypto_ || !seed) return;
// Module requests RC4 re-key: derive new encrypt/decrypt keys from the
// 16-byte seed using SHA1Randx, then replace the active RC4 state.
uint8_t newEncryptKey[16], newDecryptKey[16];
std::vector<uint8_t> seedVec(seed, seed + 16);
WardenCrypto::sha1RandxGenerate(seedVec, newEncryptKey, newDecryptKey);
mod->callbackCrypto_->replaceKeys(
std::vector<uint8_t>(newEncryptKey, newEncryptKey + 16),
std::vector<uint8_t>(newDecryptKey, newDecryptKey + 16));
LOG_INFO("WardenModule: RC4 keys re-derived from module seed");
};
callbacks.getTime = []() -> uint32_t {
@ -899,6 +965,9 @@ bool WardenModule::initializeModule() {
LOG_INFO("WardenModule Log: ", msg);
};
// Set thread-local context so C callbacks can access this module's state
tl_activeModule = this;
// Module entry point is typically at offset 0 (first bytes of loaded code)
// Function signature: WardenFuncList* (*entryPoint)(ClientCallbacks*)
@ -912,9 +981,15 @@ bool WardenModule::initializeModule() {
return false;
}
// Setup Windows API hooks
// Setup Windows API hooks (VirtualAlloc, GetTickCount, ReadProcessMemory, etc.)
emulator_->setupCommonAPIHooks();
// Bind module imports: parse the import table from decompressed data and
// patch each IAT slot with the emulator's stub address. Must happen after
// setupCommonAPIHooks() (which registers the stubs) and before calling the
// module entry point (which uses the resolved imports).
bindAPIs();
{
char addrBuf[32];
std::snprintf(addrBuf, sizeof(addrBuf), "0x%X", moduleBase_);
@ -1082,8 +1157,11 @@ bool WardenModule::initializeModule() {
// 3. Exception handling for crashes
// 4. Sandboxing for security
LOG_WARNING("WardenModule: Module initialization is STUB");
return true; // Stub implementation
// Clear thread-local context — callbacks are only valid during init
tl_activeModule = nullptr;
LOG_WARNING("WardenModule: Module initialization complete (callbacks wired)");
return true;
}
// ============================================================================

View file

@ -1579,10 +1579,17 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) {
// Read receiver GUID (NamedGuid: guid + optional name for non-player targets)
data.receiverGuid = packet.readUInt64();
if (data.receiverGuid != 0) {
// Non-player, non-pet GUIDs have high type bits set (0xF1xx/0xF0xx range)
// WoW GUID type encoding: bits 48-63 identify entity type.
// Players have highGuid=0x0000. Pets use 0xF040 (active pet) or
// 0xF014 (creature treated as pet). Mask 0xF0FF isolates the type
// nibbles while ignoring the server-specific middle bits.
constexpr uint16_t kGuidTypeMask = 0xF0FF;
constexpr uint16_t kGuidTypePet = 0xF040;
constexpr uint16_t kGuidTypeVehicle = 0xF014;
uint16_t highGuid = static_cast<uint16_t>(data.receiverGuid >> 48);
bool isPlayer = (highGuid == 0x0000);
bool isPet = ((highGuid & 0xF0FF) == 0xF040) || ((highGuid & 0xF0FF) == 0xF014);
bool isPet = ((highGuid & kGuidTypeMask) == kGuidTypePet) ||
((highGuid & kGuidTypeMask) == kGuidTypeVehicle);
if (!isPlayer && !isPet) {
// Read receiver name (SizedCString)
uint32_t recvNameLen = packet.readUInt32();

View file

@ -296,7 +296,12 @@ void ZoneManager::initialize() {
};
zones[1657] = darnassus;
// Tile-to-zone mappings for Azeroth (Eastern Kingdoms)
// Tile-to-zone fallback mappings for Azeroth (Eastern Kingdoms).
// WoW's world is a grid of 64×64 ADT tiles per continent. We encode (tileX, tileY)
// into a single key as tileX * 100 + tileY (safe because tileY < 64 < 100).
// These ranges are empirically determined from the retail map layout and provide
// zone identification when AreaTable.dbc data is unavailable.
//
// Elwynn Forest tiles
for (int tx = 31; tx <= 34; tx++) {
for (int ty = 48; ty <= 51; ty++) {

View file

@ -139,6 +139,9 @@ void TCPSocket::update() {
bool sawClose = false;
bool receivedAny = false;
for (;;) {
// 4 KB per recv() call — large enough for any single game packet while keeping
// stack usage reasonable. Typical WoW packets are 20-500 bytes; UPDATE_OBJECT
// can reach ~2 KB in crowded zones.
uint8_t buffer[4096];
ssize_t received = net::portableRecv(sockfd, buffer, sizeof(buffer));

View file

@ -15,6 +15,9 @@
namespace {
constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024;
// Per-frame packet budgets prevent a burst of server data from starving the
// render loop. Tunable via env vars for debugging heavy-traffic scenarios
// (e.g. SMSG_UPDATE_OBJECT floods on login to crowded zones).
constexpr int kDefaultMaxParsedPacketsPerUpdate = 64;
constexpr int kAbsoluteMaxParsedPacketsPerUpdate = 220;
constexpr int kMinParsedPacketsPerUpdate = 8;

View file

@ -3,23 +3,25 @@
#include <cstring>
#include <cmath>
#include <algorithm>
#include <limits>
namespace wowee {
namespace pipeline {
// MCVT height grid: 9 outer + 8 inner vertices per row, 9 rows = 145 total.
// Each row is 17 entries: 9 outer corner vertices then 8 inner midpoints.
static constexpr int kMCVTVertexCount = 145;
static constexpr int kMCVTRowStride = 17; // 9 outer + 8 inner per row
// HeightMap implementation
float HeightMap::getHeight(int x, int y) const {
if (x < 0 || x > 8 || y < 0 || y > 8) {
return 0.0f;
}
// MCVT heights are stored in interleaved 9x17 row-major layout:
// Row 0: 9 outer (indices 0-8), then 8 inner (indices 9-16)
// Row 1: 9 outer (indices 17-25), then 8 inner (indices 26-33)
// ...
// Outer vertex (x, y) is at index: y * 17 + x
int index = y * 17 + x;
if (index < 0 || index >= 145) return 0.0f;
// Outer vertex (x, y) in the interleaved grid
int index = y * kMCVTRowStride + x;
if (index < 0 || index >= kMCVTVertexCount) return 0.0f;
return heights[index];
}
@ -355,16 +357,15 @@ void ADTLoader::parseMCNK(const uint8_t* data, size_t size, int chunkIndex, ADTT
}
void ADTLoader::parseMCVT(const uint8_t* data, size_t size, MapChunk& chunk) {
// MCVT contains 145 height values (floats)
if (size < 145 * sizeof(float)) {
if (size < kMCVTVertexCount * sizeof(float)) {
LOG_WARNING("MCVT chunk too small: ", size, " bytes");
return;
}
float minHeight = 999999.0f;
float maxHeight = -999999.0f;
float minHeight = std::numeric_limits<float>::max();
float maxHeight = std::numeric_limits<float>::lowest();
for (int i = 0; i < 145; i++) {
for (int i = 0; i < kMCVTVertexCount; i++) {
float height = readFloat(data, i * sizeof(float));
chunk.heightMap.heights[i] = height;
@ -386,13 +387,13 @@ void ADTLoader::parseMCVT(const uint8_t* data, size_t size, MapChunk& chunk) {
}
void ADTLoader::parseMCNR(const uint8_t* data, size_t size, MapChunk& chunk) {
// MCNR contains 145 normals (3 bytes each, signed)
if (size < 145 * 3) {
// MCNR: one signed XYZ normal per vertex (3 bytes each)
if (size < kMCVTVertexCount * 3) {
LOG_WARNING("MCNR chunk too small: ", size, " bytes");
return;
}
for (int i = 0; i < 145 * 3; i++) {
for (int i = 0; i < kMCVTVertexCount * 3; i++) {
chunk.normals[i] = static_cast<int8_t>(data[i]);
}
}

View file

@ -233,6 +233,7 @@ BLPImage AssetManager::tryLoadPngOverride(const std::string& normalizedPath) con
if (fsPath.empty()) return BLPImage();
// Replace .blp/.BLP extension with .png
if (fsPath.size() < 4) return BLPImage();
std::string pngPath = fsPath.substr(0, fsPath.size() - 4) + ".png";
if (!LooseFileReader::fileExists(pngPath)) {
return BLPImage();

View file

@ -209,7 +209,8 @@ void BLPLoader::decompressDXT1(const uint8_t* src, uint8_t* dst, int width, int
uint16_t c0 = block[0] | (block[1] << 8);
uint16_t c1 = block[2] | (block[3] << 8);
// Convert RGB565 to RGB888
// Convert RGB565 to RGB888: extract 5/6/5-bit channels and scale to [0..255].
// R = bits[15:11] (5-bit, /31), G = bits[10:5] (6-bit, /63), B = bits[4:0] (5-bit, /31)
uint8_t r0 = ((c0 >> 11) & 0x1F) * 255 / 31;
uint8_t g0 = ((c0 >> 5) & 0x3F) * 255 / 63;
uint8_t b0 = (c0 & 0x1F) * 255 / 31;
@ -303,7 +304,7 @@ void BLPLoader::decompressDXT3(const uint8_t* src, uint8_t* dst, int width, int
case 3: pixel[0] = (r0 + 2*r1) / 3; pixel[1] = (g0 + 2*g1) / 3; pixel[2] = (b0 + 2*b1) / 3; break;
}
// Apply 4-bit alpha
// Apply 4-bit alpha: scale [0..15] → [0..255] via (n * 255 / 15)
int alphaIndex = py * 4 + px;
uint8_t alpha4 = (alphaBlock >> (alphaIndex * 4)) & 0xF;
pixel[3] = alpha4 * 255 / 15;
@ -416,7 +417,8 @@ void BLPLoader::decompressPalette(const uint8_t* src, uint8_t* dst, const uint32
if (alphaDepth == 8) {
dst[i * 4 + 3] = alphaData[i];
} else if (alphaDepth == 4) {
// 4-bit alpha: 2 pixels per byte
// 4-bit alpha: 2 pixels packed per byte (low nibble first).
// Multiply by 17 to scale [0..15] → [0..255] (equivalent to n * 255 / 15).
uint8_t alphaByte = alphaData[i / 2];
dst[i * 4 + 3] = (i % 2 == 0) ? ((alphaByte & 0x0F) * 17) : ((alphaByte >> 4) * 17);
} else if (alphaDepth == 1) {

View file

@ -64,7 +64,8 @@ bool DBCFile::load(const std::vector<uint8_t>& dbcData) {
return false;
}
// Validate record size matches field count
// DBC fields are fixed-width uint32 (4 bytes each); record size must match.
// Mismatches indicate a corrupted header or unsupported DBC variant.
if (recordSize != fieldCount * 4) {
LOG_WARNING("DBC record size mismatch: recordSize=", recordSize,
" but fieldCount*4=", fieldCount * 4);

View file

@ -384,11 +384,15 @@ std::string readString(const std::vector<uint8_t>& data, uint32_t offset, uint32
enum class TrackType { VEC3, QUAT_COMPRESSED, FLOAT };
// M2 sequence flag: when set, keyframe data is embedded in the M2 file.
// When clear, data lives in an external .anim file and the M2 offsets are
// .anim-relative — reading them from the M2 produces garbage.
constexpr uint32_t kM2SeqFlagEmbeddedData = 0x20;
// Parse an M2 animation track from the binary data.
// The track uses an "array of arrays" layout: nTimestamps pairs of {count, offset}.
// sequenceFlags: per-sequence flags; sequences WITHOUT flag 0x20 store their keyframe
// data in external .anim files, so their sub-array offsets are .anim-relative and must
// be skipped when reading from the M2 file.
// sequenceFlags: per-sequence flags; sequences without kM2SeqFlagEmbeddedData store
// their keyframe data in external .anim files, so their sub-array offsets must be skipped.
void parseAnimTrack(const std::vector<uint8_t>& data,
const M2TrackDisk& disk,
M2AnimationTrack& track,
@ -408,7 +412,7 @@ void parseAnimTrack(const std::vector<uint8_t>& data,
// Sequences without flag 0x20 have their animation data in external .anim files.
// Their sub-array offsets are .anim-file-relative, not M2-relative, so reading
// from the M2 file would produce garbage data.
if (i < sequenceFlags.size() && !(sequenceFlags[i] & 0x20)) continue;
if (i < sequenceFlags.size() && !(sequenceFlags[i] & kM2SeqFlagEmbeddedData)) continue;
// Each sub-array header is {uint32_t count, uint32_t offset} = 8 bytes
uint32_t tsHeaderOfs = disk.ofsTimestamps + i * 8;
uint32_t keyHeaderOfs = disk.ofsKeys + i * 8;
@ -1328,7 +1332,7 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
if (nSeqs > 0 && nSeqs <= 4096) {
track.sequences.resize(nSeqs);
for (uint32_t s = 0; s < nSeqs; s++) {
if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue;
if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & kM2SeqFlagEmbeddedData)) continue;
uint32_t tsHdr = disk.ofsTimestamps + s * 8;
uint32_t keyHdr = disk.ofsKeys + s * 8;
if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue;

View file

@ -1,565 +0,0 @@
#include "pipeline/mpq_manager.hpp"
#include "core/logger.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <limits>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <cctype>
#ifdef HAVE_STORMLIB
#include <StormLib.h>
#endif
// Define HANDLE and INVALID_HANDLE_VALUE for both cases
#ifndef HAVE_STORMLIB
typedef void* HANDLE;
#endif
#ifndef INVALID_HANDLE_VALUE
#define INVALID_HANDLE_VALUE ((HANDLE)(long long)-1)
#endif
namespace wowee {
namespace pipeline {
namespace {
std::string toLowerCopy(std::string value) {
std::transform(value.begin(), value.end(), value.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return value;
}
std::string normalizeVirtualFilenameForLookup(std::string value) {
// StormLib uses backslash-separated virtual paths; treat lookups as case-insensitive.
std::replace(value.begin(), value.end(), '/', '\\');
value = toLowerCopy(std::move(value));
while (!value.empty() && (value.front() == '\\' || value.front() == '/')) {
value.erase(value.begin());
}
return value;
}
bool envFlagEnabled(const char* name) {
const char* v = std::getenv(name);
if (!v || !*v) {
return false;
}
std::string s = toLowerCopy(v);
return s == "1" || s == "true" || s == "yes" || s == "on";
}
size_t envSizeTOrDefault(const char* name, size_t defValue) {
const char* v = std::getenv(name);
if (!v || !*v) return defValue;
char* end = nullptr;
unsigned long long value = std::strtoull(v, &end, 10);
if (end == v || value == 0) return defValue;
if (value > static_cast<unsigned long long>(std::numeric_limits<size_t>::max())) return defValue;
return static_cast<size_t>(value);
}
}
MPQManager::MPQManager() = default;
MPQManager::~MPQManager() {
shutdown();
}
bool MPQManager::initialize(const std::string& dataPath_) {
if (initialized) {
LOG_WARNING("MPQManager already initialized");
return true;
}
dataPath = dataPath_;
LOG_INFO("Initializing MPQ manager with data path: ", dataPath);
// Guard against cache blowups from huge numbers of unique probes.
fileArchiveCacheMaxEntries_ = envSizeTOrDefault("WOWEE_MPQ_ARCHIVE_CACHE_MAX", fileArchiveCacheMaxEntries_);
fileArchiveCacheMisses_ = envFlagEnabled("WOWEE_MPQ_CACHE_MISSES");
LOG_INFO("MPQ archive lookup cache: maxEntries=", fileArchiveCacheMaxEntries_,
" cacheMisses=", (fileArchiveCacheMisses_ ? "yes" : "no"));
// Check if data directory exists
if (!std::filesystem::exists(dataPath)) {
LOG_ERROR("Data directory does not exist: ", dataPath);
return false;
}
#ifdef HAVE_STORMLIB
// Load base archives (in order of priority)
std::vector<std::string> baseArchives = {
"common.MPQ",
"common-2.MPQ",
"expansion.MPQ",
"lichking.MPQ",
};
for (const auto& archive : baseArchives) {
std::string fullPath = dataPath + "/" + archive;
if (std::filesystem::exists(fullPath)) {
loadArchive(fullPath, 100); // Base archives have priority 100
} else {
LOG_DEBUG("Base archive not found (optional): ", archive);
}
}
// Load patch archives (highest priority)
loadPatchArchives();
// Load locale archives — auto-detect from available locale directories
{
// Prefer the locale override from environment, then scan for installed ones
const char* localeEnv = std::getenv("WOWEE_LOCALE");
std::string detectedLocale;
if (localeEnv && localeEnv[0] != '\0') {
detectedLocale = localeEnv;
LOG_INFO("Using locale from WOWEE_LOCALE env: ", detectedLocale);
} else {
// Priority order: enUS first, then other common locales
static const std::array<const char*, 12> knownLocales = {
"enUS", "enGB", "deDE", "frFR", "esES", "esMX",
"zhCN", "zhTW", "koKR", "ruRU", "ptBR", "itIT"
};
for (const char* loc : knownLocales) {
if (std::filesystem::exists(dataPath + "/" + loc)) {
detectedLocale = loc;
LOG_INFO("Auto-detected WoW locale: ", detectedLocale);
break;
}
}
if (detectedLocale.empty()) {
detectedLocale = "enUS";
LOG_WARNING("No locale directory found in data path; defaulting to enUS");
}
}
loadLocaleArchives(detectedLocale);
}
if (archives.empty()) {
LOG_WARNING("No MPQ archives loaded - will use loose file fallback");
} else {
LOG_INFO("MPQ manager initialized with ", archives.size(), " archives");
}
#else
LOG_WARNING("StormLib not available - using loose file fallback only");
#endif
initialized = true;
return true;
}
void MPQManager::shutdown() {
if (!initialized) {
return;
}
#ifdef HAVE_STORMLIB
LOG_INFO("Shutting down MPQ manager");
for (auto& entry : archives) {
if (entry.handle != INVALID_HANDLE_VALUE) {
SFileCloseArchive(entry.handle);
}
}
#endif
archives.clear();
archiveNames.clear();
{
std::lock_guard<std::shared_mutex> lock(fileArchiveCacheMutex_);
fileArchiveCache_.clear();
}
{
std::lock_guard<std::mutex> lock(missingFileMutex_);
missingFileWarnings_.clear();
}
initialized = false;
}
bool MPQManager::loadArchive(const std::string& path, int priority) {
#ifndef HAVE_STORMLIB
LOG_ERROR("Cannot load archive - StormLib not available");
return false;
#endif
#ifdef HAVE_STORMLIB
// Check if file exists
if (!std::filesystem::exists(path)) {
LOG_ERROR("Archive file not found: ", path);
return false;
}
HANDLE handle = INVALID_HANDLE_VALUE;
if (!SFileOpenArchive(path.c_str(), 0, 0, &handle)) {
LOG_ERROR("Failed to open MPQ archive: ", path);
return false;
}
ArchiveEntry entry;
entry.handle = handle;
entry.path = path;
entry.priority = priority;
archives.push_back(entry);
archiveNames.push_back(path);
// Sort archives by priority (highest first)
std::sort(archives.begin(), archives.end(),
[](const ArchiveEntry& a, const ArchiveEntry& b) {
return a.priority > b.priority;
});
// Archive set/priority changed, so cached filename -> archive mappings may be stale.
{
std::lock_guard<std::shared_mutex> lock(fileArchiveCacheMutex_);
fileArchiveCache_.clear();
}
LOG_INFO("Loaded MPQ archive: ", path, " (priority ", priority, ")");
return true;
#endif
return false;
}
bool MPQManager::fileExists(const std::string& filename) const {
#ifdef HAVE_STORMLIB
// Check MPQ archives first if available
if (!archives.empty()) {
HANDLE archive = findFileArchive(filename);
if (archive != INVALID_HANDLE_VALUE) {
return true;
}
}
#endif
// Fall back to checking for loose file
std::string loosePath = filename;
std::replace(loosePath.begin(), loosePath.end(), '\\', '/');
std::string fullPath = dataPath + "/" + loosePath;
return std::filesystem::exists(fullPath);
}
std::vector<uint8_t> MPQManager::readFile(const std::string& filename) const {
#ifdef HAVE_STORMLIB
// Try MPQ archives first if available
if (!archives.empty()) {
HANDLE archive = findFileArchive(filename);
if (archive != INVALID_HANDLE_VALUE) {
std::string stormFilename = filename;
std::replace(stormFilename.begin(), stormFilename.end(), '/', '\\');
// Open the file
HANDLE file = INVALID_HANDLE_VALUE;
if (SFileOpenFileEx(archive, stormFilename.c_str(), 0, &file)) {
// Get file size
DWORD fileSize = SFileGetFileSize(file, nullptr);
if (fileSize > 0 && fileSize != SFILE_INVALID_SIZE) {
// Read file data
std::vector<uint8_t> data(fileSize);
DWORD bytesRead = 0;
if (SFileReadFile(file, data.data(), fileSize, &bytesRead, nullptr)) {
SFileCloseFile(file);
LOG_DEBUG("Read file from MPQ: ", filename, " (", bytesRead, " bytes)");
return data;
}
}
SFileCloseFile(file);
}
}
}
#endif
// Fall back to loose file loading
// Convert WoW path (backslashes) to filesystem path (forward slashes)
std::string loosePath = filename;
std::replace(loosePath.begin(), loosePath.end(), '\\', '/');
// Try with original case
std::string fullPath = dataPath + "/" + loosePath;
if (std::filesystem::exists(fullPath)) {
std::ifstream file(fullPath, std::ios::binary | std::ios::ate);
if (file.is_open()) {
size_t size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> data(size);
file.read(reinterpret_cast<char*>(data.data()), size);
LOG_DEBUG("Read loose file: ", loosePath, " (", size, " bytes)");
return data;
}
}
// Try case-insensitive search (common for Linux)
std::filesystem::path searchPath = dataPath;
std::vector<std::string> pathComponents;
std::istringstream iss(loosePath);
std::string component;
while (std::getline(iss, component, '/')) {
if (!component.empty()) {
pathComponents.push_back(component);
}
}
// Try to find file with case-insensitive matching
for (const auto& comp : pathComponents) {
bool found = false;
if (std::filesystem::exists(searchPath) && std::filesystem::is_directory(searchPath)) {
for (const auto& entry : std::filesystem::directory_iterator(searchPath)) {
std::string entryName = entry.path().filename().string();
// Case-insensitive comparison
if (std::equal(comp.begin(), comp.end(), entryName.begin(), entryName.end(),
[](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); })) {
searchPath = entry.path();
found = true;
break;
}
}
}
if (!found) {
logMissingFileOnce(filename);
return std::vector<uint8_t>();
}
}
// Try to read the found file
if (std::filesystem::exists(searchPath) && std::filesystem::is_regular_file(searchPath)) {
std::ifstream file(searchPath, std::ios::binary | std::ios::ate);
if (file.is_open()) {
size_t size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> data(size);
file.read(reinterpret_cast<char*>(data.data()), size);
LOG_DEBUG("Read loose file (case-insensitive): ", searchPath.string(), " (", size, " bytes)");
return data;
}
}
logMissingFileOnce(filename);
return std::vector<uint8_t>();
}
void MPQManager::logMissingFileOnce(const std::string& filename) const {
std::string normalized = toLowerCopy(filename);
std::lock_guard<std::mutex> lock(missingFileMutex_);
if (missingFileWarnings_.insert(normalized).second) {
LOG_WARNING("File not found: ", filename);
}
}
uint32_t MPQManager::getFileSize(const std::string& filename) const {
#ifndef HAVE_STORMLIB
return 0;
#endif
#ifdef HAVE_STORMLIB
HANDLE archive = findFileArchive(filename);
if (archive == INVALID_HANDLE_VALUE) {
return 0;
}
std::string stormFilename = filename;
std::replace(stormFilename.begin(), stormFilename.end(), '/', '\\');
HANDLE file = INVALID_HANDLE_VALUE;
if (!SFileOpenFileEx(archive, stormFilename.c_str(), 0, &file)) {
return 0;
}
DWORD fileSize = SFileGetFileSize(file, nullptr);
SFileCloseFile(file);
return (fileSize == SFILE_INVALID_SIZE) ? 0 : fileSize;
#endif
return 0;
}
HANDLE MPQManager::findFileArchive(const std::string& filename) const {
#ifndef HAVE_STORMLIB
return INVALID_HANDLE_VALUE;
#endif
#ifdef HAVE_STORMLIB
std::string cacheKey = normalizeVirtualFilenameForLookup(filename);
{
std::shared_lock<std::shared_mutex> lock(fileArchiveCacheMutex_);
auto it = fileArchiveCache_.find(cacheKey);
if (it != fileArchiveCache_.end()) {
return it->second;
}
}
std::string stormFilename = filename;
std::replace(stormFilename.begin(), stormFilename.end(), '/', '\\');
const auto start = std::chrono::steady_clock::now();
HANDLE found = INVALID_HANDLE_VALUE;
// Search archives in priority order (already sorted)
for (const auto& entry : archives) {
if (SFileHasFile(entry.handle, stormFilename.c_str())) {
found = entry.handle;
break;
}
}
const auto end = std::chrono::steady_clock::now();
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
// Avoid caching misses unless explicitly enabled; miss caching can explode memory when
// code probes many unique non-existent paths (common with HD patch sets).
if (found == INVALID_HANDLE_VALUE && !fileArchiveCacheMisses_) {
if (ms >= 100) {
LOG_WARNING("Slow MPQ lookup: '", filename, "' scanned ", archives.size(), " archives in ", ms, " ms");
}
return found;
}
{
std::lock_guard<std::shared_mutex> lock(fileArchiveCacheMutex_);
if (fileArchiveCache_.size() >= fileArchiveCacheMaxEntries_) {
// Simple safety valve: clear the cache rather than allowing an unbounded growth.
LOG_WARNING("MPQ archive lookup cache cleared (size=", fileArchiveCache_.size(),
" reached maxEntries=", fileArchiveCacheMaxEntries_, ")");
fileArchiveCache_.clear();
}
// Another thread may have raced to populate; if so, prefer the existing value.
auto [it, inserted] = fileArchiveCache_.emplace(std::move(cacheKey), found);
if (!inserted) {
found = it->second;
}
}
// With caching this should only happen once per unique filename; keep threshold conservative.
if (ms >= 100) {
LOG_WARNING("Slow MPQ lookup: '", filename, "' scanned ", archives.size(), " archives in ", ms, " ms");
}
return found;
#endif
return INVALID_HANDLE_VALUE;
}
bool MPQManager::loadPatchArchives() {
#ifndef HAVE_STORMLIB
return false;
#endif
const bool disableLetterPatches = envFlagEnabled("WOWEE_DISABLE_LETTER_PATCHES");
const bool disableNumericPatches = envFlagEnabled("WOWEE_DISABLE_NUMERIC_PATCHES");
if (disableLetterPatches) {
LOG_WARNING("MPQ letter patches disabled via WOWEE_DISABLE_LETTER_PATCHES=1");
}
if (disableNumericPatches) {
LOG_WARNING("MPQ numeric patches disabled via WOWEE_DISABLE_NUMERIC_PATCHES=1");
}
// WoW 3.3.5a patch archives (in order of priority, highest first)
std::vector<std::pair<std::string, int>> patchArchives = {
// Lettered patch MPQs are used by some clients/distributions (e.g. Patch-A.mpq..Patch-E.mpq).
// Treat them as higher priority than numeric patch MPQs.
// Keep priorities well above numeric patch-*.MPQ so lettered patches always win when both exist.
{"Patch-Z.mpq", 925}, {"Patch-Y.mpq", 924}, {"Patch-X.mpq", 923}, {"Patch-W.mpq", 922},
{"Patch-V.mpq", 921}, {"Patch-U.mpq", 920}, {"Patch-T.mpq", 919}, {"Patch-S.mpq", 918},
{"Patch-R.mpq", 917}, {"Patch-Q.mpq", 916}, {"Patch-P.mpq", 915}, {"Patch-O.mpq", 914},
{"Patch-N.mpq", 913}, {"Patch-M.mpq", 912}, {"Patch-L.mpq", 911}, {"Patch-K.mpq", 910},
{"Patch-J.mpq", 909}, {"Patch-I.mpq", 908}, {"Patch-H.mpq", 907}, {"Patch-G.mpq", 906},
{"Patch-F.mpq", 905}, {"Patch-E.mpq", 904}, {"Patch-D.mpq", 903}, {"Patch-C.mpq", 902},
{"Patch-B.mpq", 901}, {"Patch-A.mpq", 900},
// Lowercase variants (Linux case-sensitive filesystems).
{"patch-z.mpq", 825}, {"patch-y.mpq", 824}, {"patch-x.mpq", 823}, {"patch-w.mpq", 822},
{"patch-v.mpq", 821}, {"patch-u.mpq", 820}, {"patch-t.mpq", 819}, {"patch-s.mpq", 818},
{"patch-r.mpq", 817}, {"patch-q.mpq", 816}, {"patch-p.mpq", 815}, {"patch-o.mpq", 814},
{"patch-n.mpq", 813}, {"patch-m.mpq", 812}, {"patch-l.mpq", 811}, {"patch-k.mpq", 810},
{"patch-j.mpq", 809}, {"patch-i.mpq", 808}, {"patch-h.mpq", 807}, {"patch-g.mpq", 806},
{"patch-f.mpq", 805}, {"patch-e.mpq", 804}, {"patch-d.mpq", 803}, {"patch-c.mpq", 802},
{"patch-b.mpq", 801}, {"patch-a.mpq", 800},
{"patch-5.MPQ", 500},
{"patch-4.MPQ", 400},
{"patch-3.MPQ", 300},
{"patch-2.MPQ", 200},
{"patch.MPQ", 150},
};
// Build a case-insensitive lookup of files in the data directory so that
// Patch-A.MPQ, patch-a.mpq, PATCH-A.MPQ, etc. all resolve correctly on
// case-sensitive filesystems (Linux).
std::unordered_map<std::string, std::string> lowerToActual; // lowercase name → actual path
if (std::filesystem::is_directory(dataPath)) {
for (const auto& entry : std::filesystem::directory_iterator(dataPath)) {
if (!entry.is_regular_file()) continue;
std::string fname = entry.path().filename().string();
std::string lower = toLowerCopy(fname);
lowerToActual[lower] = entry.path().string();
}
}
int loadedPatches = 0;
for (const auto& [archive, priority] : patchArchives) {
// Classify letter vs numeric patch for the disable flags
std::string lowerArchive = toLowerCopy(archive);
const bool isLetterPatch =
(lowerArchive.size() >= 11) && // "patch-X.mpq" = 11 chars
(lowerArchive.rfind("patch-", 0) == 0) && // starts with "patch-"
(lowerArchive[6] >= 'a' && lowerArchive[6] <= 'z'); // letter after dash
if (isLetterPatch && disableLetterPatches) {
continue;
}
if (!isLetterPatch && disableNumericPatches) {
continue;
}
// Case-insensitive file lookup
auto it = lowerToActual.find(lowerArchive);
if (it != lowerToActual.end()) {
if (loadArchive(it->second, priority)) {
loadedPatches++;
}
}
}
LOG_INFO("Loaded ", loadedPatches, " patch archives");
return loadedPatches > 0;
}
bool MPQManager::loadLocaleArchives(const std::string& locale) {
#ifndef HAVE_STORMLIB
return false;
#endif
std::string localePath = dataPath + "/" + locale;
if (!std::filesystem::exists(localePath)) {
LOG_WARNING("Locale directory not found: ", localePath);
return false;
}
// Locale-specific archives (including speech MPQs for NPC voices)
std::vector<std::pair<std::string, int>> localeArchives = {
{"locale-" + locale + ".MPQ", 250},
{"speech-" + locale + ".MPQ", 240}, // Base speech/NPC voices
{"expansion-speech-" + locale + ".MPQ", 245}, // TBC speech
{"lichking-speech-" + locale + ".MPQ", 248}, // WotLK speech
{"patch-" + locale + ".MPQ", 450},
{"patch-" + locale + "-2.MPQ", 460},
{"patch-" + locale + "-3.MPQ", 470},
};
int loadedLocale = 0;
for (const auto& [archive, priority] : localeArchives) {
std::string fullPath = localePath + "/" + archive;
if (std::filesystem::exists(fullPath)) {
if (loadArchive(fullPath, priority)) {
loadedLocale++;
}
}
}
LOG_INFO("Loaded ", loadedLocale, " locale archives for ", locale);
return loadedLocale > 0;
}
} // namespace pipeline
} // namespace wowee

View file

@ -291,9 +291,11 @@ WMOModel WMOLoader::load(const std::vector<uint8_t>& wmoData) {
for (uint32_t i = 0; i < nDoodads; i++) {
WMODoodad doodad;
// Name index (3 bytes) + flags (1 byte)
// WMO doodad placement: name index packed in lower 24 bits, flags in upper 8.
// The name index is an offset into the MODN string table (doodad names).
constexpr uint32_t kDoodadNameIndexMask = 0x00FFFFFF;
uint32_t nameAndFlags = read<uint32_t>(wmoData, offset);
doodad.nameIndex = nameAndFlags & 0x00FFFFFF;
doodad.nameIndex = nameAndFlags & kDoodadNameIndexMask;
doodad.position.x = read<float>(wmoData, offset);
doodad.position.y = read<float>(wmoData, offset);

View file

@ -48,7 +48,10 @@ glm::vec3 Camera::getUp() const {
}
void Camera::setJitter(float jx, float jy) {
// Remove old jitter, apply new
// Sub-pixel jitter for temporal anti-aliasing (TAA / FSR2).
// Column 2 of the projection matrix holds the NDC x/y offset — modifying
// [2][0] and [2][1] shifts the entire rendered image by a sub-pixel amount
// each frame, giving the upscaler different sample positions to reconstruct.
projectionMatrix[2][0] -= jitterOffset.x;
projectionMatrix[2][1] -= jitterOffset.y;
jitterOffset = glm::vec2(jx, jy);

View file

@ -191,8 +191,11 @@ void CameraController::update(float deltaTime) {
// Compute camera position
glm::vec3 actualCam;
if (actualDist < MIN_DISTANCE + 0.1f) {
actualCam = pivot + forward3D * 0.1f;
// Small offset prevents the camera from clipping into the character
// model when collision pushes it to near-minimum distance.
constexpr float kCameraClipEpsilon = 0.1f;
if (actualDist < MIN_DISTANCE + kCameraClipEpsilon) {
actualCam = pivot + forward3D * kCameraClipEpsilon;
} else {
actualCam = pivot + camDir * actualDist;
}

View file

@ -301,7 +301,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
auto model = pipeline::M2Loader::load(m2Data);
// Load skin file (only for WotLK M2s - vanilla has embedded skin)
// M2 version 264+ (WotLK) stores submesh/bone data in external .skin files.
// Earlier versions (Classic ≤256, TBC ≤263) have skin data embedded in the M2.
std::string skinPath = modelDir + baseName + "00.skin";
auto skinData = assetManager_->readFile(skinPath);
if (!skinData.empty() && model.version >= 264) {
@ -398,6 +399,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
auto& tex = model.textures[ti];
LOG_INFO(" Model texture[", ti, "]: type=", tex.type,
" filename='", tex.filename, "'");
// M2 texture types: 1=character skin, 6=hair/scalp. Empty filename means
// the texture is resolved at runtime via CharSections.dbc lookup.
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) {
tex.filename = bodySkinPath_;
} else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) {
@ -405,7 +408,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
}
}
// Load external .anim files
// Load external .anim files for sequences that store keyframes outside the M2.
// Flag 0x20 = embedded data; when clear, animation lives in {ModelName}{SeqID}-{Var}.anim
for (uint32_t si = 0; si < model.sequences.size(); si++) {
if (!(model.sequences[si].flags & 0x20)) {
char animFileName[256];
@ -582,6 +586,9 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
};
// --- Geosets ---
// M2 geoset IDs encode body part group × 100 + variant (e.g., 801 = group 8
// (sleeves) variant 1, 1301 = group 13 (pants) variant 1). ItemDisplayInfo.dbc
// provides the variant offset per equipped item; base IDs are per-group constants.
std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 99; i++) geosets.insert(i);
geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style

View file

@ -278,29 +278,7 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram
charVert.destroy();
charFrag.destroy();
// --- Create white fallback texture ---
{
uint8_t white[] = {255, 255, 255, 255};
whiteTexture_ = std::make_unique<VkTexture>();
whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
whiteTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
// --- Create transparent fallback texture ---
{
uint8_t transparent[] = {0, 0, 0, 0};
transparentTexture_ = std::make_unique<VkTexture>();
transparentTexture_->upload(*vkCtx_, transparent, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
transparentTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
// --- Create flat normal placeholder texture (128,128,255,128) = neutral normal, 0.5 height ---
{
uint8_t flatNormal[] = {128, 128, 255, 128};
flatNormalTexture_ = std::make_unique<VkTexture>();
flatNormalTexture_->upload(*vkCtx_, flatNormal, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
flatNormalTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
createFallbackTextures(device);
// Diagnostics-only: cache lifetime is currently tied to renderer lifetime.
textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 4096) * 1024ull * 1024ull;
@ -449,24 +427,7 @@ void CharacterRenderer::clear() {
whiteTexture_.reset();
transparentTexture_.reset();
flatNormalTexture_.reset();
{
uint8_t white[] = {255, 255, 255, 255};
whiteTexture_ = std::make_unique<VkTexture>();
whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
whiteTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
{
uint8_t transparent[] = {0, 0, 0, 0};
transparentTexture_ = std::make_unique<VkTexture>();
transparentTexture_->upload(*vkCtx_, transparent, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
transparentTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
{
uint8_t flatNormal[] = {128, 128, 255, 128};
flatNormalTexture_ = std::make_unique<VkTexture>();
flatNormalTexture_->upload(*vkCtx_, flatNormal, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
flatNormalTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
createFallbackTextures(device);
models.clear();
instances.clear();
@ -487,6 +448,30 @@ void CharacterRenderer::clear() {
}
}
void CharacterRenderer::createFallbackTextures(VkDevice device) {
// White: default diffuse when no texture is assigned
{
uint8_t white[] = {255, 255, 255, 255};
whiteTexture_ = std::make_unique<VkTexture>();
whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
whiteTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
// Transparent: placeholder for optional overlay layers (e.g. hair highlights)
{
uint8_t transparent[] = {0, 0, 0, 0};
transparentTexture_ = std::make_unique<VkTexture>();
transparentTexture_->upload(*vkCtx_, transparent, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
transparentTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
// Flat normal: neutral normal map (128,128,255) + 0.5 height in alpha channel
{
uint8_t flatNormal[] = {128, 128, 255, 128};
flatNormalTexture_ = std::make_unique<VkTexture>();
flatNormalTexture_->upload(*vkCtx_, flatNormal, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
flatNormalTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
}
void CharacterRenderer::destroyModelGPU(M2ModelGPU& gpuModel) {
if (!vkCtx_) return;
VmaAllocator alloc = vkCtx_->getAllocator();

View file

@ -474,11 +474,13 @@ void ChargeEffect::emit(const glm::vec3& position, const glm::vec3& direction) {
// Spawn dust puffs at feet
glm::vec3 horizDir = glm::vec3(direction.x, direction.y, 0.0f);
float horizLenSq = glm::dot(horizDir, horizDir);
// Skip dust when character is nearly stationary — prevents NaN from inversesqrt(0)
if (horizLenSq < 1e-6f) return;
float invHorizLen = glm::inversesqrt(horizLenSq);
glm::vec3 backDir = -horizDir * invHorizLen;
glm::vec3 sideDir = glm::vec3(-backDir.y, backDir.x, 0.0f);
// Accumulate ~0.48 per frame at 60fps (30 particles/sec * 16ms); emit when >= 1.0
dustAccum_ += 30.0f * 0.016f;
while (dustAccum_ >= 1.0f && dustPuffs_.size() < MAX_DUST) {
dustAccum_ -= 1.0f;

View file

@ -64,7 +64,10 @@ void Frustum::extractFromMatrix(const glm::mat4& vp) {
void Frustum::normalizePlane(Plane& plane) {
float lenSq = glm::dot(plane.normal, plane.normal);
if (lenSq > 0.00000001f) {
// Skip normalization for degenerate planes (near-zero normal) to avoid
// division by zero or amplifying floating-point noise into huge normals.
constexpr float kMinNormalLenSq = 1e-8f;
if (lenSq > kMinNormalLenSq) {
float invLen = glm::inversesqrt(lenSq);
plane.normal *= invLen;
plane.distance *= invLen;

View file

@ -1,4 +1,5 @@
#include "rendering/lighting_manager.hpp"
#include <glm/gtc/constants.hpp>
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "pipeline/dbc_layout.hpp"
@ -13,6 +14,10 @@ namespace rendering {
// Light coordinate scaling (test with 1.0f first, then try 36.0f if distances seem off)
constexpr float LIGHT_COORD_SCALE = 1.0f;
// WoW's Light.dbc stores time-of-day as half-minutes (0..2879).
// 24 hours × 60 minutes × 2 = 2880 half-minute ticks per day cycle.
constexpr uint16_t kHalfMinutesPerDay = 2880;
// Maximum volumes to blend (top 2-4)
constexpr size_t MAX_BLEND_VOLUMES = 2;
@ -171,7 +176,7 @@ bool LightingManager::loadLightBandDbcs(pipeline::AssetManager* assetManager) {
uint32_t timeKeyBase = libL ? (*libL)["TimeKey0"] : 3;
for (uint8_t k = 0; k < band.numKeyframes && k < 16; ++k) {
uint32_t timeValue = dbc->getUInt32(i, timeKeyBase + k);
band.times[k] = static_cast<uint16_t>(timeValue % 2880); // Clamp to valid range
band.times[k] = static_cast<uint16_t>(timeValue % kHalfMinutesPerDay); // Clamp to valid range
}
// Read color values (field 19-34) - stored as BGRA packed uint32
@ -213,7 +218,7 @@ bool LightingManager::loadLightBandDbcs(pipeline::AssetManager* assetManager) {
uint32_t timeKeyBase = lfbL ? (*lfbL)["TimeKey0"] : 3;
for (uint8_t k = 0; k < band.numKeyframes && k < 16; ++k) {
uint32_t timeValue = dbc->getUInt32(i, timeKeyBase + k);
band.times[k] = static_cast<uint16_t>(timeValue % 2880); // Clamp to valid range
band.times[k] = static_cast<uint16_t>(timeValue % kHalfMinutesPerDay); // Clamp to valid range
}
// Read float values (field 19-34)
@ -253,7 +258,7 @@ void LightingManager::update(const glm::vec3& playerPos, uint32_t mapId,
// else: manualTime_ is set, use timeOfDay_ as-is
// Convert time to half-minutes (WoW DBC format: 0-2879)
uint16_t timeHalfMinutes = static_cast<uint16_t>(timeOfDay_ * 2880.0f) % 2880;
uint16_t timeHalfMinutes = static_cast<uint16_t>(timeOfDay_ * static_cast<float>(kHalfMinutesPerDay)) % kHalfMinutesPerDay;
// Update player position and map
currentPlayerPos_ = playerPos;
@ -317,7 +322,7 @@ void LightingManager::update(const glm::vec3& playerPos, uint32_t mapId,
newParams = fallbackParams_;
// Animate sun direction
float angle = timeOfDay_ * 2.0f * 3.14159f;
float angle = timeOfDay_ * glm::two_pi<float>();
newParams.directionalDir = glm::normalize(glm::vec3(
std::sin(angle) * 0.6f,
-0.6f + std::cos(angle) * 0.4f,
@ -477,7 +482,7 @@ LightingParams LightingManager::sampleLightParams(const LightParamsProfile* prof
}
// Compute sun direction from time
float angle = (timeHalfMinutes / 2880.0f) * 2.0f * 3.14159f;
float angle = (timeHalfMinutes / static_cast<float>(kHalfMinutesPerDay)) * glm::two_pi<float>();
params.directionalDir = glm::normalize(glm::vec3(
std::sin(angle) * 0.6f,
-0.6f + std::cos(angle) * 0.4f,
@ -514,8 +519,8 @@ glm::vec3 LightingManager::sampleColorBand(const ColorBand& band, uint16_t timeH
uint16_t t2 = band.times[idx2];
// Handle midnight wrap
uint16_t timeSpan = (t2 > t1) ? (t2 - t1) : (2880 - t1 + t2);
uint16_t elapsed = (timeHalfMinutes >= t1) ? (timeHalfMinutes - t1) : (2880 - t1 + timeHalfMinutes);
uint16_t timeSpan = (t2 > t1) ? (t2 - t1) : (kHalfMinutesPerDay - t1 + t2);
uint16_t elapsed = (timeHalfMinutes >= t1) ? (timeHalfMinutes - t1) : (kHalfMinutesPerDay - t1 + timeHalfMinutes);
float t = (timeSpan > 0) ? (static_cast<float>(elapsed) / static_cast<float>(timeSpan)) : 0.0f;
t = glm::clamp(t, 0.0f, 1.0f);
@ -549,8 +554,8 @@ float LightingManager::sampleFloatBand(const FloatBand& band, uint16_t timeHalfM
uint16_t t1 = band.times[idx1];
uint16_t t2 = band.times[idx2];
uint16_t timeSpan = (t2 > t1) ? (t2 - t1) : (2880 - t1 + t2);
uint16_t elapsed = (timeHalfMinutes >= t1) ? (timeHalfMinutes - t1) : (2880 - t1 + timeHalfMinutes);
uint16_t timeSpan = (t2 > t1) ? (t2 - t1) : (kHalfMinutesPerDay - t1 + t2);
uint16_t elapsed = (timeHalfMinutes >= t1) ? (timeHalfMinutes - t1) : (kHalfMinutesPerDay - t1 + timeHalfMinutes);
float t = (timeSpan > 0) ? (static_cast<float>(elapsed) / static_cast<float>(timeSpan)) : 0.0f;
t = glm::clamp(t, 0.0f, 1.0f);

View file

@ -1974,12 +1974,13 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
// --- Spin instance portals ---
static constexpr float PORTAL_SPIN_SPEED = 1.2f; // radians/sec
static constexpr float kTwoPi = 6.2831853f;
for (size_t idx : portalInstanceIndices_) {
if (idx >= instances.size()) continue;
auto& inst = instances[idx];
inst.portalSpinAngle += PORTAL_SPIN_SPEED * deltaTime;
if (inst.portalSpinAngle > 6.2831853f)
inst.portalSpinAngle -= 6.2831853f;
if (inst.portalSpinAngle > kTwoPi)
inst.portalSpinAngle -= kTwoPi;
inst.rotation.z = inst.portalSpinAngle;
inst.updateModelMatrix();
}
@ -1990,13 +1991,17 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
for (auto& instance : instances) {
instance.animTime += dtMs;
}
// Wrap animTime for particle-only instances so emission rate tracks keep looping
// Wrap animTime for particle-only instances so emission rate tracks keep looping.
// 3333ms chosen as a safe wrap period: long enough to cover the longest known M2
// particle emission cycle (~3s for torch/campfire effects) while preventing float
// precision loss that accumulates over hours of runtime.
static constexpr float kParticleWrapMs = 3333.0f;
for (size_t idx : particleOnlyInstanceIndices_) {
if (idx >= instances.size()) continue;
auto& instance = instances[idx];
// Use iterative subtraction instead of fmod() to preserve precision
while (instance.animTime > 3333.0f) {
instance.animTime -= 3333.0f;
while (instance.animTime > kParticleWrapMs) {
instance.animTime -= kParticleWrapMs;
}
}
@ -3155,11 +3160,14 @@ float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float ani
return glm::mix(keys.floatValues[i0], keys.floatValues[i1], frac);
}
// Interpolate an M2 FBlock (particle lifetime curve) at a given life ratio [0..1].
// FBlocks store per-lifetime keyframes for particle color, alpha, and scale.
// NOTE: interpFBlockFloat and interpFBlockVec3 share identical interpolation logic —
// if you fix a bug in one, update the other to match.
float M2Renderer::interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio) {
if (fb.floatValues.empty()) return 1.0f;
if (fb.floatValues.size() == 1 || fb.timestamps.empty()) return fb.floatValues[0];
lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f);
// Find surrounding timestamps
for (size_t i = 0; i < fb.timestamps.size() - 1; i++) {
if (lifeRatio <= fb.timestamps[i + 1]) {
float t0 = fb.timestamps[i];

View file

@ -1,56 +0,0 @@
#include "rendering/mesh.hpp"
namespace wowee {
namespace rendering {
Mesh::~Mesh() {
destroy();
}
void Mesh::create(const std::vector<Vertex>& vertices, const std::vector<uint32_t>& indices) {
indexCount = indices.size();
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices.data(), GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(uint32_t), indices.data(), GL_STATIC_DRAW);
// Position
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, position));
// Normal
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, normal));
// TexCoord
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, texCoord));
glBindVertexArray(0);
}
void Mesh::destroy() {
if (VAO) glDeleteVertexArrays(1, &VAO);
if (VBO) glDeleteBuffers(1, &VBO);
if (EBO) glDeleteBuffers(1, &EBO);
VAO = VBO = EBO = 0;
}
void Mesh::draw() const {
if (VAO && indexCount > 0) {
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
}
} // namespace rendering
} // namespace wowee

View file

@ -1,7 +1,6 @@
#include "rendering/renderer.hpp"
#include "rendering/camera.hpp"
#include "rendering/camera_controller.hpp"
#include "rendering/scene.hpp"
#include "rendering/terrain_renderer.hpp"
#include "rendering/terrain_manager.hpp"
#include "rendering/performance_hud.hpp"
@ -26,7 +25,6 @@
#include "rendering/minimap.hpp"
#include "rendering/world_map.hpp"
#include "rendering/quest_marker_renderer.hpp"
#include "rendering/shader.hpp"
#include "game/game_handler.hpp"
#include "pipeline/m2_loader.hpp"
#include <algorithm>
@ -673,9 +671,6 @@ bool Renderer::initialize(core::Window* win) {
cameraController->setUseWoWSpeed(true); // Use realistic WoW movement speed
cameraController->setMouseSensitivity(0.15f);
// Create scene
scene = std::make_unique<Scene>();
// Create performance HUD
performanceHUD = std::make_unique<PerformanceHUD>();
performanceHUD->setPosition(PerformanceHUD::Position::TOP_LEFT);
@ -877,7 +872,6 @@ void Renderer::shutdown() {
zoneManager.reset();
performanceHUD.reset();
scene.reset();
cameraController.reset();
camera.reset();
@ -1709,7 +1703,6 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h
}
// Ensure we have fallbacks for movement
if (mountAnims_.stand == 0) mountAnims_.stand = 0; // Force 0 even if not found
if (mountAnims_.run == 0) mountAnims_.run = mountAnims_.stand; // Fallback to stand if no run
core::Logger::getInstance().debug("Mount animation set: jumpStart=", mountAnims_.jumpStart,
@ -3502,7 +3495,9 @@ void Renderer::update(float deltaTime) {
bool isIndoor = insideWmo;
bool isSwimming = cameraController->isSwimming();
// Check if inside blacksmith (96048 = Goldshire blacksmith)
// Detect blacksmith buildings to play ambient forge/anvil sounds.
// 96048 is the WMO group ID for the Goldshire blacksmith interior.
// TODO: extend to other smithy WMO IDs (Ironforge, Orgrimmar, etc.)
bool isBlacksmith = (insideWmoId == 96048);
// Sync weather audio with visual weather system
@ -3582,8 +3577,8 @@ void Renderer::update(float deltaTime) {
lastLoggedWmoId = wmoModelId;
}
// Blacksmith detection
if (wmoModelId == 96048) { // Goldshire blacksmith
// Detect blacksmith WMO for ambient forge sounds
if (wmoModelId == 96048) { // Goldshire blacksmith interior
insideBlacksmith = true;
LOG_INFO("Detected blacksmith WMO ", wmoModelId);
}

View file

@ -1,24 +0,0 @@
#include "rendering/scene.hpp"
#include "rendering/mesh.hpp"
#include <algorithm>
namespace wowee {
namespace rendering {
void Scene::addMesh(std::shared_ptr<Mesh> mesh) {
meshes.push_back(std::move(mesh));
}
void Scene::removeMesh(const std::shared_ptr<Mesh>& mesh) {
auto it = std::find(meshes.begin(), meshes.end(), mesh);
if (it != meshes.end()) {
meshes.erase(it);
}
}
void Scene::clear() {
meshes.clear();
}
} // namespace rendering
} // namespace wowee

View file

@ -1,140 +0,0 @@
#include "rendering/shader.hpp"
#include "core/logger.hpp"
#include <fstream>
#include <sstream>
namespace wowee {
namespace rendering {
Shader::~Shader() {
if (program) glDeleteProgram(program);
if (vertexShader) glDeleteShader(vertexShader);
if (fragmentShader) glDeleteShader(fragmentShader);
}
bool Shader::loadFromFile(const std::string& vertexPath, const std::string& fragmentPath) {
// Load vertex shader
std::ifstream vFile(vertexPath);
if (!vFile.is_open()) {
LOG_ERROR("Failed to open vertex shader: ", vertexPath);
return false;
}
std::stringstream vStream;
vStream << vFile.rdbuf();
std::string vertexSource = vStream.str();
// Load fragment shader
std::ifstream fFile(fragmentPath);
if (!fFile.is_open()) {
LOG_ERROR("Failed to open fragment shader: ", fragmentPath);
return false;
}
std::stringstream fStream;
fStream << fFile.rdbuf();
std::string fragmentSource = fStream.str();
return compile(vertexSource, fragmentSource);
}
bool Shader::loadFromSource(const std::string& vertexSource, const std::string& fragmentSource) {
return compile(vertexSource, fragmentSource);
}
bool Shader::compile(const std::string& vertexSource, const std::string& fragmentSource) {
GLint success;
GLchar infoLog[512];
// Compile vertex shader
const char* vCode = vertexSource.c_str();
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vCode, nullptr);
glCompileShader(vertexShader);
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(vertexShader, 512, nullptr, infoLog);
LOG_ERROR("Vertex shader compilation failed: ", infoLog);
return false;
}
// Compile fragment shader
const char* fCode = fragmentSource.c_str();
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fCode, nullptr);
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(fragmentShader, 512, nullptr, infoLog);
LOG_ERROR("Fragment shader compilation failed: ", infoLog);
return false;
}
// Link program
program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(program, 512, nullptr, infoLog);
LOG_ERROR("Shader program linking failed: ", infoLog);
return false;
}
return true;
}
void Shader::use() const {
glUseProgram(program);
}
void Shader::unuse() const {
glUseProgram(0);
}
GLint Shader::getUniformLocation(const std::string& name) const {
// Check cache first
auto it = uniformLocationCache.find(name);
if (it != uniformLocationCache.end()) {
return it->second;
}
// Look up and cache
GLint location = glGetUniformLocation(program, name.c_str());
uniformLocationCache[name] = location;
return location;
}
void Shader::setUniform(const std::string& name, int value) {
glUniform1i(getUniformLocation(name), value);
}
void Shader::setUniform(const std::string& name, float value) {
glUniform1f(getUniformLocation(name), value);
}
void Shader::setUniform(const std::string& name, const glm::vec2& value) {
glUniform2fv(getUniformLocation(name), 1, &value[0]);
}
void Shader::setUniform(const std::string& name, const glm::vec3& value) {
glUniform3fv(getUniformLocation(name), 1, &value[0]);
}
void Shader::setUniform(const std::string& name, const glm::vec4& value) {
glUniform4fv(getUniformLocation(name), 1, &value[0]);
}
void Shader::setUniform(const std::string& name, const glm::mat3& value) {
glUniformMatrix3fv(getUniformLocation(name), 1, GL_FALSE, &value[0][0]);
}
void Shader::setUniform(const std::string& name, const glm::mat4& value) {
glUniformMatrix4fv(getUniformLocation(name), 1, GL_FALSE, &value[0][0]);
}
void Shader::setUniformMatrixArray(const std::string& name, const glm::mat4* matrices, int count) {
glUniformMatrix4fv(getUniformLocation(name), count, GL_FALSE, &matrices[0][0][0]);
}
} // namespace rendering
} // namespace wowee

View file

@ -202,10 +202,9 @@ void Skybox::update(float deltaTime) {
}
void Skybox::setTimeOfDay(float time) {
// Clamp to 0-24 range
while (time < 0.0f) time += 24.0f;
while (time >= 24.0f) time -= 24.0f;
// Wrap to [0, 24) range using fmod instead of iterative subtraction
time = std::fmod(time, 24.0f);
if (time < 0.0f) time += 24.0f;
timeOfDay = time;
}

View file

@ -13,6 +13,14 @@
namespace wowee {
namespace rendering {
// Day/night cycle thresholds (hours, 24h clock) for star visibility.
// Stars fade in over 2 hours at dusk, stay full during night, fade out at dawn.
static constexpr float kDuskStart = 18.0f; // stars begin fading in
static constexpr float kNightStart = 20.0f; // full star visibility
static constexpr float kDawnStart = 4.0f; // stars begin fading out
static constexpr float kDawnEnd = 6.0f; // stars fully gone
static constexpr float kFadeDuration = 2.0f;
StarField::StarField() = default;
StarField::~StarField() {
@ -303,22 +311,20 @@ void StarField::destroyStarBuffers() {
}
float StarField::getStarIntensity(float timeOfDay) const {
// Full night: 20:004:00
if (timeOfDay >= 20.0f || timeOfDay < 4.0f) {
// Full night
if (timeOfDay >= kNightStart || timeOfDay < kDawnStart) {
return 1.0f;
}
// Fade in at dusk: 18:0020:00
else if (timeOfDay >= 18.0f && timeOfDay < 20.0f) {
return (timeOfDay - 18.0f) / 2.0f; // 0 → 1 over 2 hours
// Fade in at dusk
if (timeOfDay >= kDuskStart) {
return (timeOfDay - kDuskStart) / kFadeDuration;
}
// Fade out at dawn: 4:006:00
else if (timeOfDay >= 4.0f && timeOfDay < 6.0f) {
return 1.0f - (timeOfDay - 4.0f) / 2.0f; // 1 → 0 over 2 hours
// Fade out at dawn
if (timeOfDay < kDawnEnd) {
return 1.0f - (timeOfDay - kDawnStart) / kFadeDuration;
}
// Daytime: no stars
else {
return 0.0f;
}
return 0.0f;
}
} // namespace rendering

View file

@ -175,6 +175,8 @@ bool SwimEffects::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou
return false;
}
// Depth test disabled — insects are screen-space sprites that must always
// render above the water surface regardless of scene geometry.
insectPipeline = PipelineBuilder()
.setShaders(vertStage, fragStage)
.setVertexInput({binding}, attrs)

View file

@ -45,9 +45,13 @@ namespace {
// Alpha map format constants
constexpr size_t ALPHA_MAP_SIZE = 4096; // 64×64 uncompressed alpha bytes
constexpr size_t ALPHA_MAP_PACKED = 2048; // 64×64 packed 4-bit alpha (half size)
static_assert(ALPHA_MAP_PACKED * 2 == ALPHA_MAP_SIZE, "packed alpha must unpack to full size");
constexpr uint8_t ALPHA_FILL_FLAG = 0x80; // RLE command: fill vs. copy
constexpr uint8_t ALPHA_COUNT_MASK = 0x7F; // RLE command: count bits
// Random float normalization: mask to 16-bit then divide by max value to get [0..1]
constexpr float kRand16Max = 65535.0f;
// Placement transform constants
constexpr float kDegToRad = 3.14159f / 180.0f;
constexpr float kInv1024 = 1.0f / 1024.0f;
@ -1897,8 +1901,8 @@ void TerrainManager::generateGroundClutterPlacements(std::shared_ptr<PendingTile
};
for (uint32_t a = 0; a < attempts; ++a) {
float fracX = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f;
float fracY = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f;
float fracX = (nextRand() & 0xFFFFu) / kRand16Max * 8.0f;
float fracY = (nextRand() & 0xFFFFu) / kRand16Max * 8.0f;
if (hasAlpha && !alphaScratch.empty()) {
int alphaX = glm::clamp(static_cast<int>((fracX / 8.0f) * 63.0f), 0, 63);
@ -1965,8 +1969,8 @@ void TerrainManager::generateGroundClutterPlacements(std::shared_ptr<PendingTile
p.uniqueId = 0;
// MCNK chunk.position is already in terrain/render world space.
// Do not convert via ADT placement mapping (that is for MDDF/MODF records).
p.rotation = glm::vec3(0.0f, 0.0f, (nextRand() & 0xFFFFu) / 65535.0f * (2.0f * pi));
p.scale = 0.80f + ((nextRand() & 0xFFFFu) / 65535.0f) * 0.35f;
p.rotation = glm::vec3(0.0f, 0.0f, (nextRand() & 0xFFFFu) / kRand16Max * (2.0f * pi));
p.scale = 0.80f + ((nextRand() & 0xFFFFu) / kRand16Max) * 0.35f;
// Snap directly to sampled terrain height.
p.position = glm::vec3(worldX, worldY, worldZ + 0.01f);
pending->m2Placements.push_back(p);
@ -2005,8 +2009,8 @@ void TerrainManager::generateGroundClutterPlacements(std::shared_ptr<PendingTile
return seed;
};
float fracX = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f;
float fracY = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f;
float fracX = (nextRand() & 0xFFFFu) / kRand16Max * 8.0f;
float fracY = (nextRand() & 0xFFFFu) / kRand16Max * 8.0f;
if (hasRoadLikeTextureAt(chunk, fracX, fracY)) {
roadRejected++;
continue;
@ -2033,8 +2037,8 @@ void TerrainManager::generateGroundClutterPlacements(std::shared_ptr<PendingTile
PendingTile::M2Placement p;
p.modelId = proxyModelId;
p.uniqueId = 0;
p.rotation = glm::vec3(0.0f, 0.0f, (nextRand() & 0xFFFFu) / 65535.0f * (2.0f * pi));
p.scale = 0.75f + ((nextRand() & 0xFFFFu) / 65535.0f) * 0.40f;
p.rotation = glm::vec3(0.0f, 0.0f, (nextRand() & 0xFFFFu) / kRand16Max * (2.0f * pi));
p.scale = 0.75f + ((nextRand() & 0xFFFFu) / kRand16Max) * 0.40f;
p.position = glm::vec3(worldX, worldY, worldZ + 0.01f);
pending->m2Placements.push_back(p);
fallbackAdded++;

View file

@ -727,7 +727,7 @@ void TerrainRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, c
glm::vec3 cam = camera.getPosition();
// Find chunk nearest to camera
const TerrainChunkGPU* nearest = nullptr;
float nearestDist = 1e30f;
float nearestDist = std::numeric_limits<float>::max();
for (const auto& ch : chunks) {
float dx = ch.boundingSphereCenter.x - cam.x;
float dy = ch.boundingSphereCenter.y - cam.y;
@ -765,7 +765,10 @@ void TerrainRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, c
}
glm::vec3 camPos = camera.getPosition();
const float maxTerrainDistSq = 1200.0f * 1200.0f;
// Terrain chunks beyond this distance are culled. 1200 world units ≈ 9 ADT tiles,
// matching the asset loading radius (8 tiles) plus a buffer for pop-in avoidance.
constexpr float kMaxTerrainViewDist = 1200.0f;
const float maxTerrainDistSq = kMaxTerrainViewDist * kMaxTerrainViewDist;
renderedChunks = 0;
culledChunks = 0;

View file

@ -1,259 +0,0 @@
#include "rendering/video_player.hpp"
#include "core/logger.hpp"
#include <GL/glew.h>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswscale/swscale.h>
#include <libavutil/imgutils.h>
}
namespace wowee {
namespace rendering {
VideoPlayer::VideoPlayer() = default;
VideoPlayer::~VideoPlayer() {
close();
}
bool VideoPlayer::open(const std::string& path) {
if (!path.empty() && sourcePath == path && formatCtx) return true;
close();
sourcePath = path;
AVFormatContext* fmt = nullptr;
if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) {
LOG_WARNING("VideoPlayer: failed to open ", path);
return false;
}
if (avformat_find_stream_info(fmt, nullptr) < 0) {
LOG_WARNING("VideoPlayer: failed to read stream info for ", path);
avformat_close_input(&fmt);
return false;
}
int streamIndex = -1;
for (unsigned int i = 0; i < fmt->nb_streams; i++) {
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
streamIndex = static_cast<int>(i);
break;
}
}
if (streamIndex < 0) {
LOG_WARNING("VideoPlayer: no video stream in ", path);
avformat_close_input(&fmt);
return false;
}
AVCodecParameters* codecpar = fmt->streams[streamIndex]->codecpar;
const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id);
if (!codec) {
LOG_WARNING("VideoPlayer: unsupported codec for ", path);
avformat_close_input(&fmt);
return false;
}
AVCodecContext* ctx = avcodec_alloc_context3(codec);
if (!ctx) {
avformat_close_input(&fmt);
return false;
}
if (avcodec_parameters_to_context(ctx, codecpar) < 0) {
avcodec_free_context(&ctx);
avformat_close_input(&fmt);
return false;
}
if (avcodec_open2(ctx, codec, nullptr) < 0) {
avcodec_free_context(&ctx);
avformat_close_input(&fmt);
return false;
}
AVFrame* f = av_frame_alloc();
AVFrame* rgb = av_frame_alloc();
AVPacket* pkt = av_packet_alloc();
if (!f || !rgb || !pkt) {
if (pkt) av_packet_free(&pkt);
if (rgb) av_frame_free(&rgb);
if (f) av_frame_free(&f);
avcodec_free_context(&ctx);
avformat_close_input(&fmt);
return false;
}
width = ctx->width;
height = ctx->height;
if (width <= 0 || height <= 0) {
av_packet_free(&pkt);
av_frame_free(&rgb);
av_frame_free(&f);
avcodec_free_context(&ctx);
avformat_close_input(&fmt);
return false;
}
int bufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGB24, width, height, 1);
rgbBuffer.resize(static_cast<size_t>(bufferSize));
av_image_fill_arrays(rgb->data, rgb->linesize,
rgbBuffer.data(), AV_PIX_FMT_RGB24, width, height, 1);
SwsContext* sws = sws_getContext(width, height, ctx->pix_fmt,
width, height, AV_PIX_FMT_RGB24,
SWS_BILINEAR, nullptr, nullptr, nullptr);
if (!sws) {
av_packet_free(&pkt);
av_frame_free(&rgb);
av_frame_free(&f);
avcodec_free_context(&ctx);
avformat_close_input(&fmt);
return false;
}
AVRational fr = fmt->streams[streamIndex]->avg_frame_rate;
if (fr.num <= 0 || fr.den <= 0) {
fr = fmt->streams[streamIndex]->r_frame_rate;
}
double fps = (fr.num > 0 && fr.den > 0) ? static_cast<double>(fr.num) / fr.den : 30.0;
if (fps <= 0.0) fps = 30.0;
frameTime = 1.0 / fps;
accumulator = 0.0;
eof = false;
formatCtx = fmt;
codecCtx = ctx;
frame = f;
rgbFrame = rgb;
packet = pkt;
swsCtx = sws;
videoStreamIndex = streamIndex;
textureReady = false;
return true;
}
void VideoPlayer::close() {
if (textureId) {
glDeleteTextures(1, &textureId);
textureId = 0;
}
textureReady = false;
if (packet) {
av_packet_free(reinterpret_cast<AVPacket**>(&packet));
packet = nullptr;
}
if (rgbFrame) {
av_frame_free(reinterpret_cast<AVFrame**>(&rgbFrame));
rgbFrame = nullptr;
}
if (frame) {
av_frame_free(reinterpret_cast<AVFrame**>(&frame));
frame = nullptr;
}
if (codecCtx) {
avcodec_free_context(reinterpret_cast<AVCodecContext**>(&codecCtx));
codecCtx = nullptr;
}
if (formatCtx) {
avformat_close_input(reinterpret_cast<AVFormatContext**>(&formatCtx));
formatCtx = nullptr;
}
if (swsCtx) {
sws_freeContext(reinterpret_cast<SwsContext*>(swsCtx));
swsCtx = nullptr;
}
videoStreamIndex = -1;
width = 0;
height = 0;
rgbBuffer.clear();
}
void VideoPlayer::update(float deltaTime) {
if (!formatCtx || !codecCtx) return;
accumulator += deltaTime;
while (accumulator >= frameTime) {
if (!decodeNextFrame()) break;
accumulator -= frameTime;
}
}
bool VideoPlayer::decodeNextFrame() {
AVFormatContext* fmt = reinterpret_cast<AVFormatContext*>(formatCtx);
AVCodecContext* ctx = reinterpret_cast<AVCodecContext*>(codecCtx);
AVFrame* f = reinterpret_cast<AVFrame*>(frame);
AVFrame* rgb = reinterpret_cast<AVFrame*>(rgbFrame);
AVPacket* pkt = reinterpret_cast<AVPacket*>(packet);
SwsContext* sws = reinterpret_cast<SwsContext*>(swsCtx);
// Cap iterations to prevent infinite spinning on corrupt/truncated video
// files where av_read_frame fails but av_seek_frame succeeds, looping
// endlessly through the same corrupt region.
constexpr int kMaxDecodeAttempts = 500;
for (int attempt = 0; attempt < kMaxDecodeAttempts; ++attempt) {
int ret = av_read_frame(fmt, pkt);
if (ret < 0) {
if (av_seek_frame(fmt, videoStreamIndex, 0, AVSEEK_FLAG_BACKWARD) >= 0) {
avcodec_flush_buffers(ctx);
continue;
}
return false;
}
if (pkt->stream_index != videoStreamIndex) {
av_packet_unref(pkt);
continue;
}
if (avcodec_send_packet(ctx, pkt) < 0) {
av_packet_unref(pkt);
continue;
}
av_packet_unref(pkt);
ret = avcodec_receive_frame(ctx, f);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
continue;
}
if (ret < 0) {
continue;
}
sws_scale(sws,
f->data, f->linesize,
0, ctx->height,
rgb->data, rgb->linesize);
uploadFrame();
return true;
}
LOG_WARNING("Video decode: exceeded ", kMaxDecodeAttempts, " attempts — possible corrupt file");
return false;
}
void VideoPlayer::uploadFrame() {
if (width <= 0 || height <= 0) return;
if (!textureId) {
glGenTextures(1, &textureId);
glBindTexture(GL_TEXTURE_2D, textureId);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0,
GL_RGB, GL_UNSIGNED_BYTE, rgbBuffer.data());
glBindTexture(GL_TEXTURE_2D, 0);
textureReady = true;
return;
}
glBindTexture(GL_TEXTURE_2D, textureId);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height,
GL_RGB, GL_UNSIGNED_BYTE, rgbBuffer.data());
glBindTexture(GL_TEXTURE_2D, 0);
textureReady = true;
}
} // namespace rendering
} // namespace wowee

View file

@ -16,13 +16,16 @@ namespace rendering {
VkContext* VkContext::sInstance_ = nullptr;
// Hash a VkSamplerCreateInfo into a 64-bit key for the sampler cache.
// FNV-1a chosen for speed and low collision rate on small structured data.
// Constants from: http://www.isthe.com/chongo/tech/comp/fnv/
static constexpr uint64_t kFnv1aOffsetBasis = 14695981039346656037ULL;
static constexpr uint64_t kFnv1aPrime = 1099511628211ULL;
static uint64_t hashSamplerCreateInfo(const VkSamplerCreateInfo& s) {
// Pack the relevant fields into a deterministic hash.
// FNV-1a 64-bit on the raw config values.
uint64_t h = 14695981039346656037ULL;
uint64_t h = kFnv1aOffsetBasis;
auto mix = [&](uint64_t v) {
h ^= v;
h *= 1099511628211ULL;
h *= kFnv1aPrime;
};
mix(static_cast<uint64_t>(s.minFilter));
mix(static_cast<uint64_t>(s.magFilter));

View file

@ -202,18 +202,22 @@ VkPipeline PipelineBuilder::build(VkDevice device, VkPipelineCache cache) const
return pipeline;
}
// All RGBA channels enabled — used by every blend mode since we never need to
// mask individual channels (WoW's fixed-function pipeline always writes all four).
static constexpr VkColorComponentFlags kColorWriteAll =
VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
VkPipelineColorBlendAttachmentState PipelineBuilder::blendDisabled() {
VkPipelineColorBlendAttachmentState state{};
state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
state.colorWriteMask = kColorWriteAll;
state.blendEnable = VK_FALSE;
return state;
}
VkPipelineColorBlendAttachmentState PipelineBuilder::blendAlpha() {
VkPipelineColorBlendAttachmentState state{};
state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
state.colorWriteMask = kColorWriteAll;
state.blendEnable = VK_TRUE;
state.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
state.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
@ -226,8 +230,7 @@ VkPipelineColorBlendAttachmentState PipelineBuilder::blendAlpha() {
VkPipelineColorBlendAttachmentState PipelineBuilder::blendPremultiplied() {
VkPipelineColorBlendAttachmentState state{};
state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
state.colorWriteMask = kColorWriteAll;
state.blendEnable = VK_TRUE;
state.srcColorBlendFactor = VK_BLEND_FACTOR_ONE;
state.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
@ -240,8 +243,7 @@ VkPipelineColorBlendAttachmentState PipelineBuilder::blendPremultiplied() {
VkPipelineColorBlendAttachmentState PipelineBuilder::blendAdditive() {
VkPipelineColorBlendAttachmentState state{};
state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
state.colorWriteMask = kColorWriteAll;
state.blendEnable = VK_TRUE;
state.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
state.dstColorBlendFactor = VK_BLEND_FACTOR_ONE;

View file

@ -32,6 +32,7 @@ bool VkShaderModule::loadFromFile(VkDevice device, const std::string& path) {
}
size_t fileSize = static_cast<size_t>(file.tellg());
// SPIR-V is a stream of 32-bit words — file size must be a multiple of 4
if (fileSize == 0 || fileSize % 4 != 0) {
LOG_ERROR("Invalid SPIR-V file size (", fileSize, "): ", path);
return false;

View file

@ -17,7 +17,8 @@ VkTexture::VkTexture(VkTexture&& other) noexcept
ownsSampler_(other.ownsSampler_) {
other.image_ = {};
other.sampler_ = VK_NULL_HANDLE;
other.ownsSampler_ = true;
// Source no longer owns the sampler — ownership transferred to this instance
other.ownsSampler_ = false;
}
VkTexture& VkTexture::operator=(VkTexture&& other) noexcept {
@ -28,7 +29,7 @@ VkTexture& VkTexture::operator=(VkTexture&& other) noexcept {
ownsSampler_ = other.ownsSampler_;
other.image_ = {};
other.sampler_ = VK_NULL_HANDLE;
other.ownsSampler_ = true;
other.ownsSampler_ = false;
}
return *this;
}
@ -196,6 +197,23 @@ bool VkTexture::createDepth(VkContext& ctx, uint32_t width, uint32_t height, VkF
return true;
}
// Shared sampler finalization: try the global cache first (avoids duplicate Vulkan
// sampler objects), fall back to direct creation if no VkContext is available.
bool VkTexture::finalizeSampler(VkDevice device, const VkSamplerCreateInfo& samplerInfo) {
auto* ctx = VkContext::globalInstance();
if (ctx) {
sampler_ = ctx->getOrCreateSampler(samplerInfo);
ownsSampler_ = false;
return sampler_ != VK_NULL_HANDLE;
}
if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) {
LOG_ERROR("Failed to create texture sampler");
return false;
}
ownsSampler_ = true;
return true;
}
bool VkTexture::createSampler(VkDevice device,
VkFilter minFilter, VkFilter magFilter,
VkSamplerAddressMode addressMode, float maxAnisotropy)
@ -217,22 +235,7 @@ bool VkTexture::createSampler(VkDevice device,
samplerInfo.mipLodBias = 0.0f;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = static_cast<float>(mipLevels_);
// Use sampler cache if VkContext is available.
auto* ctx = VkContext::globalInstance();
if (ctx) {
sampler_ = ctx->getOrCreateSampler(samplerInfo);
ownsSampler_ = false;
return sampler_ != VK_NULL_HANDLE;
}
// Fallback: no VkContext (shouldn't happen in normal use).
if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) {
LOG_ERROR("Failed to create texture sampler");
return false;
}
ownsSampler_ = true;
return true;
return finalizeSampler(device, samplerInfo);
}
bool VkTexture::createSampler(VkDevice device,
@ -258,22 +261,7 @@ bool VkTexture::createSampler(VkDevice device,
samplerInfo.mipLodBias = 0.0f;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = static_cast<float>(mipLevels_);
// Use sampler cache if VkContext is available.
auto* ctx = VkContext::globalInstance();
if (ctx) {
sampler_ = ctx->getOrCreateSampler(samplerInfo);
ownsSampler_ = false;
return sampler_ != VK_NULL_HANDLE;
}
// Fallback: no VkContext (shouldn't happen in normal use).
if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) {
LOG_ERROR("Failed to create texture sampler");
return false;
}
ownsSampler_ = true;
return true;
return finalizeSampler(device, samplerInfo);
}
bool VkTexture::createShadowSampler(VkDevice device) {
@ -290,22 +278,7 @@ bool VkTexture::createShadowSampler(VkDevice device) {
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = 1.0f;
// Use sampler cache if VkContext is available.
auto* ctx = VkContext::globalInstance();
if (ctx) {
sampler_ = ctx->getOrCreateSampler(samplerInfo);
ownsSampler_ = false;
return sampler_ != VK_NULL_HANDLE;
}
// Fallback: no VkContext (shouldn't happen in normal use).
if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow sampler");
return false;
}
ownsSampler_ = true;
return true;
return finalizeSampler(device, samplerInfo);
}
void VkTexture::destroy(VkDevice device, VmaAllocator allocator) {
@ -313,7 +286,7 @@ void VkTexture::destroy(VkDevice device, VmaAllocator allocator) {
vkDestroySampler(device, sampler_, nullptr);
}
sampler_ = VK_NULL_HANDLE;
ownsSampler_ = true;
ownsSampler_ = false;
destroyImage(device, allocator, image_);
}

View file

@ -1295,38 +1295,36 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) {
renderTile = lsbOrder || msbOrder;
}
// Render masked-out tiles if any adjacent neighbor is visible,
// to avoid seam gaps at water surface edges.
if (!renderTile) {
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= gridWidth-1 || ny >= gridHeight-1) continue;
int neighborIdx;
if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
neighborIdx = (static_cast<int>(surface.yOffset) + ny) * 8 +
(static_cast<int>(surface.xOffset) + nx);
} else {
neighborIdx = ny * surface.width + nx;
}
int nByteIdx = neighborIdx / 8;
int nBitIdx = neighborIdx % 8;
if (nByteIdx < static_cast<int>(surface.mask.size())) {
uint8_t nMask = surface.mask[nByteIdx];
if (isMergedTerrain) {
if (nMask & (1 << nBitIdx)) {
renderTile = true;
goto found_neighbor;
}
renderTile = [&]() {
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= gridWidth-1 || ny >= gridHeight-1) continue;
int neighborIdx;
if (surface.wmoId == 0 && surface.width <= 8 && surface.mask.size() >= 8) {
neighborIdx = (static_cast<int>(surface.yOffset) + ny) * 8 +
(static_cast<int>(surface.xOffset) + nx);
} else {
if ((nMask & (1 << nBitIdx)) || (nMask & (1 << (7 - nBitIdx)))) {
renderTile = true;
goto found_neighbor;
neighborIdx = ny * surface.width + nx;
}
int nByteIdx = neighborIdx / 8;
int nBitIdx = neighborIdx % 8;
if (nByteIdx < static_cast<int>(surface.mask.size())) {
uint8_t nMask = surface.mask[nByteIdx];
if (isMergedTerrain) {
if (nMask & (1 << nBitIdx)) return true;
} else {
if ((nMask & (1 << nBitIdx)) || (nMask & (1 << (7 - nBitIdx)))) return true;
}
}
}
}
}
found_neighbor:;
return false;
}();
}
}
}

View file

@ -353,12 +353,11 @@ void Weather::resetParticles(const Camera& camera) {
}
glm::vec3 Weather::getRandomPosition(const glm::vec3& center) const {
static std::random_device rd;
static std::mt19937 gen(rd());
// Reuse the shared weather RNG to avoid duplicate generator state
static std::uniform_real_distribution<float> dist(-1.0f, 1.0f);
float x = center.x + dist(gen) * SPAWN_VOLUME_SIZE;
float z = center.z + dist(gen) * SPAWN_VOLUME_SIZE;
float x = center.x + dist(weatherRng()) * SPAWN_VOLUME_SIZE;
float z = center.z + dist(weatherRng()) * SPAWN_VOLUME_SIZE;
float y = center.y;
return glm::vec3(x, y, z);
@ -440,7 +439,6 @@ void Weather::initializeZoneWeatherDefaults() {
setZoneWeather(148, Type::RAIN, 0.1f, 0.4f, 0.15f); // Darkshore
setZoneWeather(331, Type::RAIN, 0.1f, 0.3f, 0.1f); // Ashenvale
setZoneWeather(405, Type::RAIN, 0.1f, 0.3f, 0.1f); // Desolace
setZoneWeather(15, Type::RAIN, 0.2f, 0.5f, 0.2f); // Dustwallow Marsh
setZoneWeather(490, Type::RAIN, 0.1f, 0.4f, 0.15f); // Un'Goro Crater
setZoneWeather(493, Type::RAIN, 0.1f, 0.3f, 0.1f); // Moonglade

View file

@ -363,12 +363,16 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
break;
}
}
// Track which WMO models have been force-reloaded after resolving only to
// fallback textures. Cap the set to avoid unbounded memory growth in worlds
// with many unique WMO groups (e.g. Dalaran has 2000+).
static constexpr size_t kMaxRetryTracked = 8192;
static std::unordered_set<uint32_t> retryReloadedModels;
static bool retryReloadedModelsCapped = false;
if (retryReloadedModels.size() > 8192) {
if (retryReloadedModels.size() > kMaxRetryTracked) {
retryReloadedModels.clear();
if (!retryReloadedModelsCapped) {
core::Logger::getInstance().warning("WMO fallback-retry set exceeded 8192 entries; reset");
core::Logger::getInstance().warning("WMO fallback-retry set exceeded ", kMaxRetryTracked, " entries; reset");
retryReloadedModelsCapped = true;
}
}

View file

@ -281,8 +281,11 @@ void WorldMap::loadZonesFromDBC() {
}
}
// Use expansion-aware DBC layout when available; fall back to WotLK stock field
// indices (ID=0, ParentAreaNum=2, ExploreFlag=3) when layout metadata is missing.
// Incorrect field indices silently return wrong data, so these defaults must match
// the most common AreaTable.dbc layout to minimize breakage.
const auto* atL = activeLayout ? activeLayout->getLayout("AreaTable") : nullptr;
// Map areaID → its own AreaBit, and parentAreaID → list of child AreaBits
std::unordered_map<uint32_t, uint32_t> exploreFlagByAreaId;
std::unordered_map<uint32_t, std::vector<uint32_t>> childBitsByParent;
auto areaDbc = assetManager->loadDBC("AreaTable.dbc");

View file

@ -216,7 +216,9 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
if (music) {
if (!loginMusicVolumeAdjusted_) {
savedMusicVolume_ = music->getVolume();
int loginVolume = (savedMusicVolume_ * 80) / 100; // reduce auth music by 20%
// Reduce music to 80% during login so UI button clicks and error sounds
// remain audible over the background track
int loginVolume = (savedMusicVolume_ * 80) / 100;
if (loginVolume < 0) loginVolume = 0;
if (loginVolume > 100) loginVolume = 100;
music->setVolume(loginVolume);

View file

@ -1344,6 +1344,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
if (target && target->getType() == game::ObjectType::GAMEOBJECT) {
LOG_WARNING("[GO-DIAG] Right-click: re-interacting with targeted GO 0x",
std::hex, target->getGuid(), std::dec);
gameHandler.setTarget(target->getGuid());
gameHandler.interactWithGameObject(target->getGuid());
return;
@ -1416,6 +1418,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
hitCenter.z += heightOffset;
// Log each unique GO's raypick position once
if (t == game::ObjectType::GAMEOBJECT) {
static std::unordered_set<uint64_t> goPickLog;
if (goPickLog.insert(guid).second) {
auto go = std::static_pointer_cast<game::GameObject>(entity);
LOG_WARNING("[GO-DIAG] Raypick GO: guid=0x", std::hex, guid, std::dec,
" entry=", go->getEntry(), " name='", go->getName(),
"' pos=(", entity->getX(), ",", entity->getY(), ",", entity->getZ(),
") center=(", hitCenter.x, ",", hitCenter.y, ",", hitCenter.z,
") r=", hitRadius);
}
}
} else {
hitRadius = std::max(hitRadius * 1.1f, 0.6f);
}
@ -1476,6 +1490,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
if (closestGuid != 0) {
if (closestType == game::ObjectType::GAMEOBJECT) {
LOG_WARNING("[GO-DIAG] Right-click: raypick hit GO 0x",
std::hex, closestGuid, std::dec);
gameHandler.setTarget(closestGuid);
gameHandler.interactWithGameObject(closestGuid);
return;

View file

@ -38,6 +38,45 @@ constexpr const char* kResistNames[6] = {
"Frost Resistance", "Shadow Resistance", "Arcane Resistance"
};
// Render "Classes: Warrior, Paladin" or "Races: Human, Orc" restriction text.
// Shared between quest info and item info tooltips — both use the same WoW
// allowableClass/allowableRace bitmask format with identical display logic.
void renderClassRestriction(uint32_t allowableMask, uint8_t playerClass) {
const auto& entries = ui::kClassMasks;
int mc = 0;
for (const auto& e : entries) if (allowableMask & e.mask) ++mc;
if (mc <= 0 || mc >= 10) return; // all classes allowed or none matched
char buf[128] = "Classes: "; bool first = true;
for (const auto& e : entries) {
if (!(allowableMask & e.mask)) continue;
if (!first) strncat(buf, ", ", sizeof(buf) - strlen(buf) - 1);
strncat(buf, e.name, sizeof(buf) - strlen(buf) - 1);
first = false;
}
uint32_t pm = (playerClass > 0 && playerClass <= 10) ? (1u << (playerClass - 1)) : 0;
bool ok = (pm == 0 || (allowableMask & pm));
ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : colors::kPaleRed, "%s", buf);
}
void renderRaceRestriction(uint32_t allowableMask, uint8_t playerRace) {
constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024;
if ((allowableMask & kAllPlayable) == kAllPlayable) return;
const auto& entries = ui::kRaceMasks;
int mc = 0;
for (const auto& e : entries) if (allowableMask & e.mask) ++mc;
if (mc <= 0) return;
char buf[160] = "Races: "; bool first = true;
for (const auto& e : entries) {
if (!(allowableMask & e.mask)) continue;
if (!first) strncat(buf, ", ", sizeof(buf) - strlen(buf) - 1);
strncat(buf, e.name, sizeof(buf) - strlen(buf) - 1);
first = false;
}
uint32_t pm = (playerRace > 0 && playerRace <= 11) ? (1u << (playerRace - 1)) : 0;
bool ok = (pm == 0 || (allowableMask & pm));
ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : colors::kPaleRed, "%s", buf);
}
// Socket types from shared ui_colors.hpp (ui::kSocketTypes)
const game::ItemSlot* findComparableEquipped(const game::Inventory& inventory, uint8_t inventoryType) {
@ -2847,47 +2886,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
rankName,
fIt != s_factionNamesB.end() ? fIt->second.c_str() : "Unknown Faction");
}
// Class restriction
if (qInfo->allowableClass != 0) {
const auto& kClassesB = ui::kClassMasks;
int mc = 0;
for (const auto& kc : kClassesB) if (qInfo->allowableClass & kc.mask) ++mc;
if (mc > 0 && mc < 10) {
char buf[128] = "Classes: "; bool first = true;
for (const auto& kc : kClassesB) {
if (!(qInfo->allowableClass & kc.mask)) continue;
if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1);
strncat(buf, kc.name, sizeof(buf)-strlen(buf)-1);
first = false;
}
uint8_t pc = gameHandler_->getPlayerClass();
uint32_t pm = (pc > 0 && pc <= 10) ? (1u << (pc-1)) : 0;
bool ok = (pm == 0 || (qInfo->allowableClass & pm));
ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf);
}
}
// Race restriction
if (qInfo->allowableRace != 0) {
const auto& kRacesB = ui::kRaceMasks;
constexpr uint32_t kAll = 1|2|4|8|16|32|64|128|512|1024;
if ((qInfo->allowableRace & kAll) != kAll) {
int mc = 0;
for (const auto& kr : kRacesB) if (qInfo->allowableRace & kr.mask) ++mc;
if (mc > 0) {
char buf[160] = "Races: "; bool first = true;
for (const auto& kr : kRacesB) {
if (!(qInfo->allowableRace & kr.mask)) continue;
if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1);
strncat(buf, kr.name, sizeof(buf)-strlen(buf)-1);
first = false;
}
uint8_t pr = gameHandler_->getPlayerRace();
uint32_t pm = (pr > 0 && pr <= 11) ? (1u << (pr-1)) : 0;
bool ok = (pm == 0 || (qInfo->allowableRace & pm));
ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf);
}
}
}
if (qInfo->allowableClass != 0)
renderClassRestriction(qInfo->allowableClass, gameHandler_->getPlayerClass());
if (qInfo->allowableRace != 0)
renderRaceRestriction(qInfo->allowableRace, gameHandler_->getPlayerRace());
}
}
@ -3361,64 +3363,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction");
}
// Class restriction (e.g. "Classes: Paladin, Warrior")
if (info.allowableClass != 0) {
const auto& kClasses = ui::kClassMasks;
// Count matching classes
int matchCount = 0;
for (const auto& kc : kClasses)
if (info.allowableClass & kc.mask) ++matchCount;
// Only show if restricted to a subset (not all classes)
if (matchCount > 0 && matchCount < 10) {
char classBuf[128] = "Classes: ";
bool first = true;
for (const auto& kc : kClasses) {
if (!(info.allowableClass & kc.mask)) continue;
if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1);
strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1);
first = false;
}
// Check if player's class is allowed
bool playerAllowed = true;
if (gameHandler_) {
uint8_t pc = gameHandler_->getPlayerClass();
uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0;
playerAllowed = (pmask == 0 || (info.allowableClass & pmask));
}
ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ui::colors::kPaleRed;
ImGui::TextColored(clColor, "%s", classBuf);
}
}
// Race restriction (e.g. "Races: Night Elf, Human")
if (info.allowableRace != 0) {
const auto& kRaces = ui::kRaceMasks;
constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024;
// Only show if not all playable races are allowed
if ((info.allowableRace & kAllPlayable) != kAllPlayable) {
int matchCount = 0;
for (const auto& kr : kRaces)
if (info.allowableRace & kr.mask) ++matchCount;
if (matchCount > 0) {
char raceBuf[160] = "Races: ";
bool first = true;
for (const auto& kr : kRaces) {
if (!(info.allowableRace & kr.mask)) continue;
if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1);
strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1);
first = false;
}
bool playerAllowed = true;
if (gameHandler_) {
uint8_t pr = gameHandler_->getPlayerRace();
uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0;
playerAllowed = (pmask == 0 || (info.allowableRace & pmask));
}
ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ui::colors::kPaleRed;
ImGui::TextColored(rColor, "%s", raceBuf);
}
}
}
if (info.allowableClass != 0 && gameHandler_)
renderClassRestriction(info.allowableClass, gameHandler_->getPlayerClass());
if (info.allowableRace != 0 && gameHandler_)
renderRaceRestriction(info.allowableRace, gameHandler_->getPlayerRace());
// Spell effects
for (const auto& sp : info.spells) {

View file

@ -175,9 +175,11 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
"expansion layout");
}
// If dbc_layouts.json was missing or its field names didn't match, retry with
// hard-coded WotLK field indices as a safety net. fieldCount >= 200 distinguishes
// WotLK (234 fields) from Classic (148) to avoid misreading shorter DBCs.
if (spellData.empty() && fieldCount >= 200) {
LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)");
// WotLK Spell.dbc field indices (verified against 3.3.5a schema); SchoolMask at field 225
schoolField_ = 225;
isSchoolEnum_ = false;
tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback");
@ -441,7 +443,9 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa
static int lastImGuiFrame = -1;
int curFrame = ImGui::GetFrameCount();
if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; }
if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here
// Defer without caching — returning null here allows retry next frame when
// the budget resets, rather than permanently blacklisting the icon as missing
if (loadsThisFrame >= 4) return VK_NULL_HANDLE;
auto pit = spellIconPaths.find(iconId);
if (pit == spellIconPaths.end()) {

View file

@ -76,7 +76,9 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
return;
}
// Get talent tabs for this class, sorted by orderIndex
// Get talent tabs for this class, sorted by orderIndex.
// WoW class IDs are 1-indexed (Warrior=1..Druid=11); convert to bitmask for
// TalentTab.classMask matching (Warrior=0x1, Paladin=0x2, Hunter=0x4, etc.)
uint32_t classMask = 1u << (playerClass - 1);
std::vector<const game::GameHandler::TalentTabEntry*> classTabs;
for (const auto& [tabId, tab] : gameHandler.getAllTalentTabs()) {