#include "core/application.hpp" #include "core/coordinates.hpp" #include "core/profiler.hpp" #include "core/npc_interaction_callback_handler.hpp" #include "core/audio_callback_handler.hpp" #include "core/entity_spawn_callback_handler.hpp" #include "core/animation_callback_handler.hpp" #include "core/transport_callback_handler.hpp" #include "core/world_entry_callback_handler.hpp" #include "core/ui_screen_callback_handler.hpp" #include "rendering/animation/animation_ids.hpp" #include "rendering/animation_controller.hpp" #include #include #include #include "core/spawn_presets.hpp" #include "core/logger.hpp" #include "core/memory_monitor.hpp" #include "rendering/renderer.hpp" #include "rendering/vk_context.hpp" #include "audio/npc_voice_manager.hpp" #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" #include "rendering/terrain_renderer.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/performance_hud.hpp" #include "rendering/water_renderer.hpp" #include "rendering/skybox.hpp" #include "rendering/celestial.hpp" #include "rendering/starfield.hpp" #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" #include "rendering/minimap.hpp" #include "rendering/quest_marker_renderer.hpp" #include "rendering/loading_screen.hpp" #include "audio/music_manager.hpp" #include "audio/footstep_manager.hpp" #include "audio/activity_sound_manager.hpp" #include "audio/audio_engine.hpp" #include "audio/audio_coordinator.hpp" #include "addons/addon_manager.hpp" #include #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" #include "pipeline/wdt_loader.hpp" #include "pipeline/dbc_loader.hpp" #include "ui/ui_manager.hpp" #include "ui/ui_services.hpp" #include "auth/auth_handler.hpp" #include "game/game_handler.hpp" #include "game/transport_manager.hpp" #include "game/world.hpp" #include "game/expansion_profile.hpp" #include "game/packet_parsers.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_layout.hpp" #include #include #include #include #include #include #include #include #include #include #include #ifdef __linux__ #include #include #elif defined(_WIN32) #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include #elif defined(__APPLE__) #include #include #include #endif namespace wowee { namespace core { namespace { bool envFlagEnabled(const char* key, bool defaultValue = false) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || raw[0] == 'n' || raw[0] == 'N'); } } // namespace Application* Application::instance = nullptr; Application::Application() { instance = this; } Application::~Application() { shutdown(); instance = nullptr; } bool Application::initialize() { LOG_INFO("Initializing Wowee Native Client"); // Initialize memory monitoring for dynamic cache sizing core::MemoryMonitor::getInstance().initialize(); // Create window WindowConfig windowConfig; windowConfig.title = "Wowee"; windowConfig.width = 1280; windowConfig.height = 720; windowConfig.vsync = false; window = std::make_unique(windowConfig); if (!window->initialize()) { LOG_FATAL("Failed to initialize window"); return false; } // Create renderer renderer = std::make_unique(); if (!renderer->initialize(window.get())) { LOG_FATAL("Failed to initialize renderer"); return false; } // Create and initialize audio coordinator (owns all audio managers) audioCoordinator_ = std::make_unique(); if (!audioCoordinator_->initialize()) LOG_WARNING("Audio coordinator initialization failed — game will run without audio"); renderer->setAudioCoordinator(audioCoordinator_.get()); // Create UI manager uiManager = std::make_unique(); if (!uiManager->initialize(window.get())) { LOG_FATAL("Failed to initialize UI manager"); return false; } // Create subsystems authHandler = std::make_unique(); world = std::make_unique(); // Create and initialize expansion registry expansionRegistry_ = std::make_unique(); // Create DBC layout dbcLayout_ = std::make_unique(); // Create asset manager assetManager = std::make_unique(); // Populate game services — all subsystems now available gameServices_.renderer = renderer.get(); gameServices_.audioCoordinator = audioCoordinator_.get(); gameServices_.assetManager = assetManager.get(); gameServices_.expansionRegistry = expansionRegistry_.get(); // Create game handler with explicit service dependencies gameHandler = std::make_unique(gameServices_); // Try to get WoW data path from environment variable const char* dataPathEnv = std::getenv("WOW_DATA_PATH"); std::string dataPath = dataPathEnv ? dataPathEnv : "./Data"; // Scan for available expansion profiles expansionRegistry_->initialize(dataPath); // Load expansion-specific opcode table if (gameHandler && expansionRegistry_) { auto* profile = expansionRegistry_->getActive(); if (profile) { std::string opcodesPath = profile->dataPath + "/opcodes.json"; if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) { LOG_ERROR("Failed to load opcodes from ", opcodesPath); } game::setActiveOpcodeTable(&gameHandler->getOpcodeTable()); // Load expansion-specific update field table std::string updateFieldsPath = profile->dataPath + "/update_fields.json"; if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) { LOG_ERROR("Failed to load update fields from ", updateFieldsPath); } game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable()); // Create expansion-specific packet parsers gameHandler->setPacketParsers(game::createPacketParsers(profile->id)); // Load expansion-specific DBC layouts if (dbcLayout_) { std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json"; if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) { LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath); } pipeline::setActiveDBCLayout(dbcLayout_.get()); } } } // Try expansion-specific asset path first, fall back to base Data/ std::string assetPath = dataPath; if (expansionRegistry_) { auto* profile = expansionRegistry_->getActive(); if (profile && !profile->dataPath.empty()) { // Enable expansion-specific CSV DBC lookup (Data/expansions//db/*.csv). assetManager->setExpansionDataPath(profile->dataPath); std::string expansionManifest = profile->dataPath + "/manifest.json"; if (std::filesystem::exists(expansionManifest)) { assetPath = profile->dataPath; LOG_INFO("Using expansion-specific asset path: ", assetPath); // Register base Data/ as fallback so world terrain files are found // even when the expansion path only contains DBC overrides. if (assetPath != dataPath) { assetManager->setBaseFallbackPath(dataPath); } } } } LOG_INFO("Attempting to load WoW assets from: ", assetPath); if (assetManager->initialize(assetPath)) { LOG_INFO("Asset manager initialized successfully"); // Eagerly load creature display DBC lookups so first spawn doesn't stall entitySpawner_ = std::make_unique( renderer.get(), assetManager.get(), gameHandler.get(), dbcLayout_.get(), &gameServices_); entitySpawner_->initialize(); appearanceComposer_ = std::make_unique( renderer.get(), assetManager.get(), gameHandler.get(), dbcLayout_.get(), entitySpawner_.get()); // Wire AppearanceComposer to UI components (Phase A singleton breaking) if (uiManager) { uiManager->setAppearanceComposer(appearanceComposer_.get()); // Wire all services to UI components (Phase B singleton breaking) ui::UIServices uiServices; uiServices.window = window.get(); uiServices.renderer = renderer.get(); uiServices.assetManager = assetManager.get(); uiServices.gameHandler = gameHandler.get(); uiServices.expansionRegistry = expansionRegistry_.get(); uiServices.addonManager = addonManager_.get(); // May be nullptr here, re-wire later uiServices.audioCoordinator = audioCoordinator_.get(); uiServices.entitySpawner = entitySpawner_.get(); uiServices.appearanceComposer = appearanceComposer_.get(); uiServices.worldLoader = worldLoader_.get(); uiManager->setServices(uiServices); } // Ensure the main in-world CharacterRenderer can load textures immediately. // Previously this was only wired during terrain initialization, which meant early spawns // (before terrain load) would render with white fallback textures (notably hair). if (renderer && renderer->getCharacterRenderer()) { renderer->getCharacterRenderer()->setAssetManager(assetManager.get()); } // Load transport paths from TransportAnimation.dbc and TaxiPathNode.dbc if (gameHandler && gameHandler->getTransportManager()) { gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get()); gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get()); } // Initialize addon system addonManager_ = std::make_unique(); addons::LuaServices luaSvc; luaSvc.window = window.get(); luaSvc.audioCoordinator = audioCoordinator_.get(); luaSvc.expansionRegistry = expansionRegistry_.get(); if (addonManager_->initialize(gameHandler.get(), luaSvc)) { std::string addonsDir = assetPath + "/interface/AddOns"; addonManager_->scanAddons(addonsDir); // Wire Lua errors to UI error display addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) { if (gh) gh->addUIError(err); }); // Wire chat messages to addon event dispatch gameHandler->setAddonChatCallback([this](const game::MessageChatData& msg) { if (!addonManager_ || !addonsLoaded_) return; // Map ChatType to WoW event name const char* eventName = nullptr; switch (msg.type) { case game::ChatType::SAY: eventName = "CHAT_MSG_SAY"; break; case game::ChatType::YELL: eventName = "CHAT_MSG_YELL"; break; case game::ChatType::WHISPER: eventName = "CHAT_MSG_WHISPER"; break; case game::ChatType::PARTY: eventName = "CHAT_MSG_PARTY"; break; case game::ChatType::GUILD: eventName = "CHAT_MSG_GUILD"; break; case game::ChatType::OFFICER: eventName = "CHAT_MSG_OFFICER"; break; case game::ChatType::RAID: eventName = "CHAT_MSG_RAID"; break; case game::ChatType::RAID_WARNING: eventName = "CHAT_MSG_RAID_WARNING"; break; case game::ChatType::BATTLEGROUND: eventName = "CHAT_MSG_BATTLEGROUND"; break; case game::ChatType::SYSTEM: eventName = "CHAT_MSG_SYSTEM"; break; case game::ChatType::CHANNEL: eventName = "CHAT_MSG_CHANNEL"; break; case game::ChatType::EMOTE: case game::ChatType::TEXT_EMOTE: eventName = "CHAT_MSG_EMOTE"; break; case game::ChatType::ACHIEVEMENT: eventName = "CHAT_MSG_ACHIEVEMENT"; break; case game::ChatType::GUILD_ACHIEVEMENT: eventName = "CHAT_MSG_GUILD_ACHIEVEMENT"; break; case game::ChatType::WHISPER_INFORM: eventName = "CHAT_MSG_WHISPER_INFORM"; break; case game::ChatType::RAID_LEADER: eventName = "CHAT_MSG_RAID_LEADER"; break; case game::ChatType::BATTLEGROUND_LEADER: eventName = "CHAT_MSG_BATTLEGROUND_LEADER"; break; case game::ChatType::MONSTER_SAY: eventName = "CHAT_MSG_MONSTER_SAY"; break; case game::ChatType::MONSTER_YELL: eventName = "CHAT_MSG_MONSTER_YELL"; break; case game::ChatType::MONSTER_EMOTE: eventName = "CHAT_MSG_MONSTER_EMOTE"; break; case game::ChatType::MONSTER_WHISPER: eventName = "CHAT_MSG_MONSTER_WHISPER"; break; case game::ChatType::RAID_BOSS_EMOTE: eventName = "CHAT_MSG_RAID_BOSS_EMOTE"; break; case game::ChatType::RAID_BOSS_WHISPER: eventName = "CHAT_MSG_RAID_BOSS_WHISPER"; break; case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break; case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break; case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break; case game::ChatType::MONSTER_PARTY: eventName = "CHAT_MSG_MONSTER_PARTY"; break; case game::ChatType::AFK: eventName = "CHAT_MSG_AFK"; break; case game::ChatType::DND: eventName = "CHAT_MSG_DND"; break; case game::ChatType::LOOT: eventName = "CHAT_MSG_LOOT"; break; case game::ChatType::SKILL: eventName = "CHAT_MSG_SKILL"; break; default: break; } if (eventName) { addonManager_->fireEvent(eventName, {msg.message, msg.senderName}); } }); // Wire generic game events to addon dispatch gameHandler->setAddonEventCallback([this](const std::string& event, const std::vector& args) { if (addonManager_ && addonsLoaded_) { addonManager_->fireEvent(event, args); } }); // Wire spell icon path resolver for Lua API (GetSpellInfo, UnitBuff icon, etc.) { auto spellIconPaths = std::make_shared>(); auto spellIconIds = std::make_shared>(); auto loaded = std::make_shared(false); auto* am = assetManager.get(); gameHandler->setSpellIconPathResolver([spellIconPaths, spellIconIds, loaded, am](uint32_t spellId) -> std::string { if (!am) return {}; // Lazy-load SpellIcon.dbc + Spell.dbc icon IDs on first call if (!*loaded) { *loaded = true; auto iconDbc = am->loadDBC("SpellIcon.dbc"); const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; if (iconDbc && iconDbc->isLoaded()) { for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) { uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0); std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1); if (!path.empty() && id > 0) (*spellIconPaths)[id] = path; } } auto spellDbc = am->loadDBC("Spell.dbc"); const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; if (spellDbc && spellDbc->isLoaded()) { uint32_t fieldCount = spellDbc->getFieldCount(); uint32_t iconField = 133; // WotLK default uint32_t idField = 0; if (spellL) { uint32_t layoutIcon = (*spellL)["IconID"]; if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { iconField = layoutIcon; idField = (*spellL)["ID"]; } } for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) { uint32_t id = spellDbc->getUInt32(i, idField); uint32_t iconId = spellDbc->getUInt32(i, iconField); if (id > 0 && iconId > 0) (*spellIconIds)[id] = iconId; } } } auto iit = spellIconIds->find(spellId); if (iit == spellIconIds->end()) return {}; auto pit = spellIconPaths->find(iit->second); if (pit == spellIconPaths->end()) return {}; return pit->second; }); } // Wire item icon path resolver: displayInfoId -> "Interface\\Icons\\INV_..." { auto iconNames = std::make_shared>(); auto loaded = std::make_shared(false); auto* am = assetManager.get(); gameHandler->setItemIconPathResolver([iconNames, loaded, am](uint32_t displayInfoId) -> std::string { if (!am || displayInfoId == 0) return {}; if (!*loaded) { *loaded = true; auto dbc = am->loadDBC("ItemDisplayInfo.dbc"); const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; if (dbc && dbc->isLoaded()) { uint32_t iconField = dispL ? (*dispL)["InventoryIcon"] : 5; for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { uint32_t id = dbc->getUInt32(i, 0); // field 0 = ID std::string name = dbc->getString(i, iconField); if (id > 0 && !name.empty()) (*iconNames)[id] = name; } LOG_INFO("Loaded ", iconNames->size(), " item icon names from ItemDisplayInfo.dbc"); } } auto it = iconNames->find(displayInfoId); if (it == iconNames->end()) return {}; return "Interface\\Icons\\" + it->second; }); } // Wire spell data resolver: spellId -> {castTimeMs, minRange, maxRange} { auto castTimeMap = std::make_shared>(); auto rangeMap = std::make_shared>>(); auto spellCastIdx = std::make_shared>(); // spellId→castTimeIdx auto spellRangeIdx = std::make_shared>(); // spellId→rangeIdx struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; }; auto spellCostMap = std::make_shared>(); auto loaded = std::make_shared(false); auto* am = assetManager.get(); gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo { if (!am) return {}; if (!*loaded) { *loaded = true; // Load SpellCastTimes.dbc auto ctDbc = am->loadDBC("SpellCastTimes.dbc"); if (ctDbc && ctDbc->isLoaded()) { for (uint32_t i = 0; i < ctDbc->getRecordCount(); ++i) { uint32_t id = ctDbc->getUInt32(i, 0); int32_t base = static_cast(ctDbc->getUInt32(i, 1)); if (id > 0 && base > 0) (*castTimeMap)[id] = static_cast(base); } } // Load SpellRange.dbc const auto* srL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr; uint32_t minRField = srL ? (*srL)["MinRange"] : 1; uint32_t maxRField = srL ? (*srL)["MaxRange"] : 4; auto rDbc = am->loadDBC("SpellRange.dbc"); if (rDbc && rDbc->isLoaded()) { for (uint32_t i = 0; i < rDbc->getRecordCount(); ++i) { uint32_t id = rDbc->getUInt32(i, 0); float minR = rDbc->getFloat(i, minRField); float maxR = rDbc->getFloat(i, maxRField); if (id > 0) (*rangeMap)[id] = {minR, maxR}; } } // Load Spell.dbc: extract castTimeIndex and rangeIndex per spell auto sDbc = am->loadDBC("Spell.dbc"); const auto* spL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; if (sDbc && sDbc->isLoaded()) { uint32_t idF = spL ? (*spL)["ID"] : 0; uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default uint32_t rF = spL ? (*spL)["RangeIndex"] : 132; uint32_t ptF = UINT32_MAX, mcF = UINT32_MAX; if (spL) { try { ptF = (*spL)["PowerType"]; } catch (...) {} try { mcF = (*spL)["ManaCost"]; } catch (...) {} } uint32_t fc = sDbc->getFieldCount(); for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) { uint32_t id = sDbc->getUInt32(i, idF); if (id == 0) continue; uint32_t ct = sDbc->getUInt32(i, ctF); uint32_t ri = sDbc->getUInt32(i, rF); if (ct > 0) (*spellCastIdx)[id] = ct; if (ri > 0) (*spellRangeIdx)[id] = ri; // Extract power cost uint32_t mc = (mcF < fc) ? sDbc->getUInt32(i, mcF) : 0; uint8_t pt = (ptF < fc) ? static_cast(sDbc->getUInt32(i, ptF)) : 0; if (mc > 0) (*spellCostMap)[id] = {mc, pt}; } } LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ", spellRangeIdx->size(), " range indices"); } game::GameHandler::SpellDataInfo info; auto ciIt = spellCastIdx->find(spellId); if (ciIt != spellCastIdx->end()) { auto ctIt = castTimeMap->find(ciIt->second); if (ctIt != castTimeMap->end()) info.castTimeMs = ctIt->second; } auto riIt = spellRangeIdx->find(spellId); if (riIt != spellRangeIdx->end()) { auto rIt = rangeMap->find(riIt->second); if (rIt != rangeMap->end()) { info.minRange = rIt->second.first; info.maxRange = rIt->second.second; } } auto mcIt = spellCostMap->find(spellId); if (mcIt != spellCostMap->end()) { info.manaCost = mcIt->second.manaCost; info.powerType = mcIt->second.powerType; } return info; }); } // Wire random property/suffix name resolver for item display { auto propNames = std::make_shared>(); auto propLoaded = std::make_shared(false); auto* amPtr = assetManager.get(); gameHandler->setRandomPropertyNameResolver([propNames, propLoaded, amPtr](int32_t id) -> std::string { if (!amPtr || id == 0) return {}; if (!*propLoaded) { *propLoaded = true; // ItemRandomProperties.dbc: ID=0, Name=4 (string) if (auto dbc = amPtr->loadDBC("ItemRandomProperties.dbc"); dbc && dbc->isLoaded()) { uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1; for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { int32_t rid = static_cast(dbc->getUInt32(r, 0)); std::string name = dbc->getString(r, nameField); if (!name.empty() && rid > 0) (*propNames)[rid] = name; } } // ItemRandomSuffix.dbc: ID=0, Name=4 (string) — stored as negative IDs if (auto dbc = amPtr->loadDBC("ItemRandomSuffix.dbc"); dbc && dbc->isLoaded()) { uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1; for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { int32_t rid = static_cast(dbc->getUInt32(r, 0)); std::string name = dbc->getString(r, nameField); if (!name.empty() && rid > 0) (*propNames)[-rid] = name; } } } auto it = propNames->find(id); return (it != propNames->end()) ? it->second : std::string{}; }); } LOG_INFO("Addon system initialized, found ", addonManager_->getAddons().size(), " addon(s)"); } else { LOG_WARNING("Failed to initialize addon system"); addonManager_.reset(); } // Initialize world loader (handles terrain streaming, world preload, map transitions) worldLoader_ = std::make_unique( *this, renderer.get(), assetManager.get(), gameHandler.get(), entitySpawner_.get(), appearanceComposer_.get(), window.get(), world.get(), addonManager_.get()); // Re-wire UIServices now that all services (addonManager_, worldLoader_) are available if (uiManager) { ui::UIServices uiServices; uiServices.window = window.get(); uiServices.renderer = renderer.get(); uiServices.assetManager = assetManager.get(); uiServices.gameHandler = gameHandler.get(); uiServices.expansionRegistry = expansionRegistry_.get(); uiServices.addonManager = addonManager_.get(); uiServices.audioCoordinator = audioCoordinator_.get(); uiServices.entitySpawner = entitySpawner_.get(); uiServices.appearanceComposer = appearanceComposer_.get(); uiServices.worldLoader = worldLoader_.get(); uiManager->setServices(uiServices); } // Start background preload for last-played character's world. // Warms the file cache so terrain tile loading is faster at Enter World. { auto lastWorld = worldLoader_->loadLastWorldInfo(); if (lastWorld.valid) { worldLoader_->startWorldPreload(lastWorld.mapId, lastWorld.mapName, lastWorld.x, lastWorld.y); } } } else { LOG_WARNING("Failed to initialize asset manager - asset loading will be unavailable"); LOG_WARNING("Set WOW_DATA_PATH environment variable to your WoW Data directory"); } // Set up UI callbacks setupUICallbacks(); LOG_INFO("Application initialized successfully"); running = true; return true; } void Application::run() { ZoneScopedN("Application::run"); LOG_INFO("Starting main loop"); // Pin main thread to a dedicated CPU core to reduce scheduling jitter { int numCores = static_cast(std::thread::hardware_concurrency()); if (numCores >= 2) { #ifdef __linux__ cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(0, &cpuset); int rc = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset); if (rc == 0) { LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)"); } else { LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", rc, ")"); } #elif defined(_WIN32) DWORD_PTR mask = 1; // Core 0 DWORD_PTR prev = SetThreadAffinityMask(GetCurrentThread(), mask); if (prev != 0) { LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)"); } else { LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", GetLastError(), ")"); } #elif defined(__APPLE__) // macOS doesn't support hard pinning — use affinity tags to hint // that the main thread should stay on its own core group thread_affinity_policy_data_t policy = { 1 }; // tag 1 = main thread group kern_return_t kr = thread_policy_set( pthread_mach_thread_np(pthread_self()), THREAD_AFFINITY_POLICY, reinterpret_cast(&policy), THREAD_AFFINITY_POLICY_COUNT); if (kr == KERN_SUCCESS) { LOG_INFO("Main thread affinity tag set (", numCores, " cores available)"); } else { LOG_WARNING("Failed to set main thread affinity tag (error ", kr, ")"); } #endif } } const bool frameProfileEnabled = envFlagEnabled("WOWEE_FRAME_PROFILE", false); if (frameProfileEnabled) { LOG_INFO("Frame timing profile enabled (WOWEE_FRAME_PROFILE=1)"); } auto lastTime = std::chrono::high_resolution_clock::now(); std::atomic watchdogRunning{true}; std::atomic watchdogHeartbeatMs{ std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count() }; // Signal flag: watchdog sets this when a stall is detected, main loop // handles the actual SDL calls. SDL2 video functions must only be called // from the main thread (the one that called SDL_Init); calling them from // a background thread is UB on macOS (Cocoa) and unsafe on other platforms. std::atomic watchdogRequestRelease{false}; std::thread watchdogThread([&watchdogRunning, &watchdogHeartbeatMs, &watchdogRequestRelease]() { bool signalledForCurrentStall = false; while (watchdogRunning.load(std::memory_order_acquire)) { std::this_thread::sleep_for(std::chrono::milliseconds(250)); const int64_t nowMs = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count(); const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire); const int64_t stallMs = nowMs - lastBeatMs; if (stallMs > 1500) { if (!signalledForCurrentStall) { watchdogRequestRelease.store(true, std::memory_order_release); LOG_WARNING("Main-loop stall detected (", stallMs, "ms) — requesting mouse capture release"); signalledForCurrentStall = true; } } else { signalledForCurrentStall = false; } } }); try { while (running && !window->shouldClose()) { watchdogHeartbeatMs.store( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count(), std::memory_order_release); // Handle watchdog mouse-release request on the main thread where // SDL video calls are safe (required by SDL2 threading model). if (watchdogRequestRelease.exchange(false, std::memory_order_acq_rel)) { SDL_SetRelativeMouseMode(SDL_FALSE); SDL_ShowCursor(SDL_ENABLE); if (window && window->getSDLWindow()) { SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE); } LOG_WARNING("Watchdog: force-released mouse capture on main thread"); } // Calculate delta time auto currentTime = std::chrono::high_resolution_clock::now(); std::chrono::duration deltaTimeDuration = currentTime - lastTime; float deltaTime = deltaTimeDuration.count(); lastTime = currentTime; // Cap delta time to prevent large jumps if (deltaTime > 0.1f) { deltaTime = 0.1f; } // Poll events SDL_Event event; while (SDL_PollEvent(&event)) { // Pass event to UI manager first if (uiManager) { uiManager->processEvent(event); } // Pass mouse events to camera controller (skip when UI has mouse focus) if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) { if (event.type == SDL_MOUSEMOTION) { renderer->getCameraController()->processMouseMotion(event.motion); } else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { renderer->getCameraController()->processMouseButton(event.button); } else if (event.type == SDL_MOUSEWHEEL) { renderer->getCameraController()->processMouseWheel(static_cast(event.wheel.y)); } } // Handle window events if (event.type == SDL_QUIT) { window->setShouldClose(true); } else if (event.type == SDL_WINDOWEVENT) { if (event.window.event == SDL_WINDOWEVENT_RESIZED) { int newWidth = event.window.data1; int newHeight = event.window.data2; window->setSize(newWidth, newHeight); // Mark swapchain dirty so it gets recreated at the correct size if (window->getVkContext()) { window->getVkContext()->markSwapchainDirty(); } // Vulkan viewport set in command buffer, not globally if (renderer && renderer->getCamera() && newHeight > 0) { renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); } // Notify addons so UI layouts can adapt to the new size if (addonManager_) addonManager_->fireEvent("DISPLAY_SIZE_CHANGED"); } } // Debug controls else if (event.type == SDL_KEYDOWN) { // Skip non-function-key input when UI (chat) has keyboard focus bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard; auto sc = event.key.keysym.scancode; bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12); if (uiHasKeyboard && !isFKey) { continue; // Let ImGui handle the keystroke } // F1: Toggle performance HUD if (event.key.keysym.scancode == SDL_SCANCODE_F1) { if (renderer && renderer->getPerformanceHUD()) { renderer->getPerformanceHUD()->toggle(); bool enabled = renderer->getPerformanceHUD()->isEnabled(); LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF"); } } // F4: Toggle shadows else if (event.key.keysym.scancode == SDL_SCANCODE_F4) { if (renderer) { bool enabled = !renderer->areShadowsEnabled(); renderer->setShadowsEnabled(enabled); LOG_INFO("Shadows: ", enabled ? "ON" : "OFF"); } } // F8: Debug WMO floor at current position else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) { if (renderer && renderer->getWMORenderer()) { glm::vec3 pos = renderer->getCharacterPosition(); LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")"); renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z); } } } } // Update input Input::getInstance().update(); // Update application state try { FrameMark; update(deltaTime); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during Application::update (state=", static_cast(state), ", dt=", deltaTime, "): ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception during Application::update (state=", static_cast(state), ", dt=", deltaTime, "): ", e.what()); throw; } // Render try { render(); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); throw; } // Swap buffers try { window->swapBuffers(); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during swapBuffers: ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception during swapBuffers: ", e.what()); throw; } // Exit gracefully on GPU device lost (unrecoverable) if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) { LOG_ERROR("GPU device lost — exiting application"); window->setShouldClose(true); } // Soft frame rate cap when vsync is off to prevent 100% CPU usage. // Target ~240 FPS max (~4.2ms per frame); vsync handles its own pacing. if (!window->isVsyncEnabled() && deltaTime < 0.004f) { float sleepMs = (0.004f - deltaTime) * 1000.0f; if (sleepMs > 0.5f) std::this_thread::sleep_for(std::chrono::microseconds( static_cast(sleepMs * 900.0f))); // 90% of target to account for sleep overshoot } } } catch (...) { watchdogRunning.store(false, std::memory_order_release); if (watchdogThread.joinable()) { watchdogThread.join(); } throw; } watchdogRunning.store(false, std::memory_order_release); if (watchdogThread.joinable()) { watchdogThread.join(); } LOG_INFO("Main loop ended"); } void Application::shutdown() { LOG_DEBUG("Shutting down application..."); // Hide the window immediately so the OS doesn't think the app is frozen // during the (potentially slow) resource cleanup below. if (window && window->getSDLWindow()) { SDL_HideWindow(window->getSDLWindow()); } // Stop background world preloader before destroying AssetManager if (worldLoader_) { worldLoader_->cancelWorldPreload(); }; // Save floor cache before renderer is destroyed if (renderer && renderer->getWMORenderer()) { size_t cacheSize = renderer->getWMORenderer()->getFloorCacheSize(); if (cacheSize > 0) { LOG_DEBUG("Saving WMO floor cache (", cacheSize, " entries)..."); renderer->getWMORenderer()->saveFloorCache(); LOG_DEBUG("Floor cache saved."); } } // Explicitly shut down the renderer before destroying it — this ensures // all sub-renderers free their VMA allocations in the correct order, // before VkContext::shutdown() calls vmaDestroyAllocator(). LOG_DEBUG("Shutting down renderer..."); if (renderer) { renderer->shutdown(); } LOG_DEBUG("Renderer shutdown complete, resetting..."); renderer.reset(); // Shutdown audio coordinator after renderer (renderer may reference audio during shutdown) if (audioCoordinator_) { audioCoordinator_->shutdown(); } audioCoordinator_.reset(); LOG_DEBUG("Resetting world..."); world.reset(); LOG_DEBUG("Resetting gameHandler..."); gameHandler.reset(); gameServices_ = {}; LOG_DEBUG("Resetting authHandler..."); authHandler.reset(); LOG_DEBUG("Resetting assetManager..."); assetManager.reset(); LOG_DEBUG("Resetting uiManager..."); uiManager.reset(); LOG_DEBUG("Resetting window..."); window.reset(); running = false; LOG_DEBUG("Application shutdown complete"); } void Application::setState(AppState newState) { if (state == newState) { return; } LOG_INFO("State transition: ", static_cast(state), " -> ", static_cast(newState)); state = newState; // Handle state transitions switch (newState) { case AppState::AUTHENTICATION: // Show auth screen break; case AppState::REALM_SELECTION: // Show realm screen break; case AppState::CHARACTER_CREATION: // Show character create screen break; case AppState::CHARACTER_SELECTION: // Show character screen if (uiManager && assetManager) { uiManager->getCharacterScreen().setAssetManager(assetManager.get()); } // Ensure no stale in-world player model leaks into the next login attempt. // If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync. if (addonManager_ && addonsLoaded_) { addonManager_->fireEvent("PLAYER_LEAVING_WORLD"); addonManager_->saveAllSavedVariables(); } npcsSpawned = false; playerCharacterSpawned = false; addonsLoaded_ = false; if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); wasAutoAttacking_ = false; if (worldLoader_) worldLoader_->resetLoadedMap(); spawnedPlayerGuid_ = 0; spawnedAppearanceBytes_ = 0; spawnedFacialFeatures_ = 0; if (renderer && renderer->getCharacterRenderer()) { uint32_t oldInst = renderer->getCharacterInstanceId(); if (oldInst > 0) { renderer->setCharacterFollow(0); if (auto* ac = renderer->getAnimationController()) ac->clearMount(); renderer->getCharacterRenderer()->removeInstance(oldInst); } } break; case AppState::IN_GAME: { // Wire up movement opcodes from camera controller if (renderer && renderer->getCameraController()) { auto* cc = renderer->getCameraController(); cc->setMovementCallback([this](uint32_t opcode) { if (gameHandler) { gameHandler->sendMovement(static_cast(opcode)); } }); cc->setStandUpCallback([this]() { if (gameHandler) { gameHandler->setStandState(rendering::AnimationController::STAND_STATE_STAND); } }); cc->setSitDownCallback([this]() { if (gameHandler) { gameHandler->setStandState(rendering::AnimationController::STAND_STATE_SIT); } if (renderer) { if (auto* ac = renderer->getAnimationController()) { ac->setStandState(rendering::AnimationController::STAND_STATE_SIT); } } }); cc->setAutoFollowCancelCallback([this]() { if (gameHandler) { gameHandler->cancelFollow(); } }); cc->setUseWoWSpeed(true); } if (gameHandler) { gameHandler->setMeleeSwingCallback([this](uint32_t spellId) { if (renderer) { // Ranged auto-attack spells: Auto Shot (75), Shoot (5019), Throw (2764) if (spellId == 75 || spellId == 5019 || spellId == 2764) { if (appearanceComposer_ && !appearanceComposer_->isShowingRanged()) appearanceComposer_->showRangedWeapon(true); if (auto* ac = renderer->getAnimationController()) ac->triggerRangedShot(); } else if (spellId != 0) { if (appearanceComposer_ && appearanceComposer_->isShowingRanged()) appearanceComposer_->showRangedWeapon(false); if (auto* ac = renderer->getAnimationController()) ac->triggerSpecialAttack(spellId); } else { if (appearanceComposer_ && appearanceComposer_->isShowingRanged()) appearanceComposer_->showRangedWeapon(false); if (auto* ac = renderer->getAnimationController()) ac->triggerMeleeSwing(); } } }); gameHandler->setRangedWeaponSwapCallback([this](bool show) { if (appearanceComposer_) appearanceComposer_->showRangedWeapon(show); }); gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) { if (renderer && renderer->getCameraController()) { renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); } }); gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) { if (renderer && renderer->getCameraController()) { renderer->getCameraController()->triggerShake(magnitude, frequency, duration); } }); gameHandler->setAutoFollowCallback([this](const glm::vec3* renderPos) { if (renderer && renderer->getCameraController()) { if (renderPos) { renderer->getCameraController()->setAutoFollow(renderPos); } else { renderer->getCameraController()->cancelAutoFollow(); } } }); } // Load quest marker models loadQuestMarkerModels(); break; } case AppState::DISCONNECTED: // Back to auth break; } } void Application::reloadExpansionData() { if (!expansionRegistry_ || !gameHandler) return; auto* profile = expansionRegistry_->getActive(); if (!profile) return; LOG_INFO("Reloading expansion data for: ", profile->name); std::string opcodesPath = profile->dataPath + "/opcodes.json"; if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) { LOG_ERROR("Failed to load opcodes from ", opcodesPath); } game::setActiveOpcodeTable(&gameHandler->getOpcodeTable()); std::string updateFieldsPath = profile->dataPath + "/update_fields.json"; if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) { LOG_ERROR("Failed to load update fields from ", updateFieldsPath); } game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable()); gameHandler->setPacketParsers(game::createPacketParsers(profile->id)); if (dbcLayout_) { std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json"; if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) { LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath); } pipeline::setActiveDBCLayout(dbcLayout_.get()); } // Update expansion data path for CSV DBC lookups and clear DBC cache if (assetManager && !profile->dataPath.empty()) { assetManager->setExpansionDataPath(profile->dataPath); assetManager->clearDBCCache(); } // Reset map name cache so it reloads from new expansion's Map.dbc if (worldLoader_) worldLoader_->resetMapNameCache(); // Reset game handler DBC caches so they reload from new expansion data if (gameHandler) { gameHandler->resetDbcCaches(); } // Rebuild creature display lookups with the new expansion's DBC layout if (entitySpawner_) entitySpawner_->rebuildLookups(); } void Application::logoutToLogin() { LOG_INFO("Logout requested"); // Disconnect TransportManager from WMORenderer before tearing down if (gameHandler && gameHandler->getTransportManager()) { gameHandler->getTransportManager()->setWMORenderer(nullptr); } if (gameHandler) { gameHandler->disconnect(); } // --- Per-session flags --- npcsSpawned = false; playerCharacterSpawned = false; if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); wasAutoAttacking_ = false; if (worldLoader_) worldLoader_->resetLoadedMap(); if (worldEntryCallbacks_) worldEntryCallbacks_->resetState(); facingSendCooldown_ = 0.0f; lastSentCanonicalYaw_ = 1000.0f; taxiStreamCooldown_ = 0.0f; idleYawned_ = false; // --- Charge state --- if (animationCallbacks_) animationCallbacks_->resetChargeState(); // --- Player identity --- spawnedPlayerGuid_ = 0; spawnedAppearanceBytes_ = 0; spawnedFacialFeatures_ = 0; // --- Reset all EntitySpawner state (mount, creatures, players, GOs, queues, caches) --- if (entitySpawner_) entitySpawner_->resetAllState(); world.reset(); if (renderer) { renderer->resetCombatVisualState(); // Remove old player model so it doesn't persist into next session if (auto* charRenderer = renderer->getCharacterRenderer()) { charRenderer->removeInstance(1); } // Clear all world geometry renderers if (auto* wmo = renderer->getWMORenderer()) { wmo->clearInstances(); } if (auto* m2 = renderer->getM2Renderer()) { m2->clear(); } // Clear terrain tile tracking + water surfaces so next world entry starts fresh. // Use softReset() instead of unloadAll() to avoid blocking on worker thread joins. if (auto* terrain = renderer->getTerrainManager()) { terrain->softReset(); } if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { questMarkers->clear(); } if (auto* ac = renderer->getAnimationController()) ac->clearMount(); renderer->setCharacterFollow(0); if (auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr) { music->stopMusic(0.0f); } } // Clear stale realm/character selection so switching servers starts fresh if (uiManager) { uiManager->getRealmScreen().reset(); uiManager->getCharacterScreen().reset(); } setState(AppState::AUTHENTICATION); } void Application::update(float deltaTime) { ZoneScopedN("Application::update"); const char* updateCheckpoint = "enter"; try { // Update based on current state updateCheckpoint = "state switch"; switch (state) { case AppState::AUTHENTICATION: updateCheckpoint = "auth: enter"; if (authHandler) { authHandler->update(deltaTime); } break; case AppState::REALM_SELECTION: updateCheckpoint = "realm_selection: enter"; if (authHandler) { authHandler->update(deltaTime); } break; case AppState::CHARACTER_CREATION: updateCheckpoint = "char_creation: enter"; if (gameHandler) { gameHandler->update(deltaTime); } if (uiManager) { uiManager->getCharacterCreateScreen().update(deltaTime); } break; case AppState::CHARACTER_SELECTION: updateCheckpoint = "char_selection: enter"; if (gameHandler) { gameHandler->update(deltaTime); } break; case AppState::IN_GAME: { updateCheckpoint = "in_game: enter"; const char* inGameStep = "begin"; try { auto runInGameStage = [&](const char* stageName, auto&& fn) { auto stageStart = std::chrono::steady_clock::now(); try { fn(); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during IN_GAME update stage '", stageName, "': ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception during IN_GAME update stage '", stageName, "': ", e.what()); throw; } auto stageEnd = std::chrono::steady_clock::now(); float stageMs = std::chrono::duration(stageEnd - stageStart).count(); if (stageMs > 50.0f) { LOG_WARNING("SLOW update stage '", stageName, "': ", stageMs, "ms"); } }; inGameStep = "gameHandler update"; updateCheckpoint = "in_game: gameHandler update"; runInGameStage("gameHandler->update", [&] { if (gameHandler) { gameHandler->update(deltaTime); } }); if (addonManager_ && addonsLoaded_) { addonManager_->update(deltaTime); } // Always unsheath on combat engage. inGameStep = "auto-unsheathe"; updateCheckpoint = "in_game: auto-unsheathe"; if (gameHandler) { const bool autoAttacking = gameHandler->isAutoAttacking(); if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) { appearanceComposer_->setWeaponsSheathed(false); appearanceComposer_->loadEquippedWeapons(); } // Swap back to melee weapon when auto-attack stops if (!autoAttacking && wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isShowingRanged()) { appearanceComposer_->showRangedWeapon(false); } wasAutoAttacking_ = autoAttacking; } // Toggle weapon sheathe state with Z (ignored while UI captures keyboard). inGameStep = "weapon-toggle input"; updateCheckpoint = "in_game: weapon-toggle input"; { const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; auto& input = Input::getInstance(); if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z) && appearanceComposer_) { appearanceComposer_->toggleWeaponsSheathed(); appearanceComposer_->loadEquippedWeapons(); } } inGameStep = "world update"; updateCheckpoint = "in_game: world update"; runInGameStage("world->update", [&] { if (world) { world->update(deltaTime); } }); inGameStep = "spawn/equipment queues"; updateCheckpoint = "in_game: spawn/equipment queues"; runInGameStage("spawn/equipment queues", [&] { if (entitySpawner_) entitySpawner_->update(); if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { cr->processPendingNormalMaps(4); } }); // Self-heal missing creature visuals: if a nearby UNIT exists in // entity state but has no render instance, queue a spawn retry. inGameStep = "creature resync scan"; updateCheckpoint = "in_game: creature resync scan"; if (gameHandler) { static float creatureResyncTimer = 0.0f; creatureResyncTimer += deltaTime; if (creatureResyncTimer >= 3.0f) { creatureResyncTimer = 0.0f; glm::vec3 playerPos(0.0f); bool havePlayerPos = false; uint64_t playerGuid = gameHandler->getPlayerGuid(); if (auto playerEntity = gameHandler->getEntityManager().getEntity(playerGuid)) { playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); havePlayerPos = true; } const float kResyncRadiusSq = 260.0f * 260.0f; for (const auto& pair : gameHandler->getEntityManager().getEntities()) { uint64_t guid = pair.first; const auto& entity = pair.second; if (!entity || guid == playerGuid) continue; if (entity->getType() != game::ObjectType::UNIT) continue; auto unit = std::dynamic_pointer_cast(entity); if (!unit || unit->getDisplayId() == 0) continue; if (entitySpawner_->isCreatureSpawned(guid) || entitySpawner_->isCreaturePending(guid)) continue; if (havePlayerPos) { glm::vec3 pos(unit->getX(), unit->getY(), unit->getZ()); glm::vec3 delta = pos - playerPos; float distSq = glm::dot(delta, delta); if (distSq > kResyncRadiusSq) continue; } float retryScale = 1.0f; { using game::fieldIndex; using game::UF; uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X); if (si != 0xFFFF) { uint32_t raw = unit->getField(si); if (raw != 0) { float s2 = 1.0f; std::memcpy(&s2, &raw, sizeof(float)); if (s2 > 0.01f && s2 < 100.0f) retryScale = s2; } } } entitySpawner_->queueCreatureSpawn(guid, unit->getDisplayId(), unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), retryScale); } } } inGameStep = "gameobject/transport queues"; updateCheckpoint = "in_game: gameobject/transport queues"; runInGameStage("gameobject/transport queues", [&] { // GO/transport queues handled by entitySpawner_->update() above }); inGameStep = "pending mount"; updateCheckpoint = "in_game: pending mount"; runInGameStage("processPendingMount", [&] { // Mount processing handled by entitySpawner_->update() above }); // Update 3D quest markers above NPCs inGameStep = "quest markers"; updateCheckpoint = "in_game: quest markers"; runInGameStage("updateQuestMarkers", [&] { updateQuestMarkers(); }); // Sync server run speed to camera controller inGameStep = "post-update sync"; updateCheckpoint = "in_game: post-update sync"; runInGameStage("post-update sync", [&] { if (renderer && gameHandler && renderer->getCameraController()) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed()); renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed()); renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate()); renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying()); renderer->getCameraController()->setHoverActive(gameHandler->isHovering()); // Sync camera forward pitch to movement packets during flight / swimming. // The server writes the pitch field when FLYING or SWIMMING flags are set; // without this sync it would always be 0 (horizontal), causing other // players to see the character flying flat even when pitching up/down. if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) { if (auto* cam = renderer->getCamera()) { glm::vec3 fwd = cam->getForward(); float len = glm::length(fwd); if (len > 1e-4f) { float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f)); gameHandler->setMovementPitch(pitchRad); // Tilt the mount/character model to match flight direction // (taxi flight uses setTaxiOrientationCallback for this instead) if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) { if (auto* ac = renderer->getAnimationController()) ac->setMountPitchRoll(pitchRad, 0.0f); } } } } else if (gameHandler->isMounted()) { // Reset mount pitch when not flying if (auto* ac = renderer->getAnimationController()) ac->setMountPitchRoll(0.0f, 0.0f); } } bool onTaxi = gameHandler && (gameHandler->isOnTaxiFlight() || gameHandler->isTaxiMountActive() || gameHandler->isTaxiActivationPending()); bool onTransportNow = gameHandler && gameHandler->isOnTransport(); // Clear stale client-side transport state when the tracked transport no longer exists. if (onTransportNow && gameHandler->getTransportManager()) { auto* currentTracked = gameHandler->getTransportManager()->getTransport( gameHandler->getPlayerTransportGuid()); if (!currentTracked) { gameHandler->clearPlayerTransport(); onTransportNow = false; } } // M2 transports (trams) use position-delta approach: player keeps normal // movement and the transport's frame-to-frame delta is applied on top. // Only WMO transports (ships) use full external-driven mode. bool isM2Transport = false; if (onTransportNow && gameHandler->getTransportManager()) { auto* tr = gameHandler->getTransportManager()->getTransport(gameHandler->getPlayerTransportGuid()); isM2Transport = (tr && tr->isM2); } bool onWMOTransport = onTransportNow && !isM2Transport; if (worldEntryCallbacks_ && worldEntryCallbacks_->getWorldEntryMovementGraceTimer() > 0.0f) { worldEntryCallbacks_->setWorldEntryMovementGraceTimer( worldEntryCallbacks_->getWorldEntryMovementGraceTimer() - deltaTime); // Clear stale movement from before teleport each frame // until grace period expires (keys may still be held) if (renderer && renderer->getCameraController()) renderer->getCameraController()->clearMovementInputs(); } // Hearth teleport: delegated to WorldEntryCallbackHandler if (worldEntryCallbacks_) { worldEntryCallbacks_->update(deltaTime); } if (renderer && renderer->getCameraController()) { const bool externallyDrivenMotion = onTaxi || onWMOTransport || (animationCallbacks_ && animationCallbacks_->isCharging()); // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. bool hearthFreeze = worldEntryCallbacks_ && worldEntryCallbacks_->isHearthTeleportPending(); bool landingClampActive = !onTaxi && worldEntryCallbacks_ && worldEntryCallbacks_->getTaxiLandingClampTimer() > 0.0f && worldEntryCallbacks_->getWorldEntryMovementGraceTimer() <= 0.0f && !gameHandler->isMounted(); renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive || hearthFreeze); renderer->getCameraController()->setExternalMoving(externallyDrivenMotion); if (externallyDrivenMotion) { // Drop any stale local movement toggles while server drives taxi motion. renderer->getCameraController()->clearMovementInputs(); if (worldEntryCallbacks_) worldEntryCallbacks_->setTaxiLandingClampTimer(0.0f); } if (worldEntryCallbacks_ && worldEntryCallbacks_->getLastTaxiFlight() && !onTaxi) { renderer->getCameraController()->clearMovementInputs(); // Keep clamping until terrain loads at landing position. // Timer only counts down once a valid floor is found. if (worldEntryCallbacks_) worldEntryCallbacks_->setTaxiLandingClampTimer(2.0f); } if (landingClampActive) { if (renderer && gameHandler) { glm::vec3 p = renderer->getCharacterPosition(); std::optional terrainFloor; std::optional wmoFloor; std::optional m2Floor; if (renderer->getTerrainManager()) { terrainFloor = renderer->getTerrainManager()->getHeightAt(p.x, p.y); } if (renderer->getWMORenderer()) { // Probe from above so we can recover when current Z is already below floor. wmoFloor = renderer->getWMORenderer()->getFloorHeight(p.x, p.y, p.z + 40.0f); } if (renderer->getM2Renderer()) { // Include M2 floors (bridges/platforms) in landing recovery. m2Floor = renderer->getM2Renderer()->getFloorHeight(p.x, p.y, p.z + 40.0f); } std::optional targetFloor; if (terrainFloor) targetFloor = terrainFloor; if (wmoFloor && (!targetFloor || *wmoFloor > *targetFloor)) targetFloor = wmoFloor; if (m2Floor && (!targetFloor || *m2Floor > *targetFloor)) targetFloor = m2Floor; if (targetFloor) { // Floor found — snap player to it and start countdown to release float targetZ = *targetFloor + 0.10f; if (std::abs(p.z - targetZ) > 0.05f) { p.z = targetZ; renderer->getCharacterPosition() = p; glm::vec3 canonical = core::coords::renderToCanonical(p); gameHandler->setPosition(canonical.x, canonical.y, canonical.z); gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); } float clampTimer = worldEntryCallbacks_ ? worldEntryCallbacks_->getTaxiLandingClampTimer() : 0.0f; clampTimer -= deltaTime; if (worldEntryCallbacks_) worldEntryCallbacks_->setTaxiLandingClampTimer(clampTimer); } // No floor found: don't decrement timer, keep player frozen until terrain loads } } bool idleOrbit = renderer->getCameraController()->isIdleOrbit(); if (idleOrbit && !idleYawned_ && renderer) { if (auto* ac = renderer->getAnimationController()) ac->playEmote("yawn"); idleYawned_ = true; } else if (!idleOrbit) { idleYawned_ = false; } } if (renderer) { if (auto* ac = renderer->getAnimationController()) ac->setTaxiFlight(onTaxi); } if (renderer && renderer->getTerrainManager()) { renderer->getTerrainManager()->setStreamingEnabled(true); // Taxi flights move fast (32 u/s) — load further ahead so terrain is ready // before the camera arrives. Keep updates frequent to spot new tiles early. renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.033f : 0.033f); renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4); renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7); renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi); } if (worldEntryCallbacks_) worldEntryCallbacks_->setLastTaxiFlight(onTaxi); // Sync character render position ↔ canonical WoW coords each frame if (renderer && gameHandler) { // For position sync branching, only WMO transports use the dedicated // onTransport branch. M2 transports use the normal movement else branch // with a position-delta correction applied on top. bool onTransport = onWMOTransport; static bool wasOnTransport = false; bool onTransportNowDbg = gameHandler->isOnTransport(); if (onTransportNowDbg != wasOnTransport) { LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg, " isM2=", isM2Transport, " guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec); wasOnTransport = onTransportNowDbg; } if (onTaxi) { auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid()); glm::vec3 canonical(0.0f); bool haveCanonical = false; if (playerEntity) { canonical = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); haveCanonical = true; } else { // Fallback for brief entity gaps during taxi start/updates: // movementInfo is still updated by client taxi simulation. const auto& move = gameHandler->getMovementInfo(); canonical = glm::vec3(move.x, move.y, move.z); haveCanonical = true; } if (haveCanonical) { glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; if (renderer->getCameraController()) { glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); if (followTarget) { *followTarget = renderPos; } } } } else if (onTransport) { // WMO transport mode (ships): compose world position from transform + local offset glm::vec3 canonical = gameHandler->getComposedWorldPosition(); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; gameHandler->setPosition(canonical.x, canonical.y, canonical.z); if (renderer->getCameraController()) { glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); if (followTarget) { *followTarget = renderPos; } } } else if (animationCallbacks_ && animationCallbacks_->isCharging()) { // Warrior Charge: interpolation delegated to AnimationCallbackHandler animationCallbacks_->updateCharge(deltaTime); } else { glm::vec3 renderPos = renderer->getCharacterPosition(); // M2 transport riding: resolve in canonical space and lock once per frame. // This avoids visible jitter from mixed render/canonical delta application. if (isM2Transport && gameHandler->getTransportManager()) { auto* tr = gameHandler->getTransportManager()->getTransport( gameHandler->getPlayerTransportGuid()); if (tr) { // Keep passenger locked to elevator vertical motion while grounded. // Without this, floor clamping can hold world-Z static unless the // player is jumping, which makes lifts appear to not move vertically. glm::vec3 tentativeCanonical = core::coords::renderToCanonical(renderPos); glm::vec3 localOffset = gameHandler->getPlayerTransportOffset(); localOffset.x = tentativeCanonical.x - tr->position.x; localOffset.y = tentativeCanonical.y - tr->position.y; if (renderer->getCameraController() && !renderer->getCameraController()->isGrounded()) { // While airborne (jump/fall), allow local Z offset to change. localOffset.z = tentativeCanonical.z - tr->position.z; } gameHandler->setPlayerTransportOffset(localOffset); glm::vec3 lockedCanonical = tr->position + localOffset; renderPos = core::coords::canonicalToRender(lockedCanonical); renderer->getCharacterPosition() = renderPos; } } glm::vec3 canonical = core::coords::renderToCanonical(renderPos); gameHandler->setPosition(canonical.x, canonical.y, canonical.z); // Sync orientation: camera yaw (degrees) → WoW orientation (radians) float yawDeg = renderer->getCharacterYaw(); // Keep all game-side orientation in canonical space. // We historically sent serverYaw = radians(yawDeg - 90). With the new // canonical<->server mapping (serverYaw = PI/2 - canonicalYaw), the // equivalent canonical yaw is radians(180 - yawDeg). float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg)); gameHandler->setOrientation(canonicalYaw); // Send MSG_MOVE_SET_FACING when the player changes facing direction // (e.g. via mouse-look). Without this, the server predicts movement in // the old facing and position-corrects on the next heartbeat — the // micro-teleporting the GM observed. // Skip while keyboard-turning: the server tracks that via TURN_LEFT/RIGHT flags. facingSendCooldown_ -= deltaTime; const auto& mi = gameHandler->getMovementInfo(); constexpr uint32_t kTurnFlags = static_cast(game::MovementFlags::TURN_LEFT) | static_cast(game::MovementFlags::TURN_RIGHT); bool keyboardTurning = (mi.flags & kTurnFlags) != 0; if (!keyboardTurning && facingSendCooldown_ <= 0.0f) { float yawDiff = core::coords::normalizeAngleRad(canonicalYaw - lastSentCanonicalYaw_); if (std::abs(yawDiff) > glm::radians(3.0f)) { gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING); lastSentCanonicalYaw_ = canonicalYaw; facingSendCooldown_ = 0.1f; // max 10 Hz } } // Client-side transport boarding detection (for M2 transports like trams // and lifts where the server doesn't send transport attachment data). // Thunder Bluff elevators use model origins that can be far from the deck // the player stands on, so they need wider attachment bounds. if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) { auto* tm = gameHandler->getTransportManager(); glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f; constexpr float kM2BoardVertDist = 15.0f; constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f; constexpr float kTbLiftBoardVertDist = 14.0f; uint64_t bestGuid = 0; float bestScore = 1e30f; for (auto& [guid, transport] : tm->getTransports()) { if (!transport.isM2) continue; const bool isThunderBluffLift = (transport.entry >= 20649u && transport.entry <= 20657u); const float maxHorizDistSq = isThunderBluffLift ? kTbLiftBoardHorizDistSq : kM2BoardHorizDistSq; const float maxVertDist = isThunderBluffLift ? kTbLiftBoardVertDist : kM2BoardVertDist; glm::vec3 diff = playerCanonical - transport.position; float horizDistSq = diff.x * diff.x + diff.y * diff.y; float vertDist = std::abs(diff.z); if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) { float score = horizDistSq + vertDist * vertDist; if (score < bestScore) { bestScore = score; bestGuid = guid; } } } if (bestGuid != 0) { auto* tr = tm->getTransport(bestGuid); if (tr) { gameHandler->setPlayerOnTransport(bestGuid, playerCanonical - tr->position); LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, bestGuid, std::dec); } } } // M2 transport disembark: player walked far enough from transport center if (isM2Transport && gameHandler->getTransportManager()) { auto* tm = gameHandler->getTransportManager(); auto* tr = tm->getTransport(gameHandler->getPlayerTransportGuid()); if (tr) { glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); glm::vec3 diff = playerCanonical - tr->position; float horizDistSq = diff.x * diff.x + diff.y * diff.y; const bool isThunderBluffLift = (tr->entry >= 20649u && tr->entry <= 20657u); constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f; constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f; constexpr float kM2DisembarkVertDist = 18.0f; constexpr float kTbLiftDisembarkVertDist = 16.0f; const float disembarkHorizDistSq = isThunderBluffLift ? kTbLiftDisembarkHorizDistSq : kM2DisembarkHorizDistSq; const float disembarkVertDist = isThunderBluffLift ? kTbLiftDisembarkVertDist : kM2DisembarkVertDist; if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) { gameHandler->clearPlayerTransport(); LOG_DEBUG("M2 transport disembark"); } } } } } }); // Keep creature render instances aligned with authoritative entity positions. // This prevents desync where target circles move with server entities but // creature models remain at stale spawn positions. inGameStep = "creature render sync"; updateCheckpoint = "in_game: creature render sync"; auto creatureSyncStart = std::chrono::steady_clock::now(); if (renderer && gameHandler && renderer->getCharacterRenderer()) { auto* charRenderer = renderer->getCharacterRenderer(); static float npcWeaponRetryTimer = 0.0f; npcWeaponRetryTimer += deltaTime; const bool npcWeaponRetryTick = (npcWeaponRetryTimer >= 1.0f); if (npcWeaponRetryTick) npcWeaponRetryTimer = 0.0f; int weaponAttachesThisTick = 0; glm::vec3 playerPos(0.0f); glm::vec3 playerRenderPos(0.0f); bool havePlayerPos = false; float playerCollisionRadius = 0.65f; if (auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) { playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()); playerRenderPos = core::coords::canonicalToRender(playerPos); havePlayerPos = true; glm::vec3 pc; float pr = 0.0f; if (getRenderBoundsForGuid(gameHandler->getPlayerGuid(), pc, pr)) { playerCollisionRadius = std::clamp(pr * 0.35f, 0.45f, 1.1f); } } const float syncRadiusSq = 320.0f * 320.0f; auto& _creatureInstances = entitySpawner_->getCreatureInstances(); auto& _creatureWeaponsAttached = entitySpawner_->getCreatureWeaponsAttached(); auto& _creatureWeaponAttachAttempts = entitySpawner_->getCreatureWeaponAttachAttempts(); auto& _creatureModelIds = entitySpawner_->getCreatureModelIds(); auto& _modelIdIsWolfLike = entitySpawner_->getModelIdIsWolfLike(); auto& _creatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache(); auto& _creatureSwimmingState = entitySpawner_->getCreatureSwimmingState(); auto& _creatureWalkingState = entitySpawner_->getCreatureWalkingState(); auto& _creatureFlyingState = entitySpawner_->getCreatureFlyingState(); auto& _creatureWasMoving = entitySpawner_->getCreatureWasMoving(); auto& _creatureWasSwimming = entitySpawner_->getCreatureWasSwimming(); auto& _creatureWasFlying = entitySpawner_->getCreatureWasFlying(); auto& _creatureWasWalking = entitySpawner_->getCreatureWasWalking(); for (const auto& [guid, instanceId] : _creatureInstances) { auto entity = gameHandler->getEntityManager().getEntity(guid); if (!entity || entity->getType() != game::ObjectType::UNIT) continue; if (npcWeaponRetryTick && weaponAttachesThisTick < EntitySpawner::MAX_WEAPON_ATTACHES_PER_TICK && !_creatureWeaponsAttached.count(guid)) { uint8_t attempts = 0; auto itAttempts = _creatureWeaponAttachAttempts.find(guid); if (itAttempts != _creatureWeaponAttachAttempts.end()) attempts = itAttempts->second; if (attempts < 30) { weaponAttachesThisTick++; if (entitySpawner_->tryAttachCreatureVirtualWeapons(guid, instanceId)) { _creatureWeaponsAttached.insert(guid); _creatureWeaponAttachAttempts.erase(guid); } else { _creatureWeaponAttachAttempts[guid] = static_cast(attempts + 1); } } } // Distance check uses getLatestX/Y/Z (server-authoritative destination) to // avoid false-culling entities that moved while getX/Y/Z was stale. // Position sync still uses getX/Y/Z to preserve smooth interpolation for // nearby entities; distant entities (> 150u) have planarDist≈0 anyway // so the renderer remains driven correctly by creatureMoveCallback_. glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); float canonDistSq = 0.0f; if (havePlayerPos) { glm::vec3 d = latestCanonical - playerPos; canonDistSq = glm::dot(d, d); if (canonDistSq > syncRadiusSq) continue; } // Use the destination position once the entity has reached its // target. During the dead-reckoning overrun window getX/Y/Z // drifts past the destination at the last known velocity; // using getLatest (== moveEnd while isMoving_) avoids the // visible forward-drift followed by a backward snap. const bool inOverrun = entity->isEntityMoving() && !entity->isActivelyMoving(); glm::vec3 canonical( inOverrun ? entity->getLatestX() : entity->getX(), inOverrun ? entity->getLatestY() : entity->getY(), inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // Clamp creature Z to terrain surface during movement interpolation. // The server sends single-segment moves and expects the client to place // creatures on the ground. Only clamp while actively moving — idle // creatures keep their server-authoritative Z (flight masters, etc.). if (entity->isActivelyMoving() && renderer->getTerrainManager()) { auto terrainZ = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); if (terrainZ.has_value()) { renderPos.z = terrainZ.value(); } } // Visual collision guard: keep hostile melee units from rendering inside the // player's model while attacking. This is client-side only (no server position change). // Only check for creatures within 8 units (melee range) — saves expensive // getRenderBoundsForGuid/getModelData calls for distant creatures. bool clipGuardEligible = false; bool isCombatTarget = false; if (havePlayerPos && canonDistSq < 64.0f) { // 8² = melee range auto unit = std::static_pointer_cast(entity); const uint64_t currentTargetGuid = gameHandler->hasTarget() ? gameHandler->getTargetGuid() : 0; const uint64_t autoAttackGuid = gameHandler->getAutoAttackTargetGuid(); isCombatTarget = (guid == currentTargetGuid || guid == autoAttackGuid); clipGuardEligible = unit->getHealth() > 0 && (unit->isHostile() || gameHandler->isAggressiveTowardPlayer(guid) || isCombatTarget); } if (clipGuardEligible) { float creatureCollisionRadius = 0.8f; glm::vec3 cc; float cr = 0.0f; if (getRenderBoundsForGuid(guid, cc, cr)) { creatureCollisionRadius = std::clamp(cr * 0.45f, 0.65f, 1.9f); } float minSep = std::max(playerCollisionRadius + creatureCollisionRadius, 1.9f); if (isCombatTarget) { // Stronger spacing for the actively engaged attacker to avoid bite-overlap. minSep = std::max(minSep, 2.2f); } // Species/model-specific spacing for wolf-like creatures (their lunge anims // often put head/torso inside the player capsule). auto mit = _creatureModelIds.find(guid); if (mit != _creatureModelIds.end()) { uint32_t mid = mit->second; auto wolfIt = _modelIdIsWolfLike.find(mid); if (wolfIt == _modelIdIsWolfLike.end()) { bool isWolf = false; if (const auto* md = charRenderer->getModelData(mid)) { std::string modelName = md->name; std::transform(modelName.begin(), modelName.end(), modelName.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); isWolf = (modelName.find("wolf") != std::string::npos || modelName.find("worg") != std::string::npos); } wolfIt = _modelIdIsWolfLike.emplace(mid, isWolf).first; } if (wolfIt->second) { minSep = std::max(minSep, 2.45f); } } glm::vec2 d2(renderPos.x - playerRenderPos.x, renderPos.y - playerRenderPos.y); float distSq2 = glm::dot(d2, d2); if (distSq2 < (minSep * minSep)) { glm::vec2 dir2(1.0f, 0.0f); if (distSq2 > 1e-6f) { dir2 = d2 * (1.0f / std::sqrt(distSq2)); } glm::vec2 clamped2 = glm::vec2(playerRenderPos.x, playerRenderPos.y) + dir2 * minSep; renderPos.x = clamped2.x; renderPos.y = clamped2.y; } } auto posIt = _creatureRenderPosCache.find(guid); if (posIt == _creatureRenderPosCache.end()) { charRenderer->setInstancePosition(instanceId, renderPos); _creatureRenderPosCache[guid] = renderPos; } else { const glm::vec3 prevPos = posIt->second; float ddx2 = renderPos.x - prevPos.x; float ddy2 = renderPos.y - prevPos.y; float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2; float dz = std::abs(renderPos.z - prevPos.z); auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f); // Use isActivelyMoving() so Run/Walk animation stops when the // creature reaches its destination. Don't use position-change // (planarDistSq) as a movement indicator when the entity is in // the dead-reckoning overrun window — the residual velocity // drift would keep the walk/run animation playing long after // the creature has actually arrived. Only fall back to position- // change detection for entities with no active movement tracking // (e.g. teleports or position-only updates from the server). const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq = 0.03f * 0.03f; const bool posChanging = planarDistSq > kMoveThreshSq || dz > 0.08f; const bool isMovingNow = !deadOrCorpse && (entityIsMoving || (posChanging && !entity->isEntityMoving())); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); } else if (planarDistSq > kMoveThreshSq || dz > 0.08f) { // Position changed in entity coords → drive renderer toward it. float planarDist = std::sqrt(planarDistSq); float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } // When entity is moving but getX/Y/Z is stale (distance-culled), // don't call moveInstanceTo — creatureMoveCallback_ already drove // the renderer to the correct destination via the spline packet. posIt->second = renderPos; // Drive movement animation: Walk/Run/Swim (4/5/42) when moving, // Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set. // WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim. // Only switch on transitions to avoid resetting animation time. // Don't override Death (1) animation. const bool isSwimmingNow = _creatureSwimmingState.count(guid) > 0; const bool isWalkingNow = _creatureWalkingState.count(guid) > 0; const bool isFlyingNow = _creatureFlyingState.count(guid) > 0; bool prevMoving = _creatureWasMoving[guid]; bool prevSwimming = _creatureWasSwimming[guid]; bool prevFlying = _creatureWasFlying[guid]; bool prevWalking = _creatureWasWalking[guid]; // Trigger animation update on any locomotion-state transition, not just // moving/idle — e.g. creature lands while still moving → FlyForward→Run, // or server changes WALKING flag while creature is already running → Walk. const bool stateChanged = (isMovingNow != prevMoving) || (isSwimmingNow != prevSwimming) || (isFlyingNow != prevFlying) || (isWalkingNow != prevWalking && isMovingNow); if (stateChanged) { _creatureWasMoving[guid] = isMovingNow; _creatureWasSwimming[guid] = isSwimmingNow; _creatureWasFlying[guid] = isFlyingNow; _creatureWasWalking[guid] = isWalkingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != rendering::anim::DEATH) { uint32_t targetAnim; if (isMovingNow) { if (isFlyingNow) targetAnim = rendering::anim::FLY_FORWARD; else if (isSwimmingNow) targetAnim = rendering::anim::SWIM; else if (isWalkingNow) targetAnim = rendering::anim::WALK; else targetAnim = rendering::anim::RUN; } else { if (isFlyingNow) targetAnim = rendering::anim::FLY_IDLE; else if (isSwimmingNow) targetAnim = rendering::anim::SWIM_IDLE; else targetAnim = rendering::anim::STAND; } charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); } } } float renderYaw = entity->getOrientation() + glm::radians(90.0f); charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); } } { float csMs = std::chrono::duration( std::chrono::steady_clock::now() - creatureSyncStart).count(); if (csMs > 5.0f) { LOG_WARNING("SLOW update stage 'creature render sync': ", csMs, "ms (", entitySpawner_->getCreatureInstances().size(), " creatures)"); } } // --- Online player render sync (position, orientation, animation) --- // Mirrors the creature sync loop above but without collision guard or // weapon-attach logic. Without this, online players never transition // back to Stand after movement stops ("run in place" bug). auto playerSyncStart = std::chrono::steady_clock::now(); if (renderer && gameHandler && renderer->getCharacterRenderer()) { auto* charRenderer = renderer->getCharacterRenderer(); glm::vec3 pPos(0.0f); bool havePPos = false; if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) { pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ()); havePPos = true; } const float pSyncRadiusSq = 320.0f * 320.0f; auto& _playerInstances = entitySpawner_->getPlayerInstances(); auto& _pCreatureWasMoving = entitySpawner_->getCreatureWasMoving(); auto& _pCreatureWasSwimming = entitySpawner_->getCreatureWasSwimming(); auto& _pCreatureWasFlying = entitySpawner_->getCreatureWasFlying(); auto& _pCreatureWasWalking = entitySpawner_->getCreatureWasWalking(); auto& _pCreatureSwimmingState = entitySpawner_->getCreatureSwimmingState(); auto& _pCreatureWalkingState = entitySpawner_->getCreatureWalkingState(); auto& _pCreatureFlyingState = entitySpawner_->getCreatureFlyingState(); auto& _pCreatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache(); for (const auto& [guid, instanceId] : _playerInstances) { auto entity = gameHandler->getEntityManager().getEntity(guid); if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; // Distance cull if (havePPos) { glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); glm::vec3 d = latestCanonical - pPos; if (glm::dot(d, d) > pSyncRadiusSq) continue; } // Position sync — clamp to destination during dead-reckoning // overrun to avoid drift + backward snap (same as creature loop). const bool inOverrun = entity->isEntityMoving() && !entity->isActivelyMoving(); glm::vec3 canonical( inOverrun ? entity->getLatestX() : entity->getX(), inOverrun ? entity->getLatestY() : entity->getY(), inOverrun ? entity->getLatestZ() : entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // Clamp other players' Z to terrain surface during movement if (entity->isActivelyMoving() && renderer->getTerrainManager()) { auto terrainZ = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y); if (terrainZ.has_value()) { renderPos.z = terrainZ.value(); } } auto posIt = _pCreatureRenderPosCache.find(guid); if (posIt == _pCreatureRenderPosCache.end()) { charRenderer->setInstancePosition(instanceId, renderPos); _pCreatureRenderPosCache[guid] = renderPos; } else { const glm::vec3 prevPos = posIt->second; float ddx2 = renderPos.x - prevPos.x; float ddy2 = renderPos.y - prevPos.y; float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2; float dz = std::abs(renderPos.z - prevPos.z); auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f); const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq2 = 0.03f * 0.03f; const bool posChanging2 = planarDistSq > kMoveThreshSq2 || dz > 0.08f; const bool isMovingNow = !deadOrCorpse && (entityIsMoving || (posChanging2 && !entity->isEntityMoving())); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); } else if (planarDistSq > kMoveThreshSq2 || dz > 0.08f) { float planarDist = std::sqrt(planarDistSq); float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } posIt->second = renderPos; // Drive movement animation (same logic as creatures) const bool isSwimmingNow = _pCreatureSwimmingState.count(guid) > 0; const bool isWalkingNow = _pCreatureWalkingState.count(guid) > 0; const bool isFlyingNow = _pCreatureFlyingState.count(guid) > 0; bool prevMoving = _pCreatureWasMoving[guid]; bool prevSwimming = _pCreatureWasSwimming[guid]; bool prevFlying = _pCreatureWasFlying[guid]; bool prevWalking = _pCreatureWasWalking[guid]; const bool stateChanged = (isMovingNow != prevMoving) || (isSwimmingNow != prevSwimming) || (isFlyingNow != prevFlying) || (isWalkingNow != prevWalking && isMovingNow); if (stateChanged) { _pCreatureWasMoving[guid] = isMovingNow; _pCreatureWasSwimming[guid] = isSwimmingNow; _pCreatureWasFlying[guid] = isFlyingNow; _pCreatureWasWalking[guid] = isWalkingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != rendering::anim::DEATH) { uint32_t targetAnim; if (isMovingNow) { if (isFlyingNow) targetAnim = rendering::anim::FLY_FORWARD; else if (isSwimmingNow) targetAnim = rendering::anim::SWIM; else if (isWalkingNow) targetAnim = rendering::anim::WALK; else targetAnim = rendering::anim::RUN; } else { if (isFlyingNow) targetAnim = rendering::anim::FLY_IDLE; else if (isSwimmingNow) targetAnim = rendering::anim::SWIM_IDLE; else targetAnim = rendering::anim::STAND; } charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); } } } // Orientation sync float renderYaw = entity->getOrientation() + glm::radians(90.0f); charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); } } { float psMs = std::chrono::duration( std::chrono::steady_clock::now() - playerSyncStart).count(); if (psMs > 5.0f) { LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (", entitySpawner_->getPlayerInstances().size(), " players)"); } } // Movement heartbeat is sent from GameHandler::update() to avoid // duplicate packets from multiple update loops. } catch (const std::bad_alloc& e) { LOG_ERROR("OOM inside AppState::IN_GAME at step '", inGameStep, "': ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception inside AppState::IN_GAME at step '", inGameStep, "': ", e.what()); throw; } break; } case AppState::DISCONNECTED: // Handle disconnection break; } // Process any pending world entry request via WorldLoader if (worldLoader_ && state != AppState::DISCONNECTED) { worldLoader_->processPendingEntry(); } // Update renderer (camera, etc.) only when in-game updateCheckpoint = "renderer update"; if (renderer && state == AppState::IN_GAME) { auto rendererUpdateStart = std::chrono::steady_clock::now(); try { renderer->update(deltaTime); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during Application::update stage 'renderer->update': ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception during Application::update stage 'renderer->update': ", e.what()); throw; } float ruMs = std::chrono::duration( std::chrono::steady_clock::now() - rendererUpdateStart).count(); if (ruMs > 50.0f) { LOG_WARNING("SLOW update stage 'renderer->update': ", ruMs, "ms"); } } // Update UI updateCheckpoint = "ui update"; if (uiManager) { try { uiManager->update(deltaTime); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during Application::update stage 'uiManager->update': ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception during Application::update stage 'uiManager->update': ", e.what()); throw; } } } catch (const std::bad_alloc& e) { LOG_ERROR("OOM in Application::update checkpoint '", updateCheckpoint, "': ", e.what()); throw; } catch (const std::exception& e) { LOG_ERROR("Exception in Application::update checkpoint '", updateCheckpoint, "': ", e.what()); throw; } } void Application::render() { if (!renderer) { return; } renderer->beginFrame(); // Only render 3D world when in-game if (state == AppState::IN_GAME) { if (world) { renderer->renderWorld(world.get(), gameHandler.get()); } else { renderer->renderWorld(nullptr, gameHandler.get()); } } // Render performance HUD (within ImGui frame, before UI ends the frame) if (renderer) { renderer->renderHUD(); } // Render UI on top (ends ImGui frame with ImGui::Render()) if (uiManager) { uiManager->render(state, authHandler.get(), gameHandler.get()); } renderer->endFrame(); } void Application::setupUICallbacks() { // ── UI screen callbacks (auth, realm, character selection/creation) ── uiScreenCallbacks_ = std::make_unique( *uiManager, *gameHandler, *authHandler, expansionRegistry_.get(), assetManager.get(), [this](AppState s) { setState(s); }); uiScreenCallbacks_->setupCallbacks(); // ── World entry, unstuck, hearthstone, bind point ── worldEntryCallbacks_ = std::make_unique( *renderer, *gameHandler, worldLoader_.get(), entitySpawner_.get(), audioCoordinator_.get(), assetManager.get()); worldEntryCallbacks_->setupCallbacks(); // ── Entity spawn/despawn (creatures, players, game objects) ── entitySpawnCallbacks_ = std::make_unique( *entitySpawner_, *renderer, *gameHandler, [this](uint64_t guid) { uint64_t localGuid = gameHandler ? gameHandler->getPlayerGuid() : 0; uint64_t activeGuid = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; return (localGuid != 0 && guid == localGuid) || (activeGuid != 0 && guid == activeGuid) || (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_); }); entitySpawnCallbacks_->setupCallbacks(); // ── Animation: death, respawn, swing, hit, spell, emote, charge, etc. ── animationCallbacks_ = std::make_unique( *entitySpawner_, *renderer, *gameHandler); animationCallbacks_->setupCallbacks(); // ── NPC interaction: greeting, farewell, vendor, aggro voice ── npcInteractionCallbacks_ = std::make_unique( *entitySpawner_, renderer.get(), *gameHandler, audioCoordinator_.get()); npcInteractionCallbacks_->setupCallbacks(); // ── Audio: music, sound effects, level-up, achievement, LFG ── audioCallbacks_ = std::make_unique( *assetManager, audioCoordinator_.get(), renderer.get(), uiManager.get(), *gameHandler); audioCallbacks_->setupCallbacks(); // ── Transport: mount, taxi, transport spawn/move ── transportCallbacks_ = std::make_unique( *entitySpawner_, *renderer, *gameHandler, worldLoader_.get()); transportCallbacks_->setupCallbacks(); } void Application::spawnPlayerCharacter() { if (playerCharacterSpawned) return; if (!renderer || !renderer->getCharacterRenderer() || !renderer->getCamera()) return; auto* charRenderer = renderer->getCharacterRenderer(); auto* camera = renderer->getCamera(); bool loaded = false; std::string m2Path = appearanceComposer_->getPlayerModelPath(playerRace_, playerGender_); std::string modelDir; std::string baseName; { size_t slash = m2Path.rfind('\\'); if (slash != std::string::npos) { modelDir = m2Path.substr(0, slash + 1); baseName = m2Path.substr(slash + 1); } else { baseName = m2Path; } size_t dot = baseName.rfind('.'); if (dot != std::string::npos) { baseName = baseName.substr(0, dot); } } // Try loading selected character model from MPQ if (assetManager && assetManager->isInitialized()) { auto m2Data = assetManager->readFile(m2Path); if (!m2Data.empty()) { auto model = pipeline::M2Loader::load(m2Data); // Load skin file for submesh/batch data std::string skinPath = modelDir + baseName + "00.skin"; auto skinData = assetManager->readFile(skinPath); if (!skinData.empty() && model.version >= 264) { pipeline::M2Loader::loadSkin(skinData, model); } if (model.isValid()) { // Log texture slots for (size_t ti = 0; ti < model.textures.size(); ti++) { auto& tex = model.textures[ti]; LOG_INFO(" Texture ", ti, ": type=", tex.type, " name='", tex.filename, "'"); } // Resolve textures from CharSections.dbc via AppearanceComposer PlayerTextureInfo texInfo; bool useCharSections = true; if (appearanceComposer_) { uint32_t appearanceBytes = 0; if (gameHandler) { const game::Character* activeChar = gameHandler->getActiveCharacter(); if (activeChar) { appearanceBytes = activeChar->appearanceBytes; } } texInfo = appearanceComposer_->resolvePlayerTextures(model, playerRace_, playerGender_, appearanceBytes); } // Load external .anim files for sequences with external data. // Sequences WITH flag 0x20 have their animation data inline in the M2 file. // Sequences WITHOUT flag 0x20 store data in external .anim files. for (uint32_t si = 0; si < model.sequences.size(); si++) { if (!(model.sequences[si].flags & 0x20)) { // File naming: -.anim // e.g. Character\Human\Male\HumanMale0097-00.anim char animFileName[256]; snprintf(animFileName, sizeof(animFileName), "%s%s%04u-%02u.anim", modelDir.c_str(), baseName.c_str(), model.sequences[si].id, model.sequences[si].variationIndex); auto animFileData = assetManager->readFileOptional(animFileName); if (!animFileData.empty()) { pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model); } } } charRenderer->loadModel(model, 1); // Apply composited textures via AppearanceComposer (saves skin state for re-compositing) if (useCharSections && appearanceComposer_) { appearanceComposer_->compositePlayerSkin(1, texInfo); } loaded = true; LOG_INFO("Loaded character model: ", m2Path, " (", model.vertices.size(), " verts, ", model.bones.size(), " bones, ", model.sequences.size(), " anims, ", model.indices.size(), " indices, ", model.batches.size(), " batches"); // Log all animation sequence IDs for (size_t i = 0; i < model.sequences.size(); i++) { } } } } // Fallback: create a simple cube if MPQ not available if (!loaded) { pipeline::M2Model testModel; float size = 2.0f; glm::vec3 cubePos[] = { {-size, -size, -size}, { size, -size, -size}, { size, size, -size}, {-size, size, -size}, {-size, -size, size}, { size, -size, size}, { size, size, size}, {-size, size, size} }; for (const auto& pos : cubePos) { pipeline::M2Vertex v; v.position = pos; v.normal = glm::normalize(pos); v.texCoords[0] = glm::vec2(0.0f); v.boneWeights[0] = 255; v.boneWeights[1] = v.boneWeights[2] = v.boneWeights[3] = 0; v.boneIndices[0] = 0; v.boneIndices[1] = v.boneIndices[2] = v.boneIndices[3] = 0; testModel.vertices.push_back(v); } uint16_t cubeIndices[] = { 0,1,2, 0,2,3, 4,6,5, 4,7,6, 0,4,5, 0,5,1, 2,6,7, 2,7,3, 0,3,7, 0,7,4, 1,5,6, 1,6,2 }; for (uint16_t idx : cubeIndices) testModel.indices.push_back(idx); pipeline::M2Bone bone; bone.keyBoneId = -1; bone.flags = 0; bone.parentBone = -1; bone.submeshId = 0; bone.pivot = glm::vec3(0.0f); testModel.bones.push_back(bone); pipeline::M2Sequence seq{}; seq.id = 0; seq.duration = 1000; testModel.sequences.push_back(seq); testModel.name = "TestCube"; testModel.globalFlags = 0; charRenderer->loadModel(testModel, 1); LOG_INFO("Loaded fallback cube model (no MPQ data)"); } // Spawn character at the camera controller's default position (matches hearthstone). // Most presets snap to floor; explicit WMO-floor presets keep their authored Z. auto* camCtrl = renderer->getCameraController(); glm::vec3 spawnPos = camCtrl ? camCtrl->getDefaultPosition() : (camera->getPosition() - glm::vec3(0.0f, 0.0f, 5.0f)); if (spawnSnapToGround && renderer->getTerrainManager()) { auto terrainH = renderer->getTerrainManager()->getHeightAt(spawnPos.x, spawnPos.y); if (terrainH) { spawnPos.z = *terrainH + 0.1f; } } uint32_t instanceId = charRenderer->createInstance(1, spawnPos, glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size if (instanceId > 0) { // Set up third-person follow renderer->getCharacterPosition() = spawnPos; renderer->setCharacterFollow(instanceId); // Build default geosets for the active character via AppearanceComposer uint8_t hairStyleId = 0; uint8_t facialId = 0; uint8_t raceId = 0; uint8_t sexId = 0; if (gameHandler) { if (const game::Character* ch = gameHandler->getActiveCharacter()) { hairStyleId = static_cast((ch->appearanceBytes >> 16) & 0xFF); facialId = ch->facialFeatures; raceId = static_cast(ch->race); sexId = static_cast(ch->gender); } } auto activeGeosets = appearanceComposer_ ? appearanceComposer_->buildDefaultPlayerGeosets(raceId, sexId, hairStyleId, facialId) : std::unordered_set{}; charRenderer->setActiveGeosets(instanceId, activeGeosets); // Play idle animation charRenderer->playAnimation(instanceId, rendering::anim::STAND, true); LOG_INFO("Spawned player character at (", static_cast(spawnPos.x), ", ", static_cast(spawnPos.y), ", ", static_cast(spawnPos.z), ")"); playerCharacterSpawned = true; // Set voice profile to match character race/gender if (auto* asm_ = audioCoordinator_ ? audioCoordinator_->getActivitySoundManager() : nullptr) { const char* raceFolder = "Human"; const char* raceBase = "Human"; switch (playerRace_) { case game::Race::HUMAN: raceFolder = "Human"; raceBase = "Human"; break; case game::Race::ORC: raceFolder = "Orc"; raceBase = "Orc"; break; case game::Race::DWARF: raceFolder = "Dwarf"; raceBase = "Dwarf"; break; case game::Race::NIGHT_ELF: raceFolder = "NightElf"; raceBase = "NightElf"; break; case game::Race::UNDEAD: raceFolder = "Scourge"; raceBase = "Scourge"; break; case game::Race::TAUREN: raceFolder = "Tauren"; raceBase = "Tauren"; break; case game::Race::GNOME: raceFolder = "Gnome"; raceBase = "Gnome"; break; case game::Race::TROLL: raceFolder = "Troll"; raceBase = "Troll"; break; case game::Race::BLOOD_ELF: raceFolder = "BloodElf"; raceBase = "BloodElf"; break; case game::Race::DRAENEI: raceFolder = "Draenei"; raceBase = "Draenei"; break; default: break; } bool useFemaleVoice = (playerGender_ == game::Gender::FEMALE); if (playerGender_ == game::Gender::NONBINARY && gameHandler) { if (const game::Character* ch = gameHandler->getActiveCharacter()) { useFemaleVoice = ch->useFemaleModel; } } asm_->setCharacterVoiceProfile(std::string(raceFolder), std::string(raceBase), !useFemaleVoice); } // Track which character's appearance this instance represents so we can // respawn if the user logs into a different character without restarting. spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; spawnedAppearanceBytes_ = 0; spawnedFacialFeatures_ = 0; if (gameHandler) { if (const game::Character* ch = gameHandler->getActiveCharacter()) { spawnedAppearanceBytes_ = ch->appearanceBytes; spawnedFacialFeatures_ = ch->facialFeatures; } } // Set up camera controller for first-person player hiding if (renderer->getCameraController()) { renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId); } // Load equipped weapons (sword + shield) if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); } } void Application::buildFactionHostilityMap(uint8_t playerRace) { if (!assetManager || !assetManager->isInitialized() || !gameHandler) return; auto ftDbc = assetManager->loadDBC("FactionTemplate.dbc"); auto fDbc = assetManager->loadDBC("Faction.dbc"); if (!ftDbc || !ftDbc->isLoaded()) return; // Race enum → race mask bit: race 1=0x1, 2=0x2, 3=0x4, 4=0x8, 5=0x10, 6=0x20, 7=0x40, 8=0x80, 10=0x200, 11=0x400 uint32_t playerRaceMask = 0; if (playerRace >= 1 && playerRace <= 8) { playerRaceMask = 1u << (playerRace - 1); } else if (playerRace == 10) { playerRaceMask = 0x200; // Blood Elf } else if (playerRace == 11) { playerRaceMask = 0x400; // Draenei } // Race → player faction template ID // Human=1, Orc=2, Dwarf=3, NightElf=4, Undead=5, Tauren=6, Gnome=115, Troll=116, BloodElf=1610, Draenei=1629 uint32_t playerFtId = 0; switch (playerRace) { case 1: playerFtId = 1; break; // Human case 2: playerFtId = 2; break; // Orc case 3: playerFtId = 3; break; // Dwarf case 4: playerFtId = 4; break; // Night Elf case 5: playerFtId = 5; break; // Undead case 6: playerFtId = 6; break; // Tauren case 7: playerFtId = 115; break; // Gnome case 8: playerFtId = 116; break; // Troll case 10: playerFtId = 1610; break; // Blood Elf case 11: playerFtId = 1629; break; // Draenei default: playerFtId = 1; break; } // Build set of hostile parent faction IDs from Faction.dbc base reputation const auto* facL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; const auto* ftL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("FactionTemplate") : nullptr; std::unordered_set hostileParentFactions; if (fDbc && fDbc->isLoaded()) { const uint32_t facID = facL ? (*facL)["ID"] : 0; const uint32_t facRaceMask0 = facL ? (*facL)["ReputationRaceMask0"] : 2; const uint32_t facBase0 = facL ? (*facL)["ReputationBase0"] : 10; for (uint32_t i = 0; i < fDbc->getRecordCount(); i++) { uint32_t factionId = fDbc->getUInt32(i, facID); for (int slot = 0; slot < 4; slot++) { uint32_t raceMask = fDbc->getUInt32(i, facRaceMask0 + slot); if (raceMask & playerRaceMask) { int32_t baseRep = fDbc->getInt32(i, facBase0 + slot); if (baseRep < 0) { hostileParentFactions.insert(factionId); } break; } } } LOG_INFO("Faction.dbc: ", hostileParentFactions.size(), " factions hostile to race ", static_cast(playerRace)); } // Get player faction template data const uint32_t ftID = ftL ? (*ftL)["ID"] : 0; const uint32_t ftFaction = ftL ? (*ftL)["Faction"] : 1; const uint32_t ftFG = ftL ? (*ftL)["FactionGroup"] : 3; const uint32_t ftFriend = ftL ? (*ftL)["FriendGroup"] : 4; const uint32_t ftEnemy = ftL ? (*ftL)["EnemyGroup"] : 5; const uint32_t ftEnemy0 = ftL ? (*ftL)["Enemy0"] : 6; uint32_t playerFriendGroup = 0; uint32_t playerEnemyGroup = 0; uint32_t playerFactionId = 0; for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) { if (ftDbc->getUInt32(i, ftID) == playerFtId) { playerFriendGroup = ftDbc->getUInt32(i, ftFriend) | ftDbc->getUInt32(i, ftFG); playerEnemyGroup = ftDbc->getUInt32(i, ftEnemy); playerFactionId = ftDbc->getUInt32(i, ftFaction); break; } } // Build hostility map for each faction template std::unordered_map factionMap; for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) { uint32_t id = ftDbc->getUInt32(i, ftID); uint32_t parentFaction = ftDbc->getUInt32(i, ftFaction); uint32_t factionGroup = ftDbc->getUInt32(i, ftFG); uint32_t friendGroup = ftDbc->getUInt32(i, ftFriend); uint32_t enemyGroup = ftDbc->getUInt32(i, ftEnemy); // 1. Symmetric group check bool hostile = (enemyGroup & playerFriendGroup) != 0 || (factionGroup & playerEnemyGroup) != 0; // 2. Monster factionGroup bit (8) if (!hostile && (factionGroup & 8) != 0) { hostile = true; } // 3. Individual enemy faction IDs if (!hostile && playerFactionId > 0) { for (uint32_t e = ftEnemy0; e <= ftEnemy0 + 3; e++) { if (ftDbc->getUInt32(i, e) == playerFactionId) { hostile = true; break; } } } // 4. Parent faction base reputation check (Faction.dbc) if (!hostile && parentFaction > 0) { if (hostileParentFactions.count(parentFaction)) { hostile = true; } } // 5. If explicitly friendly (friendGroup includes player), override to non-hostile if (hostile && (friendGroup & playerFriendGroup) != 0) { hostile = false; } factionMap[id] = hostile; } uint32_t hostileCount = 0; for (const auto& [fid, h] : factionMap) { if (h) hostileCount++; } gameHandler->setFactionHostileMap(std::move(factionMap)); LOG_INFO("Faction hostility for race ", static_cast(playerRace), " (FT ", playerFtId, "): ", hostileCount, "/", ftDbc->getRecordCount(), " hostile (friendGroup=0x", std::hex, playerFriendGroup, ", enemyGroup=0x", playerEnemyGroup, std::dec, ")"); } // Render bounds/position queries — delegates to EntitySpawner bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const { if (entitySpawner_) return entitySpawner_->getRenderBoundsForGuid(guid, outCenter, outRadius); return false; } bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const { if (entitySpawner_) return entitySpawner_->getRenderFootZForGuid(guid, outFootZ); return false; } bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const { if (entitySpawner_) return entitySpawner_->getRenderPositionForGuid(guid, outPos); return false; } void Application::loadQuestMarkerModels() { if (!assetManager || !renderer) return; // Quest markers are billboard sprites; the renderer's QuestMarkerRenderer handles // texture loading and pipeline setup during world initialization. // Calling initialize() here is a no-op if already done; harmless if called early. if (auto* qmr = renderer->getQuestMarkerRenderer()) { if (auto* vkCtx = renderer->getVkContext()) { VkDescriptorSetLayout pfl = renderer->getPerFrameSetLayout(); if (pfl != VK_NULL_HANDLE) { if (!qmr->initialize(vkCtx, pfl, assetManager.get())) LOG_WARNING("Quest marker renderer re-init failed (non-fatal)"); } } } } void Application::updateQuestMarkers() { if (!gameHandler || !renderer) { return; } auto* questMarkerRenderer = renderer->getQuestMarkerRenderer(); if (!questMarkerRenderer) { static bool logged = false; if (!logged) { LOG_WARNING("QuestMarkerRenderer not available!"); logged = true; } return; } const auto& questStatuses = gameHandler->getNpcQuestStatuses(); // Clear all markers (we'll re-add active ones) questMarkerRenderer->clear(); static bool firstRun = true; int markersAdded = 0; // Add markers for NPCs with quest status for (const auto& [guid, status] : questStatuses) { // Determine marker type int markerType = -1; // -1 = no marker using game::QuestGiverStatus; float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests) switch (status) { case QuestGiverStatus::AVAILABLE: markerType = 0; // Yellow ! break; case QuestGiverStatus::AVAILABLE_LOW: markerType = 0; // Grey ! (same texture, desaturated in shader) markerGrayscale = 1.0f; break; case QuestGiverStatus::REWARD: case QuestGiverStatus::REWARD_REP: markerType = 1; // Yellow ? break; case QuestGiverStatus::INCOMPLETE: markerType = 2; // Grey ? break; default: break; } if (markerType < 0) continue; // Get NPC entity position auto entity = gameHandler->getEntityManager().getEntity(guid); if (!entity) continue; if (entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); std::string name = unit->getName(); std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c){ return static_cast(std::tolower(c)); }); if (name.find("spirit healer") != std::string::npos || name.find("spirit guide") != std::string::npos) { continue; // Spirit healers/guides use their own white visual cue. } } glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = coords::canonicalToRender(canonical); // Get NPC bounding height for proper marker positioning glm::vec3 boundsCenter; float boundsRadius = 0.0f; float boundingHeight = 2.0f; // Default if (getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) { boundingHeight = boundsRadius * 2.0f; } // Set the marker (renderer will handle positioning, bob, glow, etc.) questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale); markersAdded++; } if (firstRun && markersAdded > 0) { LOG_DEBUG("Quest markers: Added ", markersAdded, " markers on first run"); firstRun = false; } } void Application::setupTestTransport() { if (!entitySpawner_) return; if (entitySpawner_->isTestTransportSetup()) return; if (!gameHandler || !renderer || !assetManager) return; auto* transportManager = gameHandler->getTransportManager(); auto* wmoRenderer = renderer->getWMORenderer(); if (!transportManager || !wmoRenderer) return; LOG_INFO("========================================"); LOG_INFO(" SETTING UP TEST TRANSPORT"); LOG_INFO("========================================"); // Connect transport manager to WMO renderer transportManager->setWMORenderer(wmoRenderer); // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) if (renderer->getM2Renderer()) { wmoRenderer->setM2Renderer(renderer->getM2Renderer()); LOG_INFO("WMORenderer connected to M2Renderer for test transport doodad transforms"); } // Define a simple circular path around Stormwind harbor (canonical coordinates) // These coordinates are approximate - adjust based on actual harbor layout std::vector harborPath = { {-8833.0f, 628.0f, 94.0f}, // Start point (Stormwind harbor) {-8900.0f, 650.0f, 94.0f}, // Move west {-8950.0f, 700.0f, 94.0f}, // Northwest {-8950.0f, 780.0f, 94.0f}, // North {-8900.0f, 830.0f, 94.0f}, // Northeast {-8833.0f, 850.0f, 94.0f}, // East {-8766.0f, 830.0f, 94.0f}, // Southeast {-8716.0f, 780.0f, 94.0f}, // South {-8716.0f, 700.0f, 94.0f}, // Southwest {-8766.0f, 650.0f, 94.0f}, // Back to start direction }; // Register the path with transport manager uint32_t pathId = 1; float speed = 12.0f; // 12 units/sec (slower than taxi for a leisurely boat ride) transportManager->loadPathFromNodes(pathId, harborPath, true, speed); LOG_INFO("Registered transport path ", pathId, " with ", harborPath.size(), " waypoints, speed=", speed); // Try transport WMOs in manifest-backed paths first. std::vector transportCandidates = { "World\\wmo\\transports\\transport_ship\\transportship.wmo", "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo", "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo", "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo", // Legacy fallbacks "Transports\\Transportship\\Transportship.wmo", "Transports\\Boat\\Boat.wmo", }; std::string transportWmoPath; std::vector wmoData; for (const auto& candidate : transportCandidates) { wmoData = assetManager->readFile(candidate); if (!wmoData.empty()) { transportWmoPath = candidate; break; } } if (wmoData.empty()) { LOG_WARNING("No transport WMO found - test transport disabled"); LOG_INFO("Expected under World\\wmo\\transports\\..."); return; } LOG_INFO("Using transport WMO: ", transportWmoPath); // Load WMO model pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); LOG_INFO("Transport WMO root loaded: ", transportWmoPath, " nGroups=", wmoModel.nGroups); // Load WMO groups int loadedGroups = 0; if (wmoModel.nGroups > 0) { std::string basePath = transportWmoPath.substr(0, transportWmoPath.size() - 4); for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { char groupSuffix[16]; snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); std::string groupPath = basePath + groupSuffix; std::vector groupData = assetManager->readFile(groupPath); if (!groupData.empty()) { pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); loadedGroups++; } else { LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); } } } if (loadedGroups == 0 && wmoModel.nGroups > 0) { LOG_WARNING("Failed to load any WMO groups for transport"); return; } // Load WMO into renderer uint32_t wmoModelId = 99999; // Use high ID to avoid conflicts if (!wmoRenderer->loadModel(wmoModel, wmoModelId)) { LOG_WARNING("Failed to load transport WMO model into renderer"); return; } // Create WMO instance at first waypoint (convert canonical to render coords) glm::vec3 startCanonical = harborPath[0]; glm::vec3 startRender = core::coords::canonicalToRender(startCanonical); uint32_t wmoInstanceId = wmoRenderer->createInstance(wmoModelId, startRender, glm::vec3(0.0f, 0.0f, 0.0f), 1.0f); if (wmoInstanceId == 0) { LOG_WARNING("Failed to create transport WMO instance"); return; } // Register transport with transport manager uint64_t transportGuid = 0x1000000000000001ULL; // Fake GUID for test transportManager->registerTransport(transportGuid, wmoInstanceId, pathId, startCanonical); // Optional: Set deck bounds (rough estimate for a ship deck) transportManager->setDeckBounds(transportGuid, glm::vec3(-15.0f, -30.0f, 0.0f), glm::vec3(15.0f, 30.0f, 10.0f)); entitySpawner_->setTestTransportSetup(true); LOG_INFO("========================================"); LOG_INFO("Test transport registered:"); LOG_INFO(" GUID: 0x", std::hex, transportGuid, std::dec); LOG_INFO(" WMO Instance: ", wmoInstanceId); LOG_INFO(" Path: ", pathId, " (", harborPath.size(), " waypoints)"); LOG_INFO(" Speed: ", speed, " units/sec"); LOG_INFO("========================================"); LOG_INFO(""); LOG_INFO("To board the transport, use console command:"); LOG_INFO(" /transport board"); LOG_INFO("To disembark:"); LOG_INFO(" /transport leave"); LOG_INFO("========================================"); } } // namespace core } // namespace wowee