#include "core/application.hpp" #include "core/coordinates.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(); audioCoordinator_->initialize(); 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_.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(); if (addonManager_->initialize(gameHandler.get())) { 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() { 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); // Vulkan viewport set in command buffer, not globally if (renderer && renderer->getCamera()) { 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 { 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_WARNING("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_WARNING("Saving WMO floor cache (", cacheSize, " entries)..."); renderer->getWMORenderer()->saveFloorCache(); LOG_WARNING("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_WARNING("Shutting down renderer..."); if (renderer) { renderer->shutdown(); } LOG_WARNING("Renderer shutdown complete, resetting..."); renderer.reset(); // Shutdown audio coordinator after renderer (renderer may reference audio during shutdown) if (audioCoordinator_) { audioCoordinator_->shutdown(); } audioCoordinator_.reset(); LOG_WARNING("Resetting world..."); world.reset(); LOG_WARNING("Resetting gameHandler..."); gameHandler.reset(); gameServices_ = {}; LOG_WARNING("Resetting authHandler..."); authHandler.reset(); LOG_WARNING("Resetting assetManager..."); assetManager.reset(); LOG_WARNING("Resetting uiManager..."); uiManager.reset(); LOG_WARNING("Resetting window..."); window.reset(); running = false; LOG_WARNING("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); renderer->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(0); // CMSG_STAND_STATE_CHANGE(STAND) } }); cc->setAutoFollowCancelCallback([this]() { if (gameHandler) { gameHandler->cancelFollow(); } }); cc->setUseWoWSpeed(true); } if (gameHandler) { gameHandler->setMeleeSwingCallback([this]() { if (renderer) { renderer->triggerMeleeSwing(); } }); 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(); lastTaxiFlight_ = false; taxiLandingClampTimer_ = 0.0f; worldEntryMovementGraceTimer_ = 0.0f; facingSendCooldown_ = 0.0f; lastSentCanonicalYaw_ = 1000.0f; taxiStreamCooldown_ = 0.0f; idleYawned_ = false; // --- Charge state --- chargeActive_ = false; chargeTimer_ = 0.0f; chargeDuration_ = 0.0f; chargeTargetGuid_ = 0; // --- 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(); } renderer->clearMount(); renderer->setCharacterFollow(0); if (auto* music = renderer->getMusicManager()) { 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) { 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(); } 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()) { renderer->setMountPitchRoll(pitchRad, 0.0f); } } } } else if (gameHandler->isMounted()) { // Reset mount pitch when not flying renderer->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 (worldEntryMovementGraceTimer_ > 0.0f) { worldEntryMovementGraceTimer_ -= 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: keep player frozen until terrain loads at destination if (hearthTeleportPending_ && renderer && renderer->getTerrainManager()) { hearthTeleportTimer_ -= deltaTime; auto terrainH = renderer->getTerrainManager()->getHeightAt( hearthTeleportPos_.x, hearthTeleportPos_.y); if (terrainH || hearthTeleportTimer_ <= 0.0f) { // Terrain loaded (or timeout) — snap to floor and release if (terrainH) { hearthTeleportPos_.z = *terrainH + 0.5f; renderer->getCameraController()->teleportTo(hearthTeleportPos_); } renderer->getCameraController()->setExternalFollow(false); worldEntryMovementGraceTimer_ = 1.0f; hearthTeleportPending_ = false; LOG_INFO("Unstuck hearth: terrain loaded, player released", terrainH ? "" : " (timeout)"); } } if (renderer && renderer->getCameraController()) { const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. bool hearthFreeze = hearthTeleportPending_; bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && worldEntryMovementGraceTimer_ <= 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(); taxiLandingClampTimer_ = 0.0f; } if (lastTaxiFlight_ && !onTaxi) { renderer->getCameraController()->clearMovementInputs(); // Keep clamping until terrain loads at landing position. // Timer only counts down once a valid floor is found. taxiLandingClampTimer_ = 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); } taxiLandingClampTimer_ -= deltaTime; } // No floor found: don't decrement timer, keep player frozen until terrain loads } } bool idleOrbit = renderer->getCameraController()->isIdleOrbit(); if (idleOrbit && !idleYawned_ && renderer) { renderer->playEmote("yawn"); idleYawned_ = true; } else if (!idleOrbit) { idleYawned_ = false; } } if (renderer) { renderer->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); } lastTaxiFlight_ = 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 (chargeActive_) { // Warrior Charge: lerp position from start to end using smoothstep chargeTimer_ += deltaTime; float t = std::min(chargeTimer_ / chargeDuration_, 1.0f); // smoothstep for natural acceleration/deceleration float s = t * t * (3.0f - 2.0f * t); glm::vec3 renderPos = chargeStartPos_ + (chargeEndPos_ - chargeStartPos_) * s; renderer->getCharacterPosition() = renderPos; // Keep facing toward target and emit charge effect glm::vec3 dir = chargeEndPos_ - chargeStartPos_; float dirLenSq = glm::dot(dir, dir); if (dirLenSq > 1e-4f) { dir *= glm::inversesqrt(dirLenSq); float yawDeg = glm::degrees(std::atan2(dir.x, dir.y)); renderer->setCharacterYaw(yawDeg); renderer->emitChargeEffect(renderPos, dir); } // Sync to game handler glm::vec3 canonical = core::coords::renderToCanonical(renderPos); gameHandler->setPosition(canonical.x, canonical.y, canonical.z); // Update camera follow target if (renderer->getCameraController()) { glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); if (followTarget) { *followTarget = renderPos; } } // Charge complete if (t >= 1.0f) { chargeActive_ = false; renderer->setCharging(false); renderer->stopChargeEffect(); renderer->getCameraController()->setExternalFollow(false); renderer->getCameraController()->setExternalMoving(false); // Snap to melee range of target's CURRENT position (it may have moved) if (chargeTargetGuid_ != 0) { auto targetEntity = gameHandler->getEntityManager().getEntity(chargeTargetGuid_); if (targetEntity) { glm::vec3 targetCanonical(targetEntity->getX(), targetEntity->getY(), targetEntity->getZ()); glm::vec3 targetRender = core::coords::canonicalToRender(targetCanonical); glm::vec3 toTarget = targetRender - renderPos; float dSq = glm::dot(toTarget, toTarget); if (dSq > 2.25f) { // Place us 1.5 units from target (well within 8-unit melee range) glm::vec3 snapPos = targetRender - toTarget * (1.5f * glm::inversesqrt(dSq)); renderer->getCharacterPosition() = snapPos; glm::vec3 snapCanonical = core::coords::renderToCanonical(snapPos); gameHandler->setPosition(snapCanonical.x, snapCanonical.y, snapCanonical.z); if (renderer->getCameraController()) { glm::vec3* ft = renderer->getCameraController()->getFollowTargetMutable(); if (ft) *ft = snapPos; } } } gameHandler->startAutoAttack(chargeTargetGuid_); renderer->triggerMeleeSwing(); } // Send movement heartbeat so server knows our new position gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); } } 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; } glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // 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); // isEntityMoving() reflects server-authoritative move state set by // startMoveTo() in handleMonsterMove, regardless of distance-cull. // This correctly detects movement for distant creatures (> 150u) // where updateMovement() is not called and getX/Y/Z() stays stale. // Use isActivelyMoving() (not isEntityMoving()) so the // Run/Walk animation stops when the creature reaches its // destination, rather than persisting through the dead- // reckoning overrun window. const bool entityIsMoving = entity->isActivelyMoving(); constexpr float kMoveThreshSq = 0.03f * 0.03f; const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq || dz > 0.08f); 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 != 1 /*Death*/) { uint32_t targetAnim; if (isMovingNow) { if (isFlyingNow) targetAnim = 159u; // FlyForward else if (isSwimmingNow) targetAnim = 42u; // Swim else if (isWalkingNow) targetAnim = 4u; // Walk else targetAnim = 5u; // Run } else { if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) else if (isSwimmingNow) targetAnim = 41u; // SwimIdle else targetAnim = 0u; // 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 glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); 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 isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq2 || dz > 0.08f); 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 != 1 /*Death*/) { uint32_t targetAnim; if (isMovingNow) { if (isFlyingNow) targetAnim = 159u; // FlyForward else if (isSwimmingNow) targetAnim = 42u; // Swim else if (isWalkingNow) targetAnim = 4u; // Walk else targetAnim = 5u; // Run } else { if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) else if (isSwimmingNow) targetAnim = 41u; // SwimIdle else targetAnim = 0u; // 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() { // Authentication screen callback uiManager->getAuthScreen().setOnSuccess([this]() { LOG_INFO("Authentication successful, transitioning to realm selection"); setState(AppState::REALM_SELECTION); }); // Realm selection callback uiManager->getRealmScreen().setOnRealmSelected([this](const std::string& realmName, const std::string& realmAddress) { LOG_INFO("Realm selected: ", realmName, " (", realmAddress, ")"); // Parse realm address (format: "hostname:port") std::string host = realmAddress; uint16_t port = 8085; // Default world server port size_t colonPos = realmAddress.find(':'); if (colonPos != std::string::npos) { host = realmAddress.substr(0, colonPos); try { port = static_cast(std::stoi(realmAddress.substr(colonPos + 1))); } catch (...) { LOG_WARNING("Invalid port in realm address: ", realmAddress); } } // Connect to world server const auto& sessionKey = authHandler->getSessionKey(); std::string accountName = authHandler->getUsername(); if (accountName.empty()) { LOG_WARNING("Auth username missing; falling back to TESTACCOUNT"); accountName = "TESTACCOUNT"; } uint32_t realmId = 0; uint16_t realmBuild = 0; { // WotLK AUTH_SESSION includes a RealmID field; some servers reject if it's wrong/zero. const auto& realms = authHandler->getRealms(); for (const auto& r : realms) { if (r.name == realmName && r.address == realmAddress) { realmId = r.id; realmBuild = r.build; break; } } LOG_INFO("Selected realmId=", realmId, " realmBuild=", realmBuild); } uint32_t clientBuild = 12340; // default WotLK if (expansionRegistry_) { auto* profile = expansionRegistry_->getActive(); if (profile) clientBuild = profile->worldBuild; } // Prefer realm-reported build when available (e.g. vanilla servers // that report build 5875 in the realm list) if (realmBuild != 0) { clientBuild = realmBuild; LOG_INFO("Using realm-reported build: ", clientBuild); } if (gameHandler->connect(host, port, sessionKey, accountName, clientBuild, realmId)) { LOG_INFO("Connected to world server, transitioning to character selection"); setState(AppState::CHARACTER_SELECTION); } else { LOG_ERROR("Failed to connect to world server"); } }); // Realm screen back button - return to login uiManager->getRealmScreen().setOnBack([this]() { if (authHandler) { authHandler->disconnect(); } uiManager->getRealmScreen().reset(); setState(AppState::AUTHENTICATION); }); // Character selection callback uiManager->getCharacterScreen().setOnCharacterSelected([this](uint64_t characterGuid) { LOG_INFO("Character selected: GUID=0x", std::hex, characterGuid, std::dec); // Always set the active character GUID if (gameHandler) { gameHandler->setActiveCharacterGuid(characterGuid); } // Keep CHARACTER_SELECTION active until world entry is fully loaded. // This avoids exposing pre-load hitching before the loading screen/intro. }); // Character create screen callbacks uiManager->getCharacterCreateScreen().setOnCreate([this](const game::CharCreateData& data) { pendingCreatedCharacterName_ = data.name; // Store name for auto-selection gameHandler->createCharacter(data); }); uiManager->getCharacterCreateScreen().setOnCancel([this]() { setState(AppState::CHARACTER_SELECTION); }); // Character create result callback gameHandler->setCharCreateCallback([this](bool success, const std::string& msg) { if (success) { // Auto-select the newly created character if (!pendingCreatedCharacterName_.empty()) { uiManager->getCharacterScreen().selectCharacterByName(pendingCreatedCharacterName_); pendingCreatedCharacterName_.clear(); } setState(AppState::CHARACTER_SELECTION); } else { uiManager->getCharacterCreateScreen().setStatus(msg, true); pendingCreatedCharacterName_.clear(); } }); // Character login failure callback gameHandler->setCharLoginFailCallback([this](const std::string& reason) { LOG_WARNING("Character login failed: ", reason); setState(AppState::CHARACTER_SELECTION); uiManager->getCharacterScreen().setStatus("Login failed: " + reason, true); }); // World entry callback (online mode) - load terrain when entering world gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) { LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" " initial=", isInitialEntry); if (renderer) { renderer->resetCombatVisualState(); } // Reconnect to the same map: terrain stays loaded but all online entities are stale. // Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world. uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF; if (entitySpawner_ && mapId == currentLoadedMap && renderer && renderer->getTerrainManager() && isInitialEntry) { LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); // Pending spawn queues and failure caches — clear so previously-failed GUIDs can retry. // Dead creature guids will be re-populated from fresh server state. entitySpawner_->clearAllQueues(); // Properly despawn all tracked instances from the renderer entitySpawner_->despawnAllCreatures(); entitySpawner_->despawnAllPlayers(); entitySpawner_->despawnAllGameObjects(); // Update player position and re-queue nearby tiles (same logic as teleport) glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; if (renderer->getCameraController()) { auto* ft = renderer->getCameraController()->getFollowTargetMutable(); if (ft) *ft = renderPos; renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(1.0f); renderer->getCameraController()->suspendGravityFor(10.0f); } worldEntryMovementGraceTimer_ = 2.0f; taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; renderer->getTerrainManager()->processReadyTiles(); { auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); std::vector> nearbyTiles; nearbyTiles.reserve(289); for (int dy = -8; dy <= 8; dy++) for (int dx = -8; dx <= 8; dx++) nearbyTiles.push_back({tileX + dx, tileY + dy}); renderer->getTerrainManager()->precacheTiles(nearbyTiles); } return; } // Same-map teleport (taxi landing, GM teleport, hearthstone on same continent): if (mapId == currentLoadedMap && renderer && renderer->getTerrainManager()) { // Check if teleport is far enough to need terrain loading (>500 render units) glm::vec3 oldPos = renderer->getCharacterPosition(); glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); float teleportDistSq = glm::dot(renderPos - oldPos, renderPos - oldPos); bool farTeleport = (teleportDistSq > 500.0f * 500.0f); if (farTeleport) { // Far same-map teleport (hearthstone, etc.): defer full world reload // to next frame to avoid blocking the packet handler for 20+ seconds. LOG_WARNING("Far same-map teleport (dist=", std::sqrt(teleportDistSq), "), deferring world reload to next frame"); // Update position immediately so the player doesn't keep moving at old location renderer->getCharacterPosition() = renderPos; if (renderer->getCameraController()) { auto* ft = renderer->getCameraController()->getFollowTargetMutable(); if (ft) *ft = renderPos; renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(1.0f); renderer->getCameraController()->suspendGravityFor(10.0f); } if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); return; } LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload"); // canonical and renderPos already computed above for distance check renderer->getCharacterPosition() = renderPos; if (renderer->getCameraController()) { auto* ft = renderer->getCameraController()->getFollowTargetMutable(); if (ft) *ft = renderPos; } worldEntryMovementGraceTimer_ = 2.0f; taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; // Stop any movement that was active before the teleport if (renderer->getCameraController()) { renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(0.5f); } // Kick off async upload for any tiles that finished background // parsing. Use the bounded processReadyTiles() instead of // processAllReadyTiles() to avoid multi-second main-thread stalls // when many tiles are ready (the rest will finalize over subsequent // frames via the normal terrain update loop). renderer->getTerrainManager()->processReadyTiles(); // Queue all remaining tiles within the load radius (8 tiles = 17x17) // at the new position. precacheTiles skips already-loaded/pending tiles, // so this only enqueues tiles that aren't yet in the pipeline. // This ensures background workers immediately start loading everything // visible from the new position (hearthstone may land far from old location). { auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); std::vector> nearbyTiles; nearbyTiles.reserve(289); for (int dy = -8; dy <= 8; dy++) for (int dx = -8; dx <= 8; dx++) nearbyTiles.push_back({tileX + dx, tileY + dy}); renderer->getTerrainManager()->precacheTiles(nearbyTiles); } return; } // If a world load is already in progress (re-entrant call from // gameHandler->update() processing SMSG_NEW_WORLD during warmup), // defer this entry. The current load will pick it up when it finishes. if (worldLoader_ && worldLoader_->isLoadingWorld()) { LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); worldLoader_->setPendingEntry(mapId, x, y, z); return; } // Full world loads are expensive and `loadOnlineWorldTerrain()` itself // drives `gameHandler->update()` during warmup. Queue the load here so // it runs after the current packet handler returns instead of recursing // from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`. LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")"); if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); }); auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { std::optional terrainFloor; std::optional wmoFloor; std::optional m2Floor; if (renderer && renderer->getTerrainManager()) { terrainFloor = renderer->getTerrainManager()->getHeightAt(x, y); } if (renderer && renderer->getWMORenderer()) { wmoFloor = renderer->getWMORenderer()->getFloorHeight(x, y, probeZ); } if (renderer && renderer->getM2Renderer()) { m2Floor = renderer->getM2Renderer()->getFloorHeight(x, y, probeZ); } std::optional best; if (terrainFloor) best = terrainFloor; if (wmoFloor && (!best || *wmoFloor > *best)) best = wmoFloor; if (m2Floor && (!best || *m2Floor > *best)) best = m2Floor; return best; }; auto clearStuckMovement = [this]() { if (renderer && renderer->getCameraController()) { renderer->getCameraController()->clearMovementInputs(); } if (gameHandler) { gameHandler->forceClearTaxiAndMovementState(); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_TURN); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_SWIM); gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); } }; auto syncTeleportedPositionToServer = [this](const glm::vec3& renderPos) { if (!gameHandler) return; glm::vec3 canonical = core::coords::renderToCanonical(renderPos); gameHandler->setPosition(canonical.x, canonical.y, canonical.z); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE); gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_TURN); gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT); }; auto forceServerTeleportCommand = [this](const glm::vec3& renderPos) { if (!gameHandler) return; // Server-authoritative reset first, then teleport. gameHandler->sendChatMessage(game::ChatType::SAY, ".revive", ""); gameHandler->sendChatMessage(game::ChatType::SAY, ".dismount", ""); glm::vec3 canonical = core::coords::renderToCanonical(renderPos); glm::vec3 serverPos = core::coords::canonicalToServer(canonical); std::ostringstream cmd; cmd.setf(std::ios::fixed); cmd.precision(3); cmd << ".go xyz " << serverPos.x << " " << serverPos.y << " " << serverPos.z << " " << gameHandler->getCurrentMapId() << " " << gameHandler->getMovementInfo().orientation; gameHandler->sendChatMessage(game::ChatType::SAY, cmd.str(), ""); }; // /unstuck — nudge player forward and snap to floor at destination. gameHandler->setUnstuckCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; clearStuckMovement(); auto* cc = renderer->getCameraController(); auto* ft = cc->getFollowTargetMutable(); if (!ft) return; glm::vec3 pos = *ft; // Always nudge forward first to escape stuck geometry (M2 models, collision seams). if (gameHandler) { float renderYaw = gameHandler->getMovementInfo().orientation + glm::radians(90.0f); pos.x += std::cos(renderYaw) * 5.0f; pos.y += std::sin(renderYaw) * 5.0f; } // Sample floor at the DESTINATION position (after nudge). // Pick the highest floor so we snap up to WMO floors when fallen below. bool foundFloor = false; if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) { pos.z = *floor + 0.2f; foundFloor = true; } cc->teleportTo(pos); if (!foundFloor) { cc->setGrounded(false); // Let gravity pull player down to a surface } syncTeleportedPositionToServer(pos); forceServerTeleportCommand(pos); clearStuckMovement(); LOG_INFO("Unstuck: nudged forward and snapped to floor"); }); // /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback. gameHandler->setUnstuckGyCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f); taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; clearStuckMovement(); auto* cc = renderer->getCameraController(); auto* ft = cc->getFollowTargetMutable(); if (!ft) return; // Try last safe position first (nearby, terrain already loaded) if (cc->hasLastSafePosition()) { glm::vec3 safePos = cc->getLastSafePosition(); safePos.z += 5.0f; cc->teleportTo(safePos); syncTeleportedPositionToServer(safePos); forceServerTeleportCommand(safePos); clearStuckMovement(); LOG_INFO("Unstuck: teleported to last safe position"); return; } uint32_t bindMap = 0; glm::vec3 bindPos(0.0f); if (gameHandler && gameHandler->getHomeBind(bindMap, bindPos) && bindMap == gameHandler->getCurrentMapId()) { bindPos.z += 2.0f; cc->teleportTo(bindPos); syncTeleportedPositionToServer(bindPos); forceServerTeleportCommand(bindPos); clearStuckMovement(); LOG_INFO("Unstuck: teleported to home bind position"); return; } // No safe/bind position — try current XY with a high floor probe. glm::vec3 pos = *ft; if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 120.0f)) { pos.z = *floor + 0.5f; cc->teleportTo(pos); syncTeleportedPositionToServer(pos); forceServerTeleportCommand(pos); clearStuckMovement(); LOG_INFO("Unstuck: teleported to sampled floor"); return; } // Last fallback: high snap to clear deeply bad geometry. pos.z += 60.0f; cc->teleportTo(pos); syncTeleportedPositionToServer(pos); forceServerTeleportCommand(pos); clearStuckMovement(); LOG_INFO("Unstuck: high fallback snap"); }); // /unstuckhearth — teleport to hearthstone bind point (server-synced). // Freezes player until terrain loads at destination to prevent falling through world. gameHandler->setUnstuckHearthCallback([this, clearStuckMovement, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController() || !gameHandler) return; uint32_t bindMap = 0; glm::vec3 bindPos(0.0f); if (!gameHandler->getHomeBind(bindMap, bindPos)) { LOG_WARNING("Unstuck hearth: no bind point available"); return; } worldEntryMovementGraceTimer_ = 10.0f; // long grace — terrain load check will clear it taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; clearStuckMovement(); auto* cc = renderer->getCameraController(); glm::vec3 renderPos = core::coords::canonicalToRender(bindPos); renderPos.z += 2.0f; // Freeze player in place (no gravity/movement) until terrain loads cc->teleportTo(renderPos); cc->setExternalFollow(true); forceServerTeleportCommand(renderPos); clearStuckMovement(); // Set pending state — update loop will unfreeze once terrain is loaded hearthTeleportPending_ = true; hearthTeleportPos_ = renderPos; hearthTeleportTimer_ = 15.0f; // 15s safety timeout LOG_INFO("Unstuck hearth: teleporting to bind point, waiting for terrain..."); }); // Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry if (renderer->getCameraController()) { renderer->getCameraController()->setAutoUnstuckCallback([this, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; auto* cc = renderer->getCameraController(); // Last resort: teleport to map entry point (terrain guaranteed loaded here) glm::vec3 spawnPos = cc->getDefaultPosition(); spawnPos.z += 5.0f; cc->teleportTo(spawnPos); forceServerTeleportCommand(spawnPos); LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)"); }); } // Bind point update (innkeeper) — position stored in gameHandler->getHomeBind() gameHandler->setBindPointCallback([this](uint32_t mapId, float x, float y, float z) { LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); }); // Hearthstone preload callback: begin loading terrain at the bind point as soon as // the player starts casting Hearthstone. The ~10 s cast gives enough time for // the background streaming workers to bring tiles into the cache so the player // lands on solid ground instead of falling through un-loaded terrain. gameHandler->setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) { if (!renderer || !assetManager) return; auto* terrainMgr = renderer->getTerrainManager(); if (!terrainMgr) return; // Resolve map name from the cached Map.dbc table std::string mapName; if (worldLoader_) { mapName = worldLoader_->getMapNameById(mapId); } if (mapName.empty()) { mapName = WorldLoader::mapIdToName(mapId); } if (mapName.empty()) mapName = "Azeroth"; uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF; if (mapId == currentLoadedMap) { // Same map: pre-enqueue tiles around the bind point so workers start // loading them now. Uses render-space coords (canonicalToRender). // Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time // for workers to parse most of these before the player arrives. glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); std::vector> tiles; tiles.reserve(81); for (int dy = -4; dy <= 4; dy++) for (int dx = -4; dx <= 4; dx++) tiles.push_back({tileX + dx, tileY + dy}); terrainMgr->precacheTiles(tiles); LOG_INFO("Hearthstone preload: enqueued ", tiles.size(), " tiles around bind point (same map) tile=[", tileX, ",", tileY, "]"); } else { // Different map: warm the file cache so ADT parsing is fast when // loadOnlineWorldTerrain runs its blocking load loop. // homeBindPos_ is canonical; startWorldPreload expects server coords. glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z)); if (worldLoader_) { worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y); } LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName, "' (id=", mapId, ")"); } }); // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!entitySpawner_) return; // Queue spawns to avoid hanging when many creatures appear at once. // Deduplicate so repeated updates don't flood pending queue. if (entitySpawner_->isCreatureSpawned(guid)) return; if (entitySpawner_->isCreaturePending(guid)) return; entitySpawner_->queueCreatureSpawn(guid, displayId, x, y, z, orientation, scale); }); // Player spawn callback (online mode) - spawn player models with correct textures gameHandler->setPlayerSpawnCallback([this](uint64_t guid, uint32_t /*displayId*/, uint8_t raceId, uint8_t genderId, uint32_t appearanceBytes, uint8_t facialFeatures, float x, float y, float z, float orientation) { if (!entitySpawner_) return; LOG_WARNING("playerSpawnCallback: guid=0x", std::hex, guid, std::dec, " race=", static_cast(raceId), " gender=", static_cast(genderId), " pos=(", x, ",", y, ",", z, ")"); // Skip local player — already spawned as the main character uint64_t localGuid = gameHandler ? gameHandler->getPlayerGuid() : 0; uint64_t activeGuid = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; if ((localGuid != 0 && guid == localGuid) || (activeGuid != 0 && guid == activeGuid) || (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { return; } if (entitySpawner_->isPlayerSpawned(guid)) return; if (entitySpawner_->isPlayerPending(guid)) return; entitySpawner_->queuePlayerSpawn(guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation); }); // Online player equipment callback - apply armor geosets/skin overlays per player instance. gameHandler->setPlayerEquipmentCallback([this](uint64_t guid, const std::array& displayInfoIds, const std::array& inventoryTypes) { if (!entitySpawner_) return; // Queue equipment compositing instead of doing it immediately — // compositeWithRegions is expensive (file I/O + CPU blit + GPU upload) // and causes frame stutters if multiple players update at once. entitySpawner_->queuePlayerEquipment(guid, displayInfoIds, inventoryTypes); }); // Creature despawn callback (online mode) - remove creature models gameHandler->setCreatureDespawnCallback([this](uint64_t guid) { if (!entitySpawner_) return; entitySpawner_->despawnCreature(guid); }); gameHandler->setPlayerDespawnCallback([this](uint64_t guid) { if (!entitySpawner_) return; entitySpawner_->despawnPlayer(guid); }); // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!entitySpawner_) return; entitySpawner_->queueGameObjectSpawn(guid, entry, displayId, x, y, z, orientation, scale); }); // GameObject despawn callback (online mode) - remove static models gameHandler->setGameObjectDespawnCallback([this](uint64_t guid) { if (!entitySpawner_) return; entitySpawner_->despawnGameObject(guid); }); // GameObject custom animation callback (e.g. chest opening) gameHandler->setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t /*animId*/) { if (!entitySpawner_) return; auto& goInstances = entitySpawner_->getGameObjectInstances(); auto it = goInstances.find(guid); if (it == goInstances.end() || !renderer) return; auto& info = it->second; if (!info.isWmo) { if (auto* m2r = renderer->getM2Renderer()) { m2r->setInstanceAnimationFrozen(info.instanceId, false); } } }); // Charge callback — warrior rushes toward target gameHandler->setChargeCallback([this](uint64_t targetGuid, float tx, float ty, float tz) { if (!renderer || !renderer->getCameraController() || !gameHandler) return; // Get current player position in render coords glm::vec3 startRender = renderer->getCharacterPosition(); // Convert target from canonical to render glm::vec3 targetRender = core::coords::canonicalToRender(glm::vec3(tx, ty, tz)); // Compute direction and stop 2.0 units short (melee reach) glm::vec3 dir = targetRender - startRender; float distSq = glm::dot(dir, dir); if (distSq < 9.0f) return; // Too close, nothing to do float invDist = glm::inversesqrt(distSq); glm::vec3 dirNorm = dir * invDist; glm::vec3 endRender = targetRender - dirNorm * 2.0f; // Face toward target BEFORE starting charge float yawRad = std::atan2(dirNorm.x, dirNorm.y); float yawDeg = glm::degrees(yawRad); renderer->setCharacterYaw(yawDeg); // Sync canonical orientation to server so it knows we turned float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg)); gameHandler->setOrientation(canonicalYaw); gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING); // Set charge state chargeActive_ = true; chargeTimer_ = 0.0f; chargeDuration_ = std::max(std::sqrt(distSq) / 25.0f, 0.3f); // ~25 units/sec chargeStartPos_ = startRender; chargeEndPos_ = endRender; chargeTargetGuid_ = targetGuid; // Disable player input, play charge animation renderer->getCameraController()->setExternalFollow(true); renderer->getCameraController()->clearMovementInputs(); renderer->setCharging(true); // Start charge visual effect (red haze + dust) glm::vec3 chargeDir = glm::normalize(endRender - startRender); renderer->startChargeEffect(startRender, chargeDir); // Play charge whoosh sound (try multiple paths) auto& audio = audio::AudioEngine::instance(); if (!audio.playSound2D("Sound\\Spells\\Charge.wav", 0.8f)) { if (!audio.playSound2D("Sound\\Spells\\charge.wav", 0.8f)) { if (!audio.playSound2D("Sound\\Spells\\SpellCharge.wav", 0.8f)) { // Fallback: weapon whoosh audio.playSound2D("Sound\\Item\\Weapons\\WeaponSwings\\mWooshLarge1.wav", 0.9f); } } } }); // Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect gameHandler->setLevelUpCallback([this](uint32_t newLevel) { if (uiManager) { uiManager->getGameScreen().toastManager().triggerDing(newLevel); } if (renderer) { renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); } }); // Achievement earned callback — show toast banner gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { if (uiManager) { uiManager->getGameScreen().toastManager().triggerAchievementToast(achievementId, name); } }); // Server-triggered music callback (SMSG_PLAY_MUSIC) // Resolves soundId → SoundEntries.dbc → MPQ path → MusicManager. gameHandler->setPlayMusicCallback([this](uint32_t soundId) { if (!assetManager || !renderer) return; auto* music = renderer->getMusicManager(); if (!music) return; auto dbc = assetManager->loadDBC("SoundEntries.dbc"); if (!dbc || !dbc->isLoaded()) return; int32_t idx = dbc->findRecordById(soundId); if (idx < 0) return; // SoundEntries.dbc (WotLK): field 2 = Name (label), fields 3-12 = File[0..9], field 23 = DirectoryBase const uint32_t row = static_cast(idx); std::string dir = dbc->getString(row, 23); for (uint32_t f = 3; f <= 12; ++f) { std::string name = dbc->getString(row, f); if (name.empty()) continue; std::string path = dir.empty() ? name : dir + "\\" + name; music->playMusic(path, /*loop=*/false); return; } }); // SMSG_PLAY_SOUND: look up SoundEntries.dbc and play 2-D sound effect gameHandler->setPlaySoundCallback([this](uint32_t soundId) { if (!assetManager) return; auto dbc = assetManager->loadDBC("SoundEntries.dbc"); if (!dbc || !dbc->isLoaded()) return; int32_t idx = dbc->findRecordById(soundId); if (idx < 0) return; const uint32_t row = static_cast(idx); std::string dir = dbc->getString(row, 23); for (uint32_t f = 3; f <= 12; ++f) { std::string name = dbc->getString(row, f); if (name.empty()) continue; std::string path = dir.empty() ? name : dir + "\\" + name; audio::AudioEngine::instance().playSound2D(path); return; } }); // SMSG_PLAY_OBJECT_SOUND / SMSG_PLAY_SPELL_IMPACT: play as 3D positional sound at source entity gameHandler->setPlayPositionalSoundCallback([this](uint32_t soundId, uint64_t sourceGuid) { if (!assetManager || !gameHandler) return; auto dbc = assetManager->loadDBC("SoundEntries.dbc"); if (!dbc || !dbc->isLoaded()) return; int32_t idx = dbc->findRecordById(soundId); if (idx < 0) return; const uint32_t row = static_cast(idx); std::string dir = dbc->getString(row, 23); for (uint32_t f = 3; f <= 12; ++f) { std::string name = dbc->getString(row, f); if (name.empty()) continue; std::string path = dir.empty() ? name : dir + "\\" + name; // Play as 3D sound if source entity position is available. // Entity stores canonical coords; listener uses render coords (camera). auto entity = gameHandler->getEntityManager().getEntity(sourceGuid); if (entity) { glm::vec3 canonical{entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()}; glm::vec3 pos = core::coords::canonicalToRender(canonical); audio::AudioEngine::instance().playSound3D(path, pos); } else { audio::AudioEngine::instance().playSound2D(path); } return; } }); // Other player level-up callback — trigger 3D effect + chat notification gameHandler->setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { if (!gameHandler || !renderer) return; // Trigger 3D effect at the other player's position auto entity = gameHandler->getEntityManager().getEntity(guid); if (entity) { glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->triggerLevelUpEffect(renderPos); } // Show chat message if in group if (gameHandler->isInGroup()) { std::string name = gameHandler->getCachedPlayerName(guid); if (name.empty()) name = "A party member"; game::MessageChatData msg; msg.type = game::ChatType::SYSTEM; msg.language = game::ChatLanguage::UNIVERSAL; msg.message = name + " has reached level " + std::to_string(newLevel) + "!"; gameHandler->addLocalChatMessage(msg); } }); // Mount callback (online mode) - defer heavy model load to next frame gameHandler->setMountCallback([this](uint32_t mountDisplayId) { if (mountDisplayId == 0) { // Dismount is instant (no loading needed) if (renderer && renderer->getCharacterRenderer() && entitySpawner_->getMountInstanceId() != 0) { renderer->getCharacterRenderer()->removeInstance(entitySpawner_->getMountInstanceId()); entitySpawner_->clearMountState(); } entitySpawner_->setMountDisplayId(0); if (renderer) renderer->clearMount(); LOG_INFO("Dismounted"); return; } // Queue the mount for processing in the next update() frame entitySpawner_->setMountDisplayId(mountDisplayId); }); // Taxi precache callback - preload terrain tiles along flight path gameHandler->setTaxiPrecacheCallback([this](const std::vector& path) { if (!renderer || !renderer->getTerrainManager()) return; std::set> uniqueTiles; // Sample waypoints along path and gather tiles. // Denser sampling + neighbor coverage reduces in-flight stream spikes. const size_t stride = 2; for (size_t i = 0; i < path.size(); i += stride) { const auto& waypoint = path[i]; glm::vec3 renderPos = core::coords::canonicalToRender(waypoint); int tileX = static_cast(32 - (renderPos.x / 533.33333f)); int tileY = static_cast(32 - (renderPos.y / 533.33333f)); if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { for (int dx = -1; dx <= 1; ++dx) { for (int dy = -1; dy <= 1; ++dy) { int nx = tileX + dx; int ny = tileY + dy; if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { uniqueTiles.insert({nx, ny}); } } } } } // Ensure final destination tile is included. if (!path.empty()) { glm::vec3 renderPos = core::coords::canonicalToRender(path.back()); int tileX = static_cast(32 - (renderPos.x / 533.33333f)); int tileY = static_cast(32 - (renderPos.y / 533.33333f)); if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { for (int dx = -1; dx <= 1; ++dx) { for (int dy = -1; dy <= 1; ++dy) { int nx = tileX + dx; int ny = tileY + dy; if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { uniqueTiles.insert({nx, ny}); } } } } } std::vector> tilesToLoad(uniqueTiles.begin(), uniqueTiles.end()); if (tilesToLoad.size() > 512) { tilesToLoad.resize(512); } LOG_INFO("Precaching ", tilesToLoad.size(), " tiles for taxi route"); renderer->getTerrainManager()->precacheTiles(tilesToLoad); }); // Taxi orientation callback - update mount rotation during flight gameHandler->setTaxiOrientationCallback([this](float yaw, float pitch, float roll) { if (renderer && renderer->getCameraController()) { // Taxi callback now provides render-space yaw directly. float yawDegrees = glm::degrees(yaw); renderer->getCameraController()->setFacingYaw(yawDegrees); renderer->setCharacterYaw(yawDegrees); // Set mount pitch and roll for realistic flight animation renderer->setMountPitchRoll(pitch, roll); } }); // Taxi flight start callback - keep non-blocking to avoid hitching at takeoff. gameHandler->setTaxiFlightStartCallback([this]() { if (renderer && renderer->getTerrainManager() && renderer->getM2Renderer()) { LOG_INFO("Taxi flight start: incremental terrain/M2 streaming active"); uint32_t m2Count = renderer->getM2Renderer()->getModelCount(); uint32_t instCount = renderer->getM2Renderer()->getInstanceCount(); LOG_INFO("Current M2 VRAM state: ", m2Count, " models (", instCount, " instances)"); } }); // Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER gameHandler->setOpenLfgCallback([this]() { if (uiManager) uiManager->getGameScreen().openDungeonFinder(); }); // Creature move callback (online mode) - update creature positions gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { if (!entitySpawner_) return; if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = 0; bool isPlayer = false; instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { isPlayer = true; } else { instanceId = entitySpawner_->getCreatureInstanceId(guid); } if (instanceId != 0) { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float durationSec = static_cast(durationMs) / 1000.0f; renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); // Play Run animation (anim 5) for the duration of the spline move. // WoW M2 animation IDs: 4=Walk, 5=Run. // Don't override Death animation (1). The per-frame sync loop will return to // Stand when movement stops. if (durationMs > 0) { // Player animation is managed by the local renderer state machine — // don't reset it here or every server movement packet restarts the // run cycle from frame 0, causing visible stutter. if (!isPlayer) { uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; auto* cr = renderer->getCharacterRenderer(); bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); // Only start Run if not already running and not in Death animation. if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) { cr->playAnimation(instanceId, 5u, /*loop=*/true); } entitySpawner_->getCreatureWasMoving()[guid] = true; } } } }); gameHandler->setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { if (!entitySpawner_) return; auto& goInstMap = entitySpawner_->getGameObjectInstances(); auto it = goInstMap.find(guid); if (it == goInstMap.end() || !renderer) { return; } glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); auto& info = it->second; if (info.isWmo) { if (auto* wr = renderer->getWMORenderer()) { glm::mat4 transform(1.0f); transform = glm::translate(transform, renderPos); transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); wr->setInstanceTransform(info.instanceId, transform); } } else { if (auto* mr = renderer->getM2Renderer()) { glm::mat4 transform(1.0f); transform = glm::translate(transform, renderPos); mr->setInstanceTransform(info.instanceId, transform); } } }); // Transport spawn callback (online mode) - register transports with TransportManager gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { if (!entitySpawner_) return; if (!renderer) return; // Get the GameObject instance now so late queue processing can rely on stable IDs. auto& goInstances2 = entitySpawner_->getGameObjectInstances(); auto it = goInstances2.find(guid); if (it == goInstances2.end()) { LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); return; } auto pendingIt = entitySpawner_->hasTransportRegistrationPending(guid); if (pendingIt) { entitySpawner_->updateTransportRegistration(guid, displayId, x, y, z, orientation); } else { entitySpawner_->queueTransportRegistration(guid, entry, displayId, x, y, z, orientation); } }); // Transport move callback (online mode) - update transport gameobject positions gameHandler->setTransportMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { if (!entitySpawner_) return; LOG_DEBUG("Transport move callback: GUID=0x", std::hex, guid, std::dec, " pos=(", x, ", ", y, ", ", z, ") orientation=", orientation); auto* transportManager = gameHandler->getTransportManager(); if (!transportManager) { LOG_WARNING("Transport move callback: TransportManager is null!"); return; } if (entitySpawner_->hasTransportRegistrationPending(guid)) { entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); return; } // Check if transport exists - if not, treat this as a late spawn (reconnection/server restart) if (!transportManager->getTransport(guid)) { LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec, " - auto-spawning from position update"); // Get transport info from entity manager auto entity = gameHandler->getEntityManager().getEntity(guid); if (entity && entity->getType() == game::ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(entity); uint32_t entry = go->getEntry(); uint32_t displayId = go->getDisplayId(); // Find the WMO instance for this transport (should exist from earlier GameObject spawn) auto& goInstances3 = entitySpawner_->getGameObjectInstances(); auto it = goInstances3.find(guid); if (it != goInstances3.end()) { uint32_t wmoInstanceId = it->second.instanceId; // TransportAnimation.dbc is indexed by GameObject entry uint32_t pathId = entry; const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); // Coordinates are already canonical (converted in game_handler.cpp) glm::vec3 canonicalSpawnPos(x, y, z); // Check if we have a real usable path, otherwise remap/infer/fall back to stationary. const bool shipOrZeppelinDisplay = (displayId == 3015 || displayId == 3031 || displayId == 7546 || displayId == 7446 || displayId == 1587 || displayId == 2454 || displayId == 807 || displayId == 808); bool hasUsablePath = transportManager->hasPathForEntry(entry); if (shipOrZeppelinDisplay) { hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); } if (preferServerData) { // Strict server-authoritative mode: no inferred/remapped fallback routes. if (!hasUsablePath) { std::vector path = { canonicalSpawnPos }; transportManager->loadPathFromNodes(pathId, path, false, 0.0f); LOG_INFO("Auto-spawned transport in strict server-first mode (stationary fallback): entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId); } else { LOG_INFO("Auto-spawned transport in server-first mode with entry DBC path: entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId); } } else if (!hasUsablePath) { bool allowZOnly = (displayId == 455 || displayId == 462); uint32_t inferredPath = transportManager->inferDbcPathForSpawn( canonicalSpawnPos, 1200.0f, allowZOnly); if (inferredPath != 0) { pathId = inferredPath; LOG_INFO("Auto-spawned transport with inferred path: entry=", entry, " inferredPath=", pathId, " displayId=", displayId, " wmoInstance=", wmoInstanceId); } else { uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); if (remappedPath != 0) { pathId = remappedPath; LOG_INFO("Auto-spawned transport with remapped fallback path: entry=", entry, " remappedPath=", pathId, " displayId=", displayId, " wmoInstance=", wmoInstanceId); } else { std::vector path = { canonicalSpawnPos }; transportManager->loadPathFromNodes(pathId, path, false, 0.0f); LOG_INFO("Auto-spawned transport with stationary path: entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId); } } } else { LOG_INFO("Auto-spawned transport with real path: entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId); } transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); // Keep type in sync with the spawned instance; needed for M2 lift boarding/motion. if (!it->second.isWmo) { if (auto* tr = transportManager->getTransport(guid)) { tr->isM2 = true; } } } else { entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, " - WMO instance not found yet (queued move for replay)"); return; } } else { entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, " - entity not found in EntityManager (queued move for replay)"); return; } } // Update TransportManager's internal state (position, rotation, transform matrices) // This also updates the WMO renderer automatically // Coordinates are already canonical (converted in game_handler.cpp when entity was created) glm::vec3 canonicalPos(x, y, z); transportManager->updateServerTransport(guid, canonicalPos, orientation); // Move player with transport if riding it if (gameHandler && gameHandler->isOnTransport() && gameHandler->getPlayerTransportGuid() == guid && renderer) { auto* cc = renderer->getCameraController(); if (cc) { glm::vec3* ft = cc->getFollowTargetMutable(); if (ft) { // Get player world position from TransportManager (handles transform properly) glm::vec3 offset = gameHandler->getPlayerTransportOffset(); glm::vec3 worldPos = transportManager->getPlayerWorldPosition(guid, offset); *ft = worldPos; } } } }); // NPC/player death callback (online mode) - play death animation gameHandler->setNpcDeathCallback([this](uint64_t guid) { if (!entitySpawner_) return; entitySpawner_->markCreatureDead(guid); if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death } }); // NPC/player respawn callback (online mode) - reset to idle animation gameHandler->setNpcRespawnCallback([this](uint64_t guid) { if (!entitySpawner_) return; entitySpawner_->unmarkCreatureDead(guid); if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle } }); // NPC/player swing callback (online mode) - play attack animation gameHandler->setNpcSwingCallback([this](uint64_t guid) { if (!entitySpawner_) return; if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { auto* cr = renderer->getCharacterRenderer(); // Try weapon-appropriate attack anim: 17=1H, 18=2H, 16=unarmed fallback static const uint32_t attackAnims[] = {17, 18, 16}; bool played = false; for (uint32_t anim : attackAnims) { if (cr->hasAnimation(instanceId, anim)) { cr->playAnimation(instanceId, anim, false); played = true; break; } } if (!played) cr->playAnimation(instanceId, 16, false); } }); // Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs. // Swim/walking state is now authoritative from the move-flags callback below. // animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync. gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) { if (!entitySpawner_) return; if (!renderer) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; uint32_t instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId == 0) instanceId = entitySpawner_->getCreatureInstanceId(guid); if (instanceId == 0) return; // Don't override Death animation (1) uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f; if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) return; cr->playAnimation(instanceId, animId, /*loop=*/true); }); // Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet. // This is more reliable than opcode-based hints for cold joins and heartbeats: // a player already swimming when we join will have SWIMMING set on the first heartbeat. // Walking(4) vs Running(5) is also driven here from the WALKING flag. gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) { if (!entitySpawner_) return; const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; const bool isFlying = (moveFlags & static_cast(game::MovementFlags::FLYING)) != 0; auto& swimState = entitySpawner_->getCreatureSwimmingState(); auto& walkState = entitySpawner_->getCreatureWalkingState(); auto& flyState = entitySpawner_->getCreatureFlyingState(); if (isSwimming) swimState[guid] = true; else swimState.erase(guid); if (isWalking) walkState[guid] = true; else walkState.erase(guid); if (isFlying) flyState[guid] = true; else flyState.erase(guid); }); // Emote animation callback — play server-driven emote animations on NPCs and other players gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) { if (!entitySpawner_) return; if (!renderer || emoteAnim == 0) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; // Look up creature instance first, then online players uint32_t emoteInstanceId = entitySpawner_->getCreatureInstanceId(guid); if (emoteInstanceId != 0) { cr->playAnimation(emoteInstanceId, emoteAnim, false); return; } emoteInstanceId = entitySpawner_->getPlayerInstanceId(guid); if (emoteInstanceId != 0) { cr->playAnimation(emoteInstanceId, emoteAnim, false); } }); // Spell cast animation callback — play cast animation on caster (player or NPC/other player) gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) { if (!entitySpawner_) return; if (!renderer) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; // Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer) const uint32_t castAnim = 3; // Check player character { uint32_t charInstId = renderer->getCharacterInstanceId(); if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) { if (start) cr->playAnimation(charInstId, castAnim, false); // On finish: playAnimation(castAnim, loop=false) will auto-return to Stand return; } } // Check creatures and other online players { uint32_t cInst = entitySpawner_->getCreatureInstanceId(guid); if (cInst != 0) { if (start) cr->playAnimation(cInst, castAnim, false); return; } } { uint32_t pInst = entitySpawner_->getPlayerInstanceId(guid); if (pInst != 0) { if (start) cr->playAnimation(pInst, castAnim, false); } } }); // Ghost state callback — make player semi-transparent when in spirit form gameHandler->setGhostStateCallback([this](bool isGhost) { if (!renderer) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; uint32_t charInstId = renderer->getCharacterInstanceId(); if (charInstId == 0) return; cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f); }); // Stand state animation callback — map server stand state to M2 animation on player // and sync camera sit flag so movement is blocked while sitting gameHandler->setStandStateCallback([this](uint8_t standState) { if (!renderer) return; // Sync camera controller sitting flag: block movement while sitting/kneeling if (auto* cc = renderer->getCameraController()) { cc->setSitting(standState >= 1 && standState <= 8 && standState != 7); } auto* cr = renderer->getCharacterRenderer(); if (!cr) return; uint32_t charInstId = renderer->getCharacterInstanceId(); if (charInstId == 0) return; // WoW stand state → M2 animation ID mapping // 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72 // Do not force Stand(0) here: locomotion state machine already owns standing/running. // Forcing Stand on packet timing causes visible run-cycle hitching while steering. uint32_t animId = 0; if (standState == 0) { return; } else if (standState >= 1 && standState <= 6) { animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height) } else if (standState == 7) { animId = 1; // Death } else if (standState == 8) { animId = 72; // Kneel } // Loop sit/kneel (not death) so the held-pose frame stays visible const bool loop = (animId != 1); cr->playAnimation(charInstId, animId, loop); }); // NPC greeting callback - play voice line gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { // Convert canonical to render coords for 3D audio glm::vec3 renderPos = core::coords::canonicalToRender(position); // Detect voice type from NPC display ID audio::VoiceType voiceType = audio::VoiceType::GENERIC; auto entity = gameHandler->getEntityManager().getEntity(guid); if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playGreeting(guid, voiceType, renderPos); } }); // NPC farewell callback - play farewell voice line gameHandler->setNpcFarewellCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { glm::vec3 renderPos = core::coords::canonicalToRender(position); audio::VoiceType voiceType = audio::VoiceType::GENERIC; auto entity = gameHandler->getEntityManager().getEntity(guid); if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playFarewell(guid, voiceType, renderPos); } }); // NPC vendor callback - play vendor voice line gameHandler->setNpcVendorCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { glm::vec3 renderPos = core::coords::canonicalToRender(position); audio::VoiceType voiceType = audio::VoiceType::GENERIC; auto entity = gameHandler->getEntityManager().getEntity(guid); if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playVendor(guid, voiceType, renderPos); } }); // NPC aggro callback - play combat start voice line gameHandler->setNpcAggroCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { glm::vec3 renderPos = core::coords::canonicalToRender(position); audio::VoiceType voiceType = audio::VoiceType::GENERIC; auto entity = gameHandler->getEntityManager().getEntity(guid); if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playAggro(guid, voiceType, renderPos); } }); // "Create Character" button on character screen uiManager->getCharacterScreen().setOnCreateCharacter([this]() { uiManager->getCharacterCreateScreen().reset(); // Apply expansion race/class constraints before showing the screen if (expansionRegistry_ && expansionRegistry_->getActive()) { auto* profile = expansionRegistry_->getActive(); uiManager->getCharacterCreateScreen().setExpansionConstraints( profile->races, profile->classes); } uiManager->getCharacterCreateScreen().initializePreview(assetManager.get()); setState(AppState::CHARACTER_CREATION); }); // "Back" button on character screen uiManager->getCharacterScreen().setOnBack([this]() { // Disconnect from world server and reset UI state for fresh realm selection if (gameHandler) { gameHandler->disconnect(); } uiManager->getRealmScreen().reset(); uiManager->getCharacterScreen().reset(); setState(AppState::REALM_SELECTION); }); // "Delete Character" button on character screen uiManager->getCharacterScreen().setOnDeleteCharacter([this](uint64_t guid) { if (gameHandler) { gameHandler->deleteCharacter(guid); } }); // Character delete result callback gameHandler->setCharDeleteCallback([this](bool success) { if (success) { uiManager->getCharacterScreen().setStatus("Character deleted."); // Refresh character list gameHandler->requestCharacterList(); } else { uint8_t code = gameHandler ? gameHandler->getLastCharDeleteResult() : 0xFF; uiManager->getCharacterScreen().setStatus( "Delete failed (code " + std::to_string(static_cast(code)) + ").", true); } }); } 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; if (gameHandler) { if (const game::Character* ch = gameHandler->getActiveCharacter()) { hairStyleId = static_cast((ch->appearanceBytes >> 16) & 0xFF); facialId = ch->facialFeatures; } } auto activeGeosets = appearanceComposer_ ? appearanceComposer_->buildDefaultPlayerGeosets(hairStyleId, facialId) : std::unordered_set{}; charRenderer->setActiveGeosets(instanceId, activeGeosets); // Play idle animation (Stand = animation ID 0) charRenderer->playAnimation(instanceId, 0, 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_ = renderer->getActivitySoundManager()) { 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) { qmr->initialize(vkCtx, pfl, assetManager.get()); } } } } 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