From 9b38e64f84f28e4a70b4436a87b878fcad492115 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 1 Apr 2026 20:06:26 +0300 Subject: [PATCH] "Fix and refine app initialization flow - Update core application startup paths and cleanup logic - Adjust renderer & input subsystem integration for stability - Address recent staging source updates with robust error handling" --- CMakeLists.txt | 1 + include/core/application.hpp | 36 +- include/core/world_loader.hpp | 125 ++++ src/core/application.cpp | 1220 ++------------------------------- src/core/world_loader.cpp | 1217 ++++++++++++++++++++++++++++++++ 5 files changed, 1391 insertions(+), 1208 deletions(-) create mode 100644 include/core/world_loader.hpp create mode 100644 src/core/world_loader.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 101a92cf..83fccadd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -488,6 +488,7 @@ set(WOWEE_SOURCES src/core/application.cpp src/core/entity_spawner.cpp src/core/appearance_composer.cpp + src/core/world_loader.cpp src/core/window.cpp src/core/input.cpp src/core/logger.cpp diff --git a/include/core/application.hpp b/include/core/application.hpp index 1865aabf..2f47e489 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -4,6 +4,7 @@ #include "core/input.hpp" #include "core/entity_spawner.hpp" #include "core/appearance_composer.hpp" +#include "core/world_loader.hpp" #include "game/character.hpp" #include "game/game_services.hpp" #include "pipeline/blp_loader.hpp" @@ -43,6 +44,8 @@ enum class AppState { }; class Application { + friend class WorldLoader; + public: Application(); ~Application(); @@ -98,14 +101,14 @@ public: // Appearance composer access AppearanceComposer* getAppearanceComposer() { return appearanceComposer_.get(); } + // World loader access + WorldLoader* getWorldLoader() { return worldLoader_.get(); } + private: void update(float deltaTime); void render(); void setupUICallbacks(); void spawnPlayerCharacter(); - static const char* mapIdToName(uint32_t mapId); - static const char* mapDisplayName(uint32_t mapId); - void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); void buildFactionHostilityMap(uint8_t playerRace); void setupTestTransport(); // Test transport boat for development @@ -125,6 +128,7 @@ private: std::unique_ptr dbcLayout_; std::unique_ptr entitySpawner_; std::unique_ptr appearanceComposer_; + std::unique_ptr worldLoader_; AppState state = AppState::AUTHENTICATION; bool running = false; @@ -147,13 +151,6 @@ private: static inline const std::vector emptyStringVec_; bool lastTaxiFlight_ = false; - uint32_t loadedMapId_ = 0xFFFFFFFF; // Map ID of currently loaded terrain (0xFFFFFFFF = none) - uint32_t worldLoadGeneration_ = 0; // Incremented on each world entry to detect re-entrant loads - bool loadingWorld_ = false; // True while loadOnlineWorldTerrain is running - struct PendingWorldEntry { - uint32_t mapId; float x, y, z; - }; - std::optional pendingWorldEntry_; // Deferred world entry during loading float taxiLandingClampTimer_ = 0.0f; float worldEntryMovementGraceTimer_ = 0.0f; @@ -175,29 +172,10 @@ private: uint64_t chargeTargetGuid_ = 0; bool wasAutoAttacking_ = false; - bool mapNameCacheLoaded_ = false; - std::unordered_map mapNameById_; // Quest marker billboard sprites (above NPCs) void loadQuestMarkerModels(); // Now loads BLP textures void updateQuestMarkers(); // Updates billboard positions - - // Background world preloader — warms AssetManager file cache for the - // expected world before the user clicks Enter World. - struct WorldPreload { - uint32_t mapId = 0; - std::string mapName; - int centerTileX = 0; - int centerTileY = 0; - std::atomic cancel{false}; - std::vector workers; - }; - std::unique_ptr worldPreload_; - void startWorldPreload(uint32_t mapId, const std::string& mapName, float serverX, float serverY); - void cancelWorldPreload(); - void saveLastWorldInfo(uint32_t mapId, const std::string& mapName, float serverX, float serverY); - struct LastWorldInfo { uint32_t mapId = 0; std::string mapName; float x = 0, y = 0; bool valid = false; }; - LastWorldInfo loadLastWorldInfo() const; }; } // namespace core diff --git a/include/core/world_loader.hpp b/include/core/world_loader.hpp new file mode 100644 index 00000000..01ce5483 --- /dev/null +++ b/include/core/world_loader.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { + +namespace rendering { class Renderer; } +namespace pipeline { class AssetManager; class DBCLayout; } +namespace game { class GameHandler; class World; } +namespace addons { class AddonManager; } + +namespace core { + +class Application; +class EntitySpawner; +class AppearanceComposer; +class Window; + +/// Handles terrain streaming, map transitions, world preloading, +/// and coordinate-aware tile management for online world entry. +class WorldLoader { +public: + WorldLoader(Application& app, + rendering::Renderer* renderer, + pipeline::AssetManager* assetManager, + game::GameHandler* gameHandler, + EntitySpawner* entitySpawner, + AppearanceComposer* appearanceComposer, + Window* window, + game::World* world, + addons::AddonManager* addonManager); + ~WorldLoader(); + + // Main terrain loading — drives loading screen, WMO/ADT detection, player spawn + void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); + + // Process deferred world entry (called from Application::update each frame) + void processPendingEntry(); + + // Map name utilities + static const char* mapIdToName(uint32_t mapId); + static const char* mapDisplayName(uint32_t mapId); + + // Background preloading — warms AssetManager file cache + void startWorldPreload(uint32_t mapId, const std::string& mapName, + float serverX, float serverY); + void cancelWorldPreload(); + + // Persistent world info for session-to-session preloading + void saveLastWorldInfo(uint32_t mapId, const std::string& mapName, + float serverX, float serverY); + struct LastWorldInfo { + uint32_t mapId = 0; + std::string mapName; + float x = 0, y = 0; + bool valid = false; + }; + LastWorldInfo loadLastWorldInfo() const; + + // State accessors + uint32_t getLoadedMapId() const { return loadedMapId_; } + bool isLoadingWorld() const { return loadingWorld_; } + bool hasPendingEntry() const { return pendingWorldEntry_.has_value(); } + + // Get cached map name by ID (returns empty string if not found) + std::string getMapNameById(uint32_t mapId) const { + auto it = mapNameById_.find(mapId); + return (it != mapNameById_.end()) ? it->second : std::string{}; + } + + // Set pending world entry for deferred processing via processPendingEntry() + void setPendingEntry(uint32_t mapId, float x, float y, float z) { + pendingWorldEntry_ = PendingWorldEntry{mapId, x, y, z}; + } + + // Reset methods (for logout / character switch) + void resetLoadedMap() { loadedMapId_ = 0xFFFFFFFF; } + void resetMapNameCache() { mapNameCacheLoaded_ = false; mapNameById_.clear(); } + +private: + Application& app_; + rendering::Renderer* renderer_; + pipeline::AssetManager* assetManager_; + game::GameHandler* gameHandler_; + EntitySpawner* entitySpawner_; + AppearanceComposer* appearanceComposer_; + Window* window_; + game::World* world_; + addons::AddonManager* addonManager_; + + uint32_t loadedMapId_ = 0xFFFFFFFF; // Map ID of currently loaded terrain (0xFFFFFFFF = none) + uint32_t worldLoadGeneration_ = 0; // Incremented on each world entry to detect re-entrant loads + bool loadingWorld_ = false; // True while loadOnlineWorldTerrain is running + + struct PendingWorldEntry { + uint32_t mapId; float x, y, z; + }; + std::optional pendingWorldEntry_; + + // Map.dbc name cache (loaded once per session) + bool mapNameCacheLoaded_ = false; + std::unordered_map mapNameById_; + + // Background world preloader — warms AssetManager file cache for the + // expected world before the user clicks Enter World. + struct WorldPreload { + uint32_t mapId = 0; + std::string mapName; + int centerTileX = 0; + int centerTileY = 0; + std::atomic cancel{false}; + std::vector workers; + }; + std::unique_ptr worldPreload_; +}; + +} // namespace core +} // namespace wowee diff --git a/src/core/application.cpp b/src/core/application.cpp index 433bc288..a91f8223 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -86,112 +86,6 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { } // namespace - -const char* Application::mapDisplayName(uint32_t mapId) { - // Friendly display names for the loading screen - switch (mapId) { - case 0: return "Eastern Kingdoms"; - case 1: return "Kalimdor"; - case 530: return "Outland"; - case 571: return "Northrend"; - default: return nullptr; - } -} - -const char* Application::mapIdToName(uint32_t mapId) { - // Fallback when Map.dbc is unavailable. Names must match WDT directory names - // (case-insensitive — AssetManager lowercases all paths). - switch (mapId) { - // Continents - case 0: return "Azeroth"; - case 1: return "Kalimdor"; - case 530: return "Expansion01"; - case 571: return "Northrend"; - // Classic dungeons/raids - case 30: return "PVPZone01"; - case 33: return "Shadowfang"; - case 34: return "StormwindJail"; - case 36: return "DeadminesInstance"; - case 43: return "WailingCaverns"; - case 47: return "RazserfenKraulInstance"; - case 48: return "Blackfathom"; - case 70: return "Uldaman"; - case 90: return "GnomeragonInstance"; - case 109: return "SunkenTemple"; - case 129: return "RazorfenDowns"; - case 189: return "MonasteryInstances"; - case 209: return "TanarisInstance"; - case 229: return "BlackRockSpire"; - case 230: return "BlackrockDepths"; - case 249: return "OnyxiaLairInstance"; - case 289: return "ScholomanceInstance"; - case 309: return "Zul'Gurub"; - case 329: return "Stratholme"; - case 349: return "Mauradon"; - case 369: return "DeeprunTram"; - case 389: return "OrgrimmarInstance"; - case 409: return "MoltenCore"; - case 429: return "DireMaul"; - case 469: return "BlackwingLair"; - case 489: return "PVPZone03"; - case 509: return "AhnQiraj"; - case 529: return "PVPZone04"; - case 531: return "AhnQirajTemple"; - case 533: return "Stratholme Raid"; - // TBC - case 532: return "Karazahn"; - case 534: return "HyjalPast"; - case 540: return "HellfireMilitary"; - case 542: return "HellfireDemon"; - case 543: return "HellfireRampart"; - case 544: return "HellfireRaid"; - case 545: return "CoilfangPumping"; - case 546: return "CoilfangMarsh"; - case 547: return "CoilfangDraenei"; - case 548: return "CoilfangRaid"; - case 550: return "TempestKeepRaid"; - case 552: return "TempestKeepArcane"; - case 553: return "TempestKeepAtrium"; - case 554: return "TempestKeepFactory"; - case 555: return "AuchindounShadow"; - case 556: return "AuchindounDraenei"; - case 557: return "AuchindounEthereal"; - case 558: return "AuchindounDemon"; - case 560: return "HillsbradPast"; - case 564: return "BlackTemple"; - case 565: return "GruulsLair"; - case 566: return "PVPZone05"; - case 568: return "ZulAman"; - case 580: return "SunwellPlateau"; - case 585: return "Sunwell5ManFix"; - // WotLK - case 574: return "Valgarde70"; - case 575: return "UtgardePinnacle"; - case 576: return "Nexus70"; - case 578: return "Nexus80"; - case 595: return "StratholmeCOT"; - case 599: return "Ulduar70"; - case 600: return "Ulduar80"; - case 601: return "DrakTheronKeep"; - case 602: return "GunDrak"; - case 603: return "UlduarRaid"; - case 608: return "DalaranPrison"; - case 615: return "ChamberOfAspectsBlack"; - case 617: return "DeathKnightStart"; - case 619: return "Azjol_Uppercity"; - case 624: return "WintergraspRaid"; - case 631: return "IcecrownCitadel"; - case 632: return "IcecrownCitadel5Man"; - case 649: return "ArgentTournamentRaid"; - case 650: return "ArgentTournamentDungeon"; - case 658: return "QuarryOfTears"; - case 668: return "HallsOfReflection"; - case 724: return "ChamberOfAspectsRed"; - default: return ""; - } -} - - Application* Application::instance = nullptr; Application::Application() { @@ -342,15 +236,6 @@ bool Application::initialize() { gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get()); } - // Start background preload for last-played character's world. - // Warms the file cache so terrain tile loading is faster at Enter World. - { - auto lastWorld = loadLastWorldInfo(); - if (lastWorld.valid) { - startWorldPreload(lastWorld.mapId, lastWorld.mapName, lastWorld.x, lastWorld.y); - } - } - // Initialize addon system addonManager_ = std::make_unique(); if (addonManager_->initialize(gameHandler.get())) { @@ -608,6 +493,21 @@ bool Application::initialize() { 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()); + + // 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"); @@ -884,7 +784,9 @@ void Application::shutdown() { } // Stop background world preloader before destroying AssetManager - cancelWorldPreload(); + if (worldLoader_) { + worldLoader_->cancelWorldPreload(); + }; // Save floor cache before renderer is destroyed if (renderer && renderer->getWMORenderer()) { @@ -959,7 +861,7 @@ void Application::setState(AppState newState) { addonsLoaded_ = false; if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); wasAutoAttacking_ = false; - loadedMapId_ = 0xFFFFFFFF; + if (worldLoader_) worldLoader_->resetLoadedMap(); spawnedPlayerGuid_ = 0; spawnedAppearanceBytes_ = 0; spawnedFacialFeatures_ = 0; @@ -1065,8 +967,7 @@ void Application::reloadExpansionData() { } // Reset map name cache so it reloads from new expansion's Map.dbc - mapNameCacheLoaded_ = false; - mapNameById_.clear(); + if (worldLoader_) worldLoader_->resetMapNameCache(); // Reset game handler DBC caches so they reload from new expansion data if (gameHandler) { @@ -1094,7 +995,7 @@ void Application::logoutToLogin() { playerCharacterSpawned = false; if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); wasAutoAttacking_ = false; - loadedMapId_ = 0xFFFFFFFF; + if (worldLoader_) worldLoader_->resetLoadedMap(); lastTaxiFlight_ = false; taxiLandingClampTimer_ = 0.0f; worldEntryMovementGraceTimer_ = 0.0f; @@ -2129,18 +2030,9 @@ void Application::update(float deltaTime) { break; } - if (pendingWorldEntry_ && !loadingWorld_ && state != AppState::DISCONNECTED) { - auto entry = *pendingWorldEntry_; - pendingWorldEntry_.reset(); - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - if (renderer && renderer->getCameraController()) { - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(1.0f); - renderer->getCameraController()->suspendGravityFor(10.0f); - } - loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); + // Process any pending world entry request via WorldLoader + if (worldLoader_ && state != AppState::DISCONNECTED) { + worldLoader_->processPendingEntry(); } // Update renderer (camera, etc.) only when in-game @@ -2339,7 +2231,8 @@ void Application::setupUICallbacks() { // 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. - if (entitySpawner_ && mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) { + 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. @@ -2379,7 +2272,7 @@ void Application::setupUICallbacks() { } // Same-map teleport (taxi landing, GM teleport, hearthstone on same continent): - if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager()) { + 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)); @@ -2401,7 +2294,7 @@ void Application::setupUICallbacks() { renderer->getCameraController()->suppressMovementFor(1.0f); renderer->getCameraController()->suspendGravityFor(10.0f); } - pendingWorldEntry_ = PendingWorldEntry{mapId, x, y, z}; + if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); return; } LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload"); @@ -2446,9 +2339,9 @@ void Application::setupUICallbacks() { // 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 (loadingWorld_) { + if (worldLoader_ && worldLoader_->isLoadingWorld()) { LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); - pendingWorldEntry_ = {mapId, x, y, z}; + worldLoader_->setPendingEntry(mapId, x, y, z); return; } @@ -2457,7 +2350,7 @@ void Application::setupUICallbacks() { // 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, ")"); - pendingWorldEntry_ = {mapId, x, y, z}; + if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z); }); auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { @@ -2687,14 +2580,16 @@ void Application::setupUICallbacks() { // Resolve map name from the cached Map.dbc table std::string mapName; - if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) { - mapName = it->second; - } else { - mapName = mapIdToName(mapId); + if (worldLoader_) { + mapName = worldLoader_->getMapNameById(mapId); + } + if (mapName.empty()) { + mapName = WorldLoader::mapIdToName(mapId); } if (mapName.empty()) mapName = "Azeroth"; - if (mapId == loadedMapId_) { + 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 @@ -2716,7 +2611,9 @@ void Application::setupUICallbacks() { // loadOnlineWorldTerrain runs its blocking load loop. // homeBindPos_ is canonical; startWorldPreload expects server coords. glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z)); - startWorldPreload(mapId, mapName, server.x, server.y); + if (worldLoader_) { + worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y); + } LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName, "' (id=", mapId, ")"); } @@ -3948,920 +3845,6 @@ void Application::buildFactionHostilityMap(uint8_t playerRace) { " hostile (friendGroup=0x", std::hex, playerFriendGroup, ", enemyGroup=0x", playerEnemyGroup, std::dec, ")"); } -void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z) { - if (!renderer || !assetManager || !assetManager->isInitialized()) { - LOG_WARNING("Cannot load online terrain: renderer or assets not ready"); - return; - } - - // Guard against re-entrant calls. The worldEntryCallback defers new - // entries while this flag is set; we process them at the end. - loadingWorld_ = true; - pendingWorldEntry_.reset(); - - // --- Loading screen for online mode --- - rendering::LoadingScreen loadingScreen; - loadingScreen.setVkContext(window->getVkContext()); - loadingScreen.setSDLWindow(window->getSDLWindow()); - bool loadingScreenOk = loadingScreen.initialize(); - - auto showProgress = [&](const char* msg, float progress) { - SDL_Event event; - while (SDL_PollEvent(&event)) { - if (event.type == SDL_QUIT) { - window->setShouldClose(true); - loadingScreen.shutdown(); - return; - } - if (event.type == SDL_WINDOWEVENT && - event.window.event == SDL_WINDOWEVENT_RESIZED) { - int w = event.window.data1; - int h = event.window.data2; - window->setSize(w, h); - // Vulkan viewport set in command buffer - if (renderer && renderer->getCamera()) { - renderer->getCamera()->setAspectRatio(static_cast(w) / h); - } - } - } - if (!loadingScreenOk) return; - loadingScreen.setStatus(msg); - loadingScreen.setProgress(progress); - loadingScreen.render(); - window->swapBuffers(); - }; - - // Set zone name on loading screen — prefer friendly display name, then DBC - { - const char* friendly = mapDisplayName(mapId); - if (friendly) { - loadingScreen.setZoneName(friendly); - } else if (gameHandler) { - std::string dbcName = gameHandler->getMapName(mapId); - if (!dbcName.empty()) - loadingScreen.setZoneName(dbcName); - else - loadingScreen.setZoneName("Loading..."); - } - } - - showProgress("Entering world...", 0.0f); - - // --- Clean up previous map's state on map change --- - // (Same cleanup as logout, but preserves player identity and renderer objects.) - LOG_WARNING("loadOnlineWorldTerrain: mapId=", mapId, " loadedMapId_=", loadedMapId_); - bool hasRendererData = renderer && (renderer->getWMORenderer() || renderer->getM2Renderer()); - if (loadedMapId_ != 0xFFFFFFFF || hasRendererData) { - LOG_WARNING("Map change: cleaning up old map ", loadedMapId_, " before loading map ", mapId); - - // Clear pending queues first (these don't touch GPU resources) - entitySpawner_->clearAllQueues(); - - if (renderer) { - // Clear all world geometry from old map (including textures/models). - // WMO clearAll and M2 clear both call vkDeviceWaitIdle internally, - // ensuring no GPU command buffers reference old resources. - if (auto* wmo = renderer->getWMORenderer()) { - wmo->clearAll(); - } - if (auto* m2 = renderer->getM2Renderer()) { - m2->clear(); - } - - // Full clear of character renderer: removes all instances, models, - // textures, and resets descriptor pools. This prevents stale GPU - // resources from accumulating across map changes (old creature - // models, bone buffers, texture descriptor sets) which can cause - // VK_ERROR_DEVICE_LOST on some drivers. - if (auto* cr = renderer->getCharacterRenderer()) { - cr->clear(); - renderer->setCharacterFollow(0); - } - // Reset equipment dirty tracking so composited textures are rebuilt - // after spawnPlayerCharacter() recreates the character instance. - if (gameHandler) { - gameHandler->resetEquipmentDirtyTracking(); - } - - if (auto* terrain = renderer->getTerrainManager()) { - terrain->softReset(); - terrain->setStreamingEnabled(true); // Re-enable in case previous map disabled it - } - if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { - questMarkers->clear(); - } - renderer->clearMount(); - } - - // Clear application-level instance tracking (after renderer cleanup) - entitySpawner_->resetAllState(); - - // Force player character re-spawn on new map - playerCharacterSpawned = false; - } - - // Resolve map folder name from Map.dbc (authoritative for world/instance maps). - // This is required for instances like DeeprunTram (map 369) that are not Azeroth/Kalimdor. - if (!mapNameCacheLoaded_ && assetManager) { - mapNameCacheLoaded_ = true; - if (auto mapDbc = assetManager->loadDBC("Map.dbc"); mapDbc && mapDbc->isLoaded()) { - mapNameById_.reserve(mapDbc->getRecordCount()); - const auto* mapL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Map") : nullptr; - for (uint32_t i = 0; i < mapDbc->getRecordCount(); i++) { - uint32_t id = mapDbc->getUInt32(i, mapL ? (*mapL)["ID"] : 0); - std::string internalName = mapDbc->getString(i, mapL ? (*mapL)["InternalName"] : 1); - if (!internalName.empty() && mapNameById_.find(id) == mapNameById_.end()) { - mapNameById_[id] = std::move(internalName); - } - } - LOG_INFO("Loaded Map.dbc map-name cache: ", mapNameById_.size(), " entries"); - } else { - LOG_WARNING("Map.dbc not available; using fallback map-id mapping"); - } - } - - std::string mapName; - if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) { - mapName = it->second; - } else { - mapName = mapIdToName(mapId); - } - if (mapName.empty()) { - LOG_WARNING("Unknown mapId ", mapId, " (no Map.dbc entry); falling back to Azeroth"); - mapName = "Azeroth"; - } - LOG_INFO("Loading online world terrain for map '", mapName, "' (ID ", mapId, ")"); - - // Cancel any stale preload (if it was for a different map, the file cache - // still retains whatever was loaded — it doesn't hurt). - if (worldPreload_) { - if (worldPreload_->mapId == mapId) { - LOG_INFO("World preload: cache-warm hit for map '", mapName, "'"); - } else { - LOG_INFO("World preload: map mismatch (preloaded ", worldPreload_->mapName, - ", entering ", mapName, ")"); - } - } - cancelWorldPreload(); - - // Save this world info for next session's early preload - saveLastWorldInfo(mapId, mapName, x, y); - - // Convert server coordinates to canonical WoW coordinates - // Server sends: X=West (canonical.Y), Y=North (canonical.X), Z=Up - glm::vec3 spawnCanonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); - glm::vec3 spawnRender = core::coords::canonicalToRender(spawnCanonical); - - // Set camera position and facing from server orientation - if (renderer->getCameraController()) { - float yawDeg = 0.0f; - if (gameHandler) { - float canonicalYaw = gameHandler->getMovementInfo().orientation; - yawDeg = 180.0f - glm::degrees(canonicalYaw); - } - renderer->getCameraController()->setOnlineMode(true); - renderer->getCameraController()->setDefaultSpawn(spawnRender, yawDeg, -15.0f); - renderer->getCameraController()->reset(); - } - - // Set map name for WMO renderer and reset instance mode - if (renderer->getWMORenderer()) { - renderer->getWMORenderer()->setMapName(mapName); - renderer->getWMORenderer()->setWMOOnlyMap(false); - } - - // Set map name for terrain manager - if (renderer->getTerrainManager()) { - renderer->getTerrainManager()->setMapName(mapName); - } - - // NOTE: TransportManager renderer connection moved to after initializeRenderers (later in this function) - - // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) - if (renderer->getWMORenderer() && renderer->getM2Renderer()) { - renderer->getWMORenderer()->setM2Renderer(renderer->getM2Renderer()); - LOG_INFO("WMORenderer connected to M2Renderer for hierarchical doodad transforms"); - } - - showProgress("Loading character model...", 0.05f); - - // Build faction hostility map for this character's race - if (gameHandler) { - const game::Character* activeChar = gameHandler->getActiveCharacter(); - if (activeChar) { - buildFactionHostilityMap(static_cast(activeChar->race)); - } - } - - // Spawn player model for online mode (skip if already spawned, e.g. teleport) - if (gameHandler) { - const game::Character* activeChar = gameHandler->getActiveCharacter(); - if (activeChar) { - const uint64_t activeGuid = gameHandler->getActiveCharacterGuid(); - const bool appearanceChanged = - (activeGuid != spawnedPlayerGuid_) || - (activeChar->appearanceBytes != spawnedAppearanceBytes_) || - (activeChar->facialFeatures != spawnedFacialFeatures_) || - (activeChar->race != playerRace_) || - (activeChar->gender != playerGender_) || - (activeChar->characterClass != playerClass_); - - if (!playerCharacterSpawned || appearanceChanged) { - if (appearanceChanged) { - LOG_INFO("Respawning player model for new/changed character: guid=0x", - std::hex, activeGuid, std::dec); - } - // Remove old instance so we don't keep stale visuals. - if (renderer && renderer->getCharacterRenderer()) { - uint32_t oldInst = renderer->getCharacterInstanceId(); - if (oldInst > 0) { - renderer->setCharacterFollow(0); - renderer->clearMount(); - renderer->getCharacterRenderer()->removeInstance(oldInst); - } - } - playerCharacterSpawned = false; - spawnedPlayerGuid_ = 0; - spawnedAppearanceBytes_ = 0; - spawnedFacialFeatures_ = 0; - - playerRace_ = activeChar->race; - playerGender_ = activeChar->gender; - playerClass_ = activeChar->characterClass; - spawnSnapToGround = false; - if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); - if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); // will no-op until instance exists - spawnPlayerCharacter(); - } - renderer->getCharacterPosition() = spawnRender; - LOG_INFO("Online player at render pos (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")"); - } else { - LOG_WARNING("No active character found for player model spawning"); - } - } - - showProgress("Loading terrain...", 0.20f); - - // Check WDT to detect WMO-only maps (dungeons, raids, BGs) - bool isWMOOnlyMap = false; - pipeline::WDTInfo wdtInfo; - { - std::string wdtPath = "World\\Maps\\" + mapName + "\\" + mapName + ".wdt"; - LOG_WARNING("Reading WDT: ", wdtPath); - std::vector wdtData = assetManager->readFile(wdtPath); - if (!wdtData.empty()) { - wdtInfo = pipeline::parseWDT(wdtData); - isWMOOnlyMap = wdtInfo.isWMOOnly() && !wdtInfo.rootWMOPath.empty(); - LOG_WARNING("WDT result: isWMOOnly=", isWMOOnlyMap, " rootWMO='", wdtInfo.rootWMOPath, "'"); - } else { - LOG_WARNING("No WDT file found at ", wdtPath); - } - } - - bool terrainOk = false; - - if (isWMOOnlyMap) { - // ---- WMO-only map (dungeon/raid/BG): load root WMO directly ---- - LOG_WARNING("WMO-only map detected — loading root WMO: ", wdtInfo.rootWMOPath); - showProgress("Loading instance geometry...", 0.25f); - - // Initialize renderers if they don't exist yet (first login to a WMO-only map). - // On map change, renderers already exist from the previous map. - if (!renderer->getWMORenderer() || !renderer->getTerrainManager()) { - renderer->initializeRenderers(assetManager.get(), mapName); - } - - // Set map name on WMO renderer and disable terrain streaming (no ADT tiles for instances) - if (renderer->getWMORenderer()) { - renderer->getWMORenderer()->setMapName(mapName); - renderer->getWMORenderer()->setWMOOnlyMap(true); - } - if (renderer->getTerrainManager()) { - renderer->getTerrainManager()->setStreamingEnabled(false); - } - - // Spawn player character now that renderers are initialized - if (!playerCharacterSpawned) { - spawnPlayerCharacter(); - if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); - } - - // Load the root WMO - auto* wmoRenderer = renderer->getWMORenderer(); - LOG_WARNING("WMO-only: wmoRenderer=", (wmoRenderer ? "valid" : "NULL")); - if (wmoRenderer) { - LOG_WARNING("WMO-only: reading root WMO file: ", wdtInfo.rootWMOPath); - std::vector wmoData = assetManager->readFile(wdtInfo.rootWMOPath); - LOG_WARNING("WMO-only: root WMO data size=", wmoData.size()); - if (!wmoData.empty()) { - pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); - LOG_WARNING("WMO-only: parsed WMO model, nGroups=", wmoModel.nGroups); - - if (wmoModel.nGroups > 0) { - showProgress("Loading instance groups...", 0.35f); - std::string basePath = wdtInfo.rootWMOPath; - std::string extension; - if (basePath.size() > 4) { - extension = basePath.substr(basePath.size() - 4); - std::string extLower = extension; - for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); - if (extLower == ".wmo") { - basePath = basePath.substr(0, basePath.size() - 4); - } - } - - uint32_t loadedGroups = 0; - for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { - char groupSuffix[16]; - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); - std::string groupPath = basePath + groupSuffix; - std::vector groupData = assetManager->readFile(groupPath); - if (groupData.empty()) { - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); - groupData = assetManager->readFile(basePath + groupSuffix); - } - if (groupData.empty()) { - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); - groupData = assetManager->readFile(basePath + groupSuffix); - } - if (!groupData.empty()) { - pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); - loadedGroups++; - } - - // Update loading progress - if (wmoModel.nGroups > 1) { - float groupProgress = 0.35f + 0.30f * static_cast(gi + 1) / wmoModel.nGroups; - char buf[128]; - snprintf(buf, sizeof(buf), "Loading instance groups... %u / %u", gi + 1, wmoModel.nGroups); - showProgress(buf, groupProgress); - } - } - - LOG_INFO("Loaded ", loadedGroups, " / ", wmoModel.nGroups, " WMO groups for instance"); - } - - // WMO-only maps: MODF uses same format as ADT MODF. - // Apply the same rotation conversion that outdoor WMOs get - // (including the implicit +180° Z yaw), but skip the ZEROPOINT - // position offset for zero-position instances (server sends - // coordinates relative to the WMO, not relative to map corner). - glm::vec3 wmoPos(0.0f); - glm::vec3 wmoRot( - -wdtInfo.rotation[2] * 3.14159f / 180.0f, - -wdtInfo.rotation[0] * 3.14159f / 180.0f, - (wdtInfo.rotation[1] + 180.0f) * 3.14159f / 180.0f - ); - if (wdtInfo.position[0] != 0.0f || wdtInfo.position[1] != 0.0f || wdtInfo.position[2] != 0.0f) { - wmoPos = core::coords::adtToWorld( - wdtInfo.position[0], wdtInfo.position[1], wdtInfo.position[2]); - } - - showProgress("Uploading instance geometry...", 0.70f); - uint32_t wmoModelId = 900000 + mapId; // Unique ID range for instance WMOs - if (wmoRenderer->loadModel(wmoModel, wmoModelId)) { - uint32_t instanceId = wmoRenderer->createInstance(wmoModelId, wmoPos, wmoRot, 1.0f); - if (instanceId > 0) { - LOG_WARNING("Instance WMO loaded: modelId=", wmoModelId, - " instanceId=", instanceId); - LOG_WARNING(" MOHD bbox local: (", - wmoModel.boundingBoxMin.x, ", ", wmoModel.boundingBoxMin.y, ", ", wmoModel.boundingBoxMin.z, - ") to (", wmoModel.boundingBoxMax.x, ", ", wmoModel.boundingBoxMax.y, ", ", wmoModel.boundingBoxMax.z, ")"); - LOG_WARNING(" WMO pos: (", wmoPos.x, ", ", wmoPos.y, ", ", wmoPos.z, - ") rot: (", wmoRot.x, ", ", wmoRot.y, ", ", wmoRot.z, ")"); - LOG_WARNING(" Player render pos: (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")"); - LOG_WARNING(" Player canonical: (", spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")"); - // Show player position in WMO local space - { - glm::mat4 instMat(1.0f); - instMat = glm::translate(instMat, wmoPos); - instMat = glm::rotate(instMat, wmoRot.z, glm::vec3(0,0,1)); - instMat = glm::rotate(instMat, wmoRot.y, glm::vec3(0,1,0)); - instMat = glm::rotate(instMat, wmoRot.x, glm::vec3(1,0,0)); - glm::mat4 invMat = glm::inverse(instMat); - glm::vec3 localPlayer = glm::vec3(invMat * glm::vec4(spawnRender, 1.0f)); - LOG_WARNING(" Player in WMO local: (", localPlayer.x, ", ", localPlayer.y, ", ", localPlayer.z, ")"); - bool inside = localPlayer.x >= wmoModel.boundingBoxMin.x && localPlayer.x <= wmoModel.boundingBoxMax.x && - localPlayer.y >= wmoModel.boundingBoxMin.y && localPlayer.y <= wmoModel.boundingBoxMax.y && - localPlayer.z >= wmoModel.boundingBoxMin.z && localPlayer.z <= wmoModel.boundingBoxMax.z; - LOG_WARNING(" Player inside MOHD bbox: ", inside ? "YES" : "NO"); - } - - // Load doodads from the specified doodad set - auto* m2Renderer = renderer->getM2Renderer(); - if (m2Renderer && !wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) { - uint32_t setIdx = std::min(static_cast(wdtInfo.doodadSet), - static_cast(wmoModel.doodadSets.size() - 1)); - const auto& doodadSet = wmoModel.doodadSets[setIdx]; - - showProgress("Loading instance doodads...", 0.75f); - glm::mat4 wmoMatrix(1.0f); - wmoMatrix = glm::translate(wmoMatrix, wmoPos); - wmoMatrix = glm::rotate(wmoMatrix, wmoRot.z, glm::vec3(0, 0, 1)); - wmoMatrix = glm::rotate(wmoMatrix, wmoRot.y, glm::vec3(0, 1, 0)); - wmoMatrix = glm::rotate(wmoMatrix, wmoRot.x, glm::vec3(1, 0, 0)); - - uint32_t loadedDoodads = 0; - for (uint32_t di = 0; di < doodadSet.count; di++) { - uint32_t doodadIdx = doodadSet.startIndex + di; - if (doodadIdx >= wmoModel.doodads.size()) break; - - const auto& doodad = wmoModel.doodads[doodadIdx]; - auto nameIt = wmoModel.doodadNames.find(doodad.nameIndex); - if (nameIt == wmoModel.doodadNames.end()) continue; - - std::string m2Path = nameIt->second; - if (m2Path.empty()) continue; - - if (m2Path.size() > 4) { - std::string ext = m2Path.substr(m2Path.size() - 4); - for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); - if (ext == ".mdx" || ext == ".mdl") { - m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2"; - } - } - - std::vector m2Data = assetManager->readFile(m2Path); - if (m2Data.empty()) continue; - - pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); - if (m2Model.name.empty()) m2Model.name = m2Path; - - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - std::vector skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && m2Model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, m2Model); - } - if (!m2Model.isValid()) continue; - - glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, - doodad.rotation.y, doodad.rotation.z); - glm::mat4 doodadLocal(1.0f); - doodadLocal = glm::translate(doodadLocal, doodad.position); - doodadLocal *= glm::mat4_cast(fixedRotation); - doodadLocal = glm::scale(doodadLocal, glm::vec3(doodad.scale)); - - glm::mat4 worldMatrix = wmoMatrix * doodadLocal; - glm::vec3 worldPos = glm::vec3(worldMatrix[3]); - - uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); - if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; - uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos); - if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true); - loadedDoodads++; - } - LOG_INFO("Loaded ", loadedDoodads, " instance WMO doodads"); - } - } else { - LOG_WARNING("Failed to create instance WMO instance"); - } - } else { - LOG_WARNING("Failed to load instance WMO model"); - } - } else { - LOG_WARNING("Failed to read root WMO file: ", wdtInfo.rootWMOPath); - } - - // Build collision cache for the instance WMO - showProgress("Building collision cache...", 0.88f); - if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } - wmoRenderer->loadFloorCache(); - if (wmoRenderer->getFloorCacheSize() == 0) { - showProgress("Computing walkable surfaces...", 0.90f); - if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } - wmoRenderer->precomputeFloorCache(); - } - } - - // Snap player to WMO floor so they don't fall through on first frame - if (wmoRenderer && renderer) { - glm::vec3 playerPos = renderer->getCharacterPosition(); - // Query floor with generous height margin above spawn point - auto floor = wmoRenderer->getFloorHeight(playerPos.x, playerPos.y, playerPos.z + 50.0f); - if (floor) { - playerPos.z = *floor + 0.1f; // Small offset above floor - renderer->getCharacterPosition() = playerPos; - if (gameHandler) { - glm::vec3 canonical = core::coords::renderToCanonical(playerPos); - gameHandler->setPosition(canonical.x, canonical.y, canonical.z); - } - LOG_INFO("Snapped player to instance WMO floor: z=", *floor); - } else { - LOG_WARNING("Could not find WMO floor at player spawn (", - playerPos.x, ", ", playerPos.y, ", ", playerPos.z, ")"); - } - } - - // Diagnostic: verify WMO renderer state after instance loading - LOG_WARNING("=== INSTANCE WMO LOAD COMPLETE ==="); - LOG_WARNING(" wmoRenderer models loaded: ", wmoRenderer->getLoadedModelCount()); - LOG_WARNING(" wmoRenderer instances: ", wmoRenderer->getInstanceCount()); - LOG_WARNING(" wmoRenderer floor cache: ", wmoRenderer->getFloorCacheSize()); - - terrainOk = true; // Mark as OK so post-load setup runs - } else { - // ---- Normal ADT-based map ---- - // Compute ADT tile from canonical coordinates - auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y); - std::string adtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + - std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt"; - LOG_INFO("Loading ADT tile [", tileX, ",", tileY, "] from canonical (", - spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")"); - - // Load the initial terrain tile - terrainOk = renderer->loadTestTerrain(assetManager.get(), adtPath); - if (!terrainOk) { - LOG_WARNING("Could not load terrain for online world - atmospheric rendering only"); - } else { - LOG_INFO("Online world terrain loading initiated"); - } - - // Set map name on WMO renderer (initializeRenderers handles terrain/minimap/worldMap) - if (renderer->getWMORenderer()) { - renderer->getWMORenderer()->setMapName(mapName); - } - - // Character renderer is created inside loadTestTerrain(), so spawn the - // player model now that the renderer actually exists. - if (!playerCharacterSpawned) { - spawnPlayerCharacter(); - if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); - } - - showProgress("Streaming terrain tiles...", 0.35f); - - // Wait for surrounding terrain tiles to stream in - if (terrainOk && renderer->getTerrainManager() && renderer->getCamera()) { - auto* terrainMgr = renderer->getTerrainManager(); - auto* camera = renderer->getCamera(); - - // Use a small radius for the initial load (just immediate tiles), - // then restore the full radius after entering the game. - // This matches WoW's behavior: load quickly, stream the rest in-game. - const int savedLoadRadius = 4; - terrainMgr->setLoadRadius(3); // 7x7=49 tiles — prevents hitches on spawn - terrainMgr->setUnloadRadius(7); - - // Trigger tile streaming for surrounding area - terrainMgr->update(*camera, 1.0f); - - auto startTime = std::chrono::high_resolution_clock::now(); - auto lastProgressTime = startTime; - const float maxWaitSeconds = 60.0f; - const float stallSeconds = 10.0f; - int initialRemaining = terrainMgr->getRemainingTileCount(); - if (initialRemaining < 1) initialRemaining = 1; - int lastRemaining = initialRemaining; - - // Wait until all pending + ready-queue tiles are finalized - while (terrainMgr->getRemainingTileCount() > 0) { - SDL_Event event; - while (SDL_PollEvent(&event)) { - if (event.type == SDL_QUIT) { - window->setShouldClose(true); - loadingScreen.shutdown(); - return; - } - if (event.type == SDL_WINDOWEVENT && - event.window.event == SDL_WINDOWEVENT_RESIZED) { - int w = event.window.data1; - int h = event.window.data2; - window->setSize(w, h); - // Vulkan viewport set in command buffer - if (renderer->getCamera()) { - renderer->getCamera()->setAspectRatio(static_cast(w) / h); - } - } - } - - // Trigger new streaming — enqueue tiles for background workers - terrainMgr->update(*camera, 0.016f); - - // Process ONE tile per iteration so the progress bar updates - // smoothly between tiles instead of stalling on large batches. - terrainMgr->processOneReadyTile(); - - int remaining = terrainMgr->getRemainingTileCount(); - int loaded = terrainMgr->getLoadedTileCount(); - int total = loaded + remaining; - if (total < 1) total = 1; - float tileProgress = static_cast(loaded) / static_cast(total); - float progress = 0.35f + tileProgress * 0.50f; - - auto now = std::chrono::high_resolution_clock::now(); - float elapsedSec = std::chrono::duration(now - startTime).count(); - - char buf[192]; - if (loaded > 0 && remaining > 0) { - float tilesPerSec = static_cast(loaded) / std::max(elapsedSec, 0.1f); - float etaSec = static_cast(remaining) / std::max(tilesPerSec, 0.1f); - snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles (%.0f tiles/s, ~%.0fs remaining)", - loaded, total, tilesPerSec, etaSec); - } else { - snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles", - loaded, total); - } - - if (loadingScreenOk) { - loadingScreen.setStatus(buf); - loadingScreen.setProgress(progress); - loadingScreen.render(); - window->swapBuffers(); - } - - if (remaining != lastRemaining) { - lastRemaining = remaining; - lastProgressTime = now; - } - - auto elapsed = std::chrono::high_resolution_clock::now() - startTime; - if (std::chrono::duration(elapsed).count() > maxWaitSeconds) { - LOG_WARNING("Online terrain streaming timeout after ", maxWaitSeconds, "s"); - break; - } - auto stalledFor = std::chrono::high_resolution_clock::now() - lastProgressTime; - if (std::chrono::duration(stalledFor).count() > stallSeconds) { - LOG_WARNING("Online terrain streaming stalled for ", stallSeconds, - "s (remaining=", lastRemaining, "), continuing without full preload"); - break; - } - - // Don't sleep if there are more tiles to finalize — keep processing - if (remaining > 0 && terrainMgr->getReadyQueueCount() == 0) { - SDL_Delay(16); - } - } - - LOG_INFO("Online terrain streaming complete: ", terrainMgr->getLoadedTileCount(), " tiles loaded"); - - // Restore full load radius — remaining tiles stream in-game - terrainMgr->setLoadRadius(savedLoadRadius); - - // Load/precompute collision cache - if (renderer->getWMORenderer()) { - showProgress("Building collision cache...", 0.88f); - if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } - renderer->getWMORenderer()->loadFloorCache(); - if (renderer->getWMORenderer()->getFloorCacheSize() == 0) { - showProgress("Computing walkable surfaces...", 0.90f); - if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } - renderer->getWMORenderer()->precomputeFloorCache(); - } - } - } - } - - // Snap player to loaded terrain so they don't spawn underground - if (renderer->getCameraController()) { - renderer->getCameraController()->reset(); - } - - // Test transport disabled — real transports come from server via UPDATEFLAG_TRANSPORT - showProgress("Finalizing world...", 0.94f); - // setupTestTransport(); - - // Connect TransportManager to renderers (must happen AFTER initializeRenderers) - if (gameHandler && gameHandler->getTransportManager()) { - auto* tm = gameHandler->getTransportManager(); - if (renderer->getWMORenderer()) tm->setWMORenderer(renderer->getWMORenderer()); - if (renderer->getM2Renderer()) tm->setM2Renderer(renderer->getM2Renderer()); - LOG_WARNING("TransportManager connected: wmoR=", (renderer->getWMORenderer() ? "yes" : "NULL"), - " m2R=", (renderer->getM2Renderer() ? "yes" : "NULL")); - } - - // Set up NPC animation callbacks (for online creatures) - showProgress("Preparing creatures...", 0.97f); - if (gameHandler && renderer && renderer->getCharacterRenderer()) { - auto* cr = renderer->getCharacterRenderer(); - auto* app = this; - - gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) { - app->entitySpawner_->markCreatureDead(guid); - uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid); - if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid); - if (instanceId != 0 && cr) { - cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death - } - }); - - gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) { - app->entitySpawner_->unmarkCreatureDead(guid); - uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid); - if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid); - if (instanceId != 0 && cr) { - cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle - } - }); - - gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) { - uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid); - if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid); - if (instanceId != 0 && cr) { - cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1 - } - }); - } - - // Keep the loading screen visible until all spawn/equipment/gameobject queues - // are fully drained. This ensures the player sees a fully populated world - // (character clothed, NPCs placed, game objects loaded) when the screen drops. - { - const float kMinWarmupSeconds = 2.0f; // minimum time to drain network packets - const float kMaxWarmupSeconds = 25.0f; // hard cap to avoid infinite stall - const auto warmupStart = std::chrono::high_resolution_clock::now(); - // Track consecutive idle iterations (all queues empty) to detect convergence - int idleIterations = 0; - const int kIdleThreshold = 5; // require 5 consecutive empty loops (~80ms) - - while (true) { - SDL_Event event; - while (SDL_PollEvent(&event)) { - if (event.type == SDL_QUIT) { - window->setShouldClose(true); - if (loadingScreenOk) loadingScreen.shutdown(); - return; - } - if (event.type == SDL_WINDOWEVENT && - event.window.event == SDL_WINDOWEVENT_RESIZED) { - int w = event.window.data1; - int h = event.window.data2; - window->setSize(w, h); - if (renderer && renderer->getCamera()) { - renderer->getCamera()->setAspectRatio(static_cast(w) / h); - } - } - } - - // Drain network and process deferred spawn/composite queues while hidden. - if (gameHandler) gameHandler->update(1.0f / 60.0f); - - // If a new world entry was deferred during packet processing, - // stop warming up this map — we'll load the new one after cleanup. - if (pendingWorldEntry_) { - LOG_WARNING("loadOnlineWorldTerrain(map ", mapId, - ") — deferred world entry pending, stopping warmup"); - break; - } - - if (world) world->update(1.0f / 60.0f); - - // Process all spawn/equipment/transport queues during warmup - entitySpawner_->update(); - if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { - cr->processPendingNormalMaps(4); - } - updateQuestMarkers(); - - // Update renderer (terrain streaming, animations) - if (renderer) { - renderer->update(1.0f / 60.0f); - } - - const auto now = std::chrono::high_resolution_clock::now(); - const float elapsed = std::chrono::duration(now - warmupStart).count(); - - // Check if all queues are drained - bool queuesEmpty = !entitySpawner_->hasWorkPending(); - - if (queuesEmpty) { - idleIterations++; - } else { - idleIterations = 0; - } - - // Don't exit warmup until the ground under the player exists. - // In cities like Stormwind, players stand on WMO floors, not terrain. - // Check BOTH terrain AND WMO floor — require at least one to be valid. - bool groundReady = false; - if (renderer) { - glm::vec3 renderSpawn = core::coords::canonicalToRender( - glm::vec3(x, y, z)); - float rx = renderSpawn.x, ry = renderSpawn.y, rz = renderSpawn.z; - - // Check WMO floor FIRST (cities like Stormwind stand on WMO floors). - // Terrain exists below WMOs but at the wrong height. - if (auto* wmo = renderer->getWMORenderer()) { - auto wmoH = wmo->getFloorHeight(rx, ry, rz + 5.0f); - if (wmoH.has_value() && std::abs(*wmoH - rz) < 15.0f) { - groundReady = true; - } - } - // Check terrain — but only if it's close to spawn Z (within 15 units). - // Terrain far below a WMO city doesn't count as ground. - if (!groundReady) { - if (auto* tm = renderer->getTerrainManager()) { - auto tH = tm->getHeightAt(rx, ry); - if (tH.has_value() && std::abs(*tH - rz) < 15.0f) { - groundReady = true; - } - } - } - // After 5s with enough tiles loaded, accept terrain as ready even if - // the height sample doesn't match spawn Z exactly. This handles cases - // where getHeightAt returns a slightly different value than the server's - // spawn Z (e.g. terrain LOD, MCNK chunk boundaries, or spawn inside a - // building where floor height differs from terrain below). - if (!groundReady && elapsed >= 5.0f) { - if (auto* tm = renderer->getTerrainManager()) { - if (tm->getLoadedTileCount() >= 4) { - groundReady = true; - LOG_WARNING("Warmup: using tile-count fallback (", tm->getLoadedTileCount(), " tiles) after ", elapsed, "s"); - } - } - } - - if (!groundReady && elapsed > 5.0f && static_cast(elapsed * 2) % 3 == 0) { - LOG_WARNING("Warmup: ground not ready at spawn (", rx, ",", ry, ",", rz, - ") after ", elapsed, "s"); - } - } - - // Exit when: (min time passed AND queues drained AND ground ready) OR hard cap - bool readyToExit = (elapsed >= kMinWarmupSeconds && idleIterations >= kIdleThreshold && groundReady); - if (readyToExit || elapsed >= kMaxWarmupSeconds) { - if (elapsed >= kMaxWarmupSeconds && !groundReady) { - LOG_WARNING("Warmup hit hard cap (", kMaxWarmupSeconds, "s), ground NOT ready — may fall through world"); - } else if (elapsed >= kMaxWarmupSeconds) { - LOG_WARNING("Warmup hit hard cap (", kMaxWarmupSeconds, "s), entering world with pending work"); - } - break; - } - - const float t = std::clamp(elapsed / kMaxWarmupSeconds, 0.0f, 1.0f); - showProgress("Finalizing world sync...", 0.97f + t * 0.025f); - SDL_Delay(16); - } - } - - // Start intro pan right before entering gameplay so it's visible after loading. - if (renderer->getCameraController()) { - renderer->getCameraController()->startIntroPan(2.8f, 140.0f); - } - - showProgress("Entering world...", 1.0f); - - // Ensure all GPU resources (textures, buffers, pipelines) created during - // world load are fully flushed before the first render frame. Without this, - // vkCmdBeginRenderPass can crash on NVIDIA 590.x when resources from async - // uploads haven't completed their queue operations. - if (renderer && renderer->getVkContext()) { - vkDeviceWaitIdle(renderer->getVkContext()->getDevice()); - } - - if (loadingScreenOk) { - loadingScreen.shutdown(); - } - - // Track which map we actually loaded (used by same-map teleport check). - loadedMapId_ = mapId; - - // Clear loading flag and process any deferred world entry. - // A deferred entry occurs when SMSG_NEW_WORLD arrived during our warmup - // (e.g., an area trigger in a dungeon immediately teleporting the player out). - loadingWorld_ = false; - if (pendingWorldEntry_) { - auto entry = *pendingWorldEntry_; - pendingWorldEntry_.reset(); - LOG_WARNING("Processing deferred world entry: map ", entry.mapId); - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - // Recursive call — sets loadedMapId_ and IN_GAME state for the final map. - loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); - return; // The recursive call handles setState(IN_GAME). - } - - // Only enter IN_GAME when this is the final map (no deferred entry pending). - setState(AppState::IN_GAME); - - // Load addons once per session on first world entry - if (addonManager_ && !addonsLoaded_) { - // Set character name for per-character SavedVariables - if (gameHandler) { - const std::string& charName = gameHandler->lookupName(gameHandler->getPlayerGuid()); - if (!charName.empty()) { - addonManager_->setCharacterName(charName); - } else { - // Fallback: find name from character list - for (const auto& c : gameHandler->getCharacters()) { - if (c.guid == gameHandler->getPlayerGuid()) { - addonManager_->setCharacterName(c.name); - break; - } - } - } - } - addonManager_->loadAllAddons(); - addonsLoaded_ = true; - addonManager_->fireEvent("VARIABLES_LOADED"); - addonManager_->fireEvent("PLAYER_LOGIN"); - addonManager_->fireEvent("PLAYER_ENTERING_WORLD"); - } else if (addonManager_ && addonsLoaded_) { - // Subsequent world entries (e.g. teleport, instance entry) - addonManager_->fireEvent("PLAYER_ENTERING_WORLD"); - } -} - // 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); @@ -5126,126 +4109,5 @@ void Application::setupTestTransport() { LOG_INFO("========================================"); } -// ─── World Preloader ───────────────────────────────────────────────────────── -// Pre-warms AssetManager file cache with ADT files (and their _obj0 variants) -// for tiles around the expected spawn position. Runs in background so that -// when loadOnlineWorldTerrain eventually asks TerrainManager workers to parse -// the same files, every readFile() is an instant cache hit instead of disk I/O. - -void Application::startWorldPreload(uint32_t mapId, const std::string& mapName, - float serverX, float serverY) { - cancelWorldPreload(); - if (!assetManager || !assetManager->isInitialized() || mapName.empty()) return; - - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, 0.0f)); - auto [tileX, tileY] = core::coords::canonicalToTile(canonical.x, canonical.y); - - worldPreload_ = std::make_unique(); - worldPreload_->mapId = mapId; - worldPreload_->mapName = mapName; - worldPreload_->centerTileX = tileX; - worldPreload_->centerTileY = tileY; - - LOG_INFO("World preload: starting for map '", mapName, "' tile [", tileX, ",", tileY, "]"); - - // Build list of tiles to preload (radius 1 = 3x3 = 9 tiles, matching load screen) - struct TileJob { int x, y; }; - auto jobs = std::make_shared>(); - // Center tile first (most important) - jobs->push_back({tileX, tileY}); - for (int dx = -1; dx <= 1; dx++) { - for (int dy = -1; dy <= 1; dy++) { - if (dx == 0 && dy == 0) continue; - int tx = tileX + dx, ty = tileY + dy; - if (tx < 0 || tx > 63 || ty < 0 || ty > 63) continue; - jobs->push_back({tx, ty}); - } - } - - // Spawn worker threads (one per tile for maximum parallelism) - auto cancelFlag = &worldPreload_->cancel; - auto* am = assetManager.get(); - std::string mn = mapName; - - int numWorkers = std::min(static_cast(jobs->size()), 4); - auto nextJob = std::make_shared>(0); - - for (int w = 0; w < numWorkers; w++) { - worldPreload_->workers.emplace_back([am, mn, jobs, nextJob, cancelFlag]() { - while (!cancelFlag->load(std::memory_order_relaxed)) { - int idx = nextJob->fetch_add(1, std::memory_order_relaxed); - if (idx >= static_cast(jobs->size())) break; - - int tx = (*jobs)[idx].x; - int ty = (*jobs)[idx].y; - - // Read ADT file (warms file cache) - std::string adtPath = "World\\Maps\\" + mn + "\\" + mn + "_" + - std::to_string(tx) + "_" + std::to_string(ty) + ".adt"; - am->readFile(adtPath); - if (cancelFlag->load(std::memory_order_relaxed)) break; - - // Read obj0 variant - std::string objPath = "World\\Maps\\" + mn + "\\" + mn + "_" + - std::to_string(tx) + "_" + std::to_string(ty) + "_obj0.adt"; - am->readFile(objPath); - } - LOG_DEBUG("World preload worker finished"); - }); - } -} - -void Application::cancelWorldPreload() { - if (!worldPreload_) return; - worldPreload_->cancel.store(true, std::memory_order_relaxed); - for (auto& t : worldPreload_->workers) { - if (t.joinable()) t.join(); - } - LOG_INFO("World preload: cancelled (map=", worldPreload_->mapName, - " tile=[", worldPreload_->centerTileX, ",", worldPreload_->centerTileY, "])"); - worldPreload_.reset(); -} - -void Application::saveLastWorldInfo(uint32_t mapId, const std::string& mapName, - float serverX, float serverY) { -#ifdef _WIN32 - const char* base = std::getenv("APPDATA"); - std::string dir = base ? std::string(base) + "\\wowee" : "."; -#else - const char* home = std::getenv("HOME"); - std::string dir = home ? std::string(home) + "/.wowee" : "."; -#endif - std::filesystem::create_directories(dir); - std::ofstream f(dir + "/last_world.cfg"); - if (f) { - f << mapId << "\n" << mapName << "\n" << serverX << "\n" << serverY << "\n"; - } -} - -Application::LastWorldInfo Application::loadLastWorldInfo() const { -#ifdef _WIN32 - const char* base = std::getenv("APPDATA"); - std::string dir = base ? std::string(base) + "\\wowee" : "."; -#else - const char* home = std::getenv("HOME"); - std::string dir = home ? std::string(home) + "/.wowee" : "."; -#endif - LastWorldInfo info; - std::ifstream f(dir + "/last_world.cfg"); - if (!f) return info; - std::string line; - try { - if (std::getline(f, line)) info.mapId = static_cast(std::stoul(line)); - if (std::getline(f, line)) info.mapName = line; - if (std::getline(f, line)) info.x = std::stof(line); - if (std::getline(f, line)) info.y = std::stof(line); - } catch (...) { - LOG_WARNING("Malformed last_world.cfg, ignoring saved position"); - return info; - } - info.valid = !info.mapName.empty(); - return info; -} - } // namespace core } // namespace wowee diff --git a/src/core/world_loader.cpp b/src/core/world_loader.cpp new file mode 100644 index 00000000..9e90e747 --- /dev/null +++ b/src/core/world_loader.cpp @@ -0,0 +1,1217 @@ +// WorldLoader — terrain streaming, map transitions, world preloading +// Extracted from Application as part of god-class decomposition (Section 3.3) + +#include "core/world_loader.hpp" +#include "core/application.hpp" +#include "core/entity_spawner.hpp" +#include "core/appearance_composer.hpp" +#include "core/window.hpp" +#include "core/coordinates.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" +#include "rendering/quest_marker_renderer.hpp" +#include "rendering/loading_screen.hpp" +#include "addons/addon_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "pipeline/m2_loader.hpp" +#include "pipeline/wmo_loader.hpp" +#include "pipeline/wdt_loader.hpp" +#include "game/game_handler.hpp" +#include "game/transport_manager.hpp" +#include "game/world.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace core { + +WorldLoader::WorldLoader(Application& app, + rendering::Renderer* renderer, + pipeline::AssetManager* assetManager, + game::GameHandler* gameHandler, + EntitySpawner* entitySpawner, + AppearanceComposer* appearanceComposer, + Window* window, + game::World* world, + addons::AddonManager* addonManager) + : app_(app) + , renderer_(renderer) + , assetManager_(assetManager) + , gameHandler_(gameHandler) + , entitySpawner_(entitySpawner) + , appearanceComposer_(appearanceComposer) + , window_(window) + , world_(world) + , addonManager_(addonManager) +{} + +WorldLoader::~WorldLoader() { + cancelWorldPreload(); +} + +const char* WorldLoader::mapDisplayName(uint32_t mapId) { + // Friendly display names for the loading screen + switch (mapId) { + case 0: return "Eastern Kingdoms"; + case 1: return "Kalimdor"; + case 530: return "Outland"; + case 571: return "Northrend"; + default: return nullptr; + } +} + +const char* WorldLoader::mapIdToName(uint32_t mapId) { + // Fallback when Map.dbc is unavailable. Names must match WDT directory names + // (case-insensitive — AssetManager lowercases all paths). + switch (mapId) { + // Continents + case 0: return "Azeroth"; + case 1: return "Kalimdor"; + case 530: return "Expansion01"; + case 571: return "Northrend"; + // Classic dungeons/raids + case 30: return "PVPZone01"; + case 33: return "Shadowfang"; + case 34: return "StormwindJail"; + case 36: return "DeadminesInstance"; + case 43: return "WailingCaverns"; + case 47: return "RazserfenKraulInstance"; + case 48: return "Blackfathom"; + case 70: return "Uldaman"; + case 90: return "GnomeragonInstance"; + case 109: return "SunkenTemple"; + case 129: return "RazorfenDowns"; + case 189: return "MonasteryInstances"; + case 209: return "TanarisInstance"; + case 229: return "BlackRockSpire"; + case 230: return "BlackrockDepths"; + case 249: return "OnyxiaLairInstance"; + case 289: return "ScholomanceInstance"; + case 309: return "Zul'Gurub"; + case 329: return "Stratholme"; + case 349: return "Mauradon"; + case 369: return "DeeprunTram"; + case 389: return "OrgrimmarInstance"; + case 409: return "MoltenCore"; + case 429: return "DireMaul"; + case 469: return "BlackwingLair"; + case 489: return "PVPZone03"; + case 509: return "AhnQiraj"; + case 529: return "PVPZone04"; + case 531: return "AhnQirajTemple"; + case 533: return "Stratholme Raid"; + // TBC + case 532: return "Karazahn"; + case 534: return "HyjalPast"; + case 540: return "HellfireMilitary"; + case 542: return "HellfireDemon"; + case 543: return "HellfireRampart"; + case 544: return "HellfireRaid"; + case 545: return "CoilfangPumping"; + case 546: return "CoilfangMarsh"; + case 547: return "CoilfangDraenei"; + case 548: return "CoilfangRaid"; + case 550: return "TempestKeepRaid"; + case 552: return "TempestKeepArcane"; + case 553: return "TempestKeepAtrium"; + case 554: return "TempestKeepFactory"; + case 555: return "AuchindounShadow"; + case 556: return "AuchindounDraenei"; + case 557: return "AuchindounEthereal"; + case 558: return "AuchindounDemon"; + case 560: return "HillsbradPast"; + case 564: return "BlackTemple"; + case 565: return "GruulsLair"; + case 566: return "PVPZone05"; + case 568: return "ZulAman"; + case 580: return "SunwellPlateau"; + case 585: return "Sunwell5ManFix"; + // WotLK + case 574: return "Valgarde70"; + case 575: return "UtgardePinnacle"; + case 576: return "Nexus70"; + case 578: return "Nexus80"; + case 595: return "StratholmeCOT"; + case 599: return "Ulduar70"; + case 600: return "Ulduar80"; + case 601: return "DrakTheronKeep"; + case 602: return "GunDrak"; + case 603: return "UlduarRaid"; + case 608: return "DalaranPrison"; + case 615: return "ChamberOfAspectsBlack"; + case 617: return "DeathKnightStart"; + case 619: return "Azjol_Uppercity"; + case 624: return "WintergraspRaid"; + case 631: return "IcecrownCitadel"; + case 632: return "IcecrownCitadel5Man"; + case 649: return "ArgentTournamentRaid"; + case 650: return "ArgentTournamentDungeon"; + case 658: return "QuarryOfTears"; + case 668: return "HallsOfReflection"; + case 724: return "ChamberOfAspectsRed"; + default: return ""; + } +} + +void WorldLoader::processPendingEntry() { + if (!pendingWorldEntry_ || loadingWorld_) return; + auto entry = *pendingWorldEntry_; + pendingWorldEntry_.reset(); + LOG_WARNING("Processing deferred world entry: map ", entry.mapId); + app_.worldEntryMovementGraceTimer_ = 2.0f; + app_.taxiLandingClampTimer_ = 0.0f; + app_.lastTaxiFlight_ = false; + // Clear camera movement inputs before loading terrain + if (renderer_ && renderer_->getCameraController()) { + renderer_->getCameraController()->clearMovementInputs(); + renderer_->getCameraController()->suppressMovementFor(1.0f); + renderer_->getCameraController()->suspendGravityFor(10.0f); + } + loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); +} + +void WorldLoader::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z) { + if (!renderer_ || !assetManager_ || !assetManager_->isInitialized()) { + LOG_WARNING("Cannot load online terrain: renderer or assets not ready"); + return; + } + + // Guard against re-entrant calls. The worldEntryCallback defers new + // entries while this flag is set; we process them at the end. + loadingWorld_ = true; + pendingWorldEntry_.reset(); + + // --- Loading screen for online mode --- + rendering::LoadingScreen loadingScreen; + loadingScreen.setVkContext(window_->getVkContext()); + loadingScreen.setSDLWindow(window_->getSDLWindow()); + bool loadingScreenOk = loadingScreen.initialize(); + + auto showProgress = [&](const char* msg, float progress) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_QUIT) { + window_->setShouldClose(true); + loadingScreen.shutdown(); + return; + } + if (event.type == SDL_WINDOWEVENT && + event.window.event == SDL_WINDOWEVENT_RESIZED) { + int w = event.window.data1; + int h = event.window.data2; + window_->setSize(w, h); + // Vulkan viewport set in command buffer + if (renderer_ && renderer_->getCamera()) { + renderer_->getCamera()->setAspectRatio(static_cast(w) / h); + } + } + } + if (!loadingScreenOk) return; + loadingScreen.setStatus(msg); + loadingScreen.setProgress(progress); + loadingScreen.render(); + window_->swapBuffers(); + }; + + // Set zone name on loading screen — prefer friendly display name, then DBC + { + const char* friendly = mapDisplayName(mapId); + if (friendly) { + loadingScreen.setZoneName(friendly); + } else if (gameHandler_) { + std::string dbcName = gameHandler_->getMapName(mapId); + if (!dbcName.empty()) + loadingScreen.setZoneName(dbcName); + else + loadingScreen.setZoneName("Loading..."); + } + } + + showProgress("Entering world...", 0.0f); + + // --- Clean up previous map's state on map change --- + // (Same cleanup as logout, but preserves player identity and renderer objects.) + LOG_WARNING("loadOnlineWorldTerrain: mapId=", mapId, " loadedMapId_=", loadedMapId_); + bool hasRendererData = renderer_ && (renderer_->getWMORenderer() || renderer_->getM2Renderer()); + if (loadedMapId_ != 0xFFFFFFFF || hasRendererData) { + LOG_WARNING("Map change: cleaning up old map ", loadedMapId_, " before loading map ", mapId); + + // Clear pending queues first (these don't touch GPU resources) + entitySpawner_->clearAllQueues(); + + if (renderer_) { + // Clear all world geometry from old map (including textures/models). + // WMO clearAll and M2 clear both call vkDeviceWaitIdle internally, + // ensuring no GPU command buffers reference old resources. + if (auto* wmo = renderer_->getWMORenderer()) { + wmo->clearAll(); + } + if (auto* m2 = renderer_->getM2Renderer()) { + m2->clear(); + } + + // Full clear of character renderer: removes all instances, models, + // textures, and resets descriptor pools. This prevents stale GPU + // resources from accumulating across map changes (old creature + // models, bone buffers, texture descriptor sets) which can cause + // VK_ERROR_DEVICE_LOST on some drivers. + if (auto* cr = renderer_->getCharacterRenderer()) { + cr->clear(); + renderer_->setCharacterFollow(0); + } + // Reset equipment dirty tracking so composited textures are rebuilt + // after spawnPlayerCharacter() recreates the character instance. + if (gameHandler_) { + gameHandler_->resetEquipmentDirtyTracking(); + } + + if (auto* terrain = renderer_->getTerrainManager()) { + terrain->softReset(); + terrain->setStreamingEnabled(true); // Re-enable in case previous map disabled it + } + if (auto* questMarkers = renderer_->getQuestMarkerRenderer()) { + questMarkers->clear(); + } + renderer_->clearMount(); + } + + // Clear application-level instance tracking (after renderer cleanup) + entitySpawner_->resetAllState(); + + // Force player character re-spawn on new map + app_.playerCharacterSpawned = false; + } + + // Resolve map folder name from Map.dbc (authoritative for world/instance maps). + // This is required for instances like DeeprunTram (map 369) that are not Azeroth/Kalimdor. + if (!mapNameCacheLoaded_ && assetManager_) { + mapNameCacheLoaded_ = true; + if (auto mapDbc = assetManager_->loadDBC("Map.dbc"); mapDbc && mapDbc->isLoaded()) { + mapNameById_.reserve(mapDbc->getRecordCount()); + const auto* mapL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Map") : nullptr; + for (uint32_t i = 0; i < mapDbc->getRecordCount(); i++) { + uint32_t id = mapDbc->getUInt32(i, mapL ? (*mapL)["ID"] : 0); + std::string internalName = mapDbc->getString(i, mapL ? (*mapL)["InternalName"] : 1); + if (!internalName.empty() && mapNameById_.find(id) == mapNameById_.end()) { + mapNameById_[id] = std::move(internalName); + } + } + LOG_INFO("Loaded Map.dbc map-name cache: ", mapNameById_.size(), " entries"); + } else { + LOG_WARNING("Map.dbc not available; using fallback map-id mapping"); + } + } + + std::string mapName; + if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) { + mapName = it->second; + } else { + mapName = mapIdToName(mapId); + } + if (mapName.empty()) { + LOG_WARNING("Unknown mapId ", mapId, " (no Map.dbc entry); falling back to Azeroth"); + mapName = "Azeroth"; + } + LOG_INFO("Loading online world terrain for map '", mapName, "' (ID ", mapId, ")"); + + // Cancel any stale preload (if it was for a different map, the file cache + // still retains whatever was loaded — it doesn't hurt). + if (worldPreload_) { + if (worldPreload_->mapId == mapId) { + LOG_INFO("World preload: cache-warm hit for map '", mapName, "'"); + } else { + LOG_INFO("World preload: map mismatch (preloaded ", worldPreload_->mapName, + ", entering ", mapName, ")"); + } + } + cancelWorldPreload(); + + // Save this world info for next session's early preload + saveLastWorldInfo(mapId, mapName, x, y); + + // Convert server coordinates to canonical WoW coordinates + // Server sends: X=West (canonical.Y), Y=North (canonical.X), Z=Up + glm::vec3 spawnCanonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); + glm::vec3 spawnRender = core::coords::canonicalToRender(spawnCanonical); + + // Set camera position and facing from server orientation + if (renderer_->getCameraController()) { + float yawDeg = 0.0f; + if (gameHandler_) { + float canonicalYaw = gameHandler_->getMovementInfo().orientation; + yawDeg = 180.0f - glm::degrees(canonicalYaw); + } + renderer_->getCameraController()->setOnlineMode(true); + renderer_->getCameraController()->setDefaultSpawn(spawnRender, yawDeg, -15.0f); + renderer_->getCameraController()->reset(); + } + + // Set map name for WMO renderer and reset instance mode + if (renderer_->getWMORenderer()) { + renderer_->getWMORenderer()->setMapName(mapName); + renderer_->getWMORenderer()->setWMOOnlyMap(false); + } + + // Set map name for terrain manager + if (renderer_->getTerrainManager()) { + renderer_->getTerrainManager()->setMapName(mapName); + } + + // NOTE: TransportManager renderer connection moved to after initializeRenderers (later in this function) + + // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) + if (renderer_->getWMORenderer() && renderer_->getM2Renderer()) { + renderer_->getWMORenderer()->setM2Renderer(renderer_->getM2Renderer()); + LOG_INFO("WMORenderer connected to M2Renderer for hierarchical doodad transforms"); + } + + showProgress("Loading character model...", 0.05f); + + // Build faction hostility map for this character's race + if (gameHandler_) { + const game::Character* activeChar = gameHandler_->getActiveCharacter(); + if (activeChar) { + app_.buildFactionHostilityMap(static_cast(activeChar->race)); + } + } + + // Spawn player model for online mode (skip if already spawned, e.g. teleport) + if (gameHandler_) { + const game::Character* activeChar = gameHandler_->getActiveCharacter(); + if (activeChar) { + const uint64_t activeGuid = gameHandler_->getActiveCharacterGuid(); + const bool appearanceChanged = + (activeGuid != app_.spawnedPlayerGuid_) || + (activeChar->appearanceBytes != app_.spawnedAppearanceBytes_) || + (activeChar->facialFeatures != app_.spawnedFacialFeatures_) || + (activeChar->race != app_.playerRace_) || + (activeChar->gender != app_.playerGender_) || + (activeChar->characterClass != app_.playerClass_); + + if (!app_.playerCharacterSpawned || appearanceChanged) { + if (appearanceChanged) { + LOG_INFO("Respawning player model for new/changed character: guid=0x", + std::hex, activeGuid, std::dec); + } + // Remove old instance so we don't keep stale visuals. + if (renderer_ && renderer_->getCharacterRenderer()) { + uint32_t oldInst = renderer_->getCharacterInstanceId(); + if (oldInst > 0) { + renderer_->setCharacterFollow(0); + renderer_->clearMount(); + renderer_->getCharacterRenderer()->removeInstance(oldInst); + } + } + app_.playerCharacterSpawned = false; + app_.spawnedPlayerGuid_ = 0; + app_.spawnedAppearanceBytes_ = 0; + app_.spawnedFacialFeatures_ = 0; + + app_.playerRace_ = activeChar->race; + app_.playerGender_ = activeChar->gender; + app_.playerClass_ = activeChar->characterClass; + app_.spawnSnapToGround = false; + if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false); + if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); // will no-op until instance exists + app_.spawnPlayerCharacter(); + } + renderer_->getCharacterPosition() = spawnRender; + LOG_INFO("Online player at render pos (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")"); + } else { + LOG_WARNING("No active character found for player model spawning"); + } + } + + showProgress("Loading terrain...", 0.20f); + + // Check WDT to detect WMO-only maps (dungeons, raids, BGs) + bool isWMOOnlyMap = false; + pipeline::WDTInfo wdtInfo; + { + std::string wdtPath = "World\\Maps\\" + mapName + "\\" + mapName + ".wdt"; + LOG_WARNING("Reading WDT: ", wdtPath); + std::vector wdtData = assetManager_->readFile(wdtPath); + if (!wdtData.empty()) { + wdtInfo = pipeline::parseWDT(wdtData); + isWMOOnlyMap = wdtInfo.isWMOOnly() && !wdtInfo.rootWMOPath.empty(); + LOG_WARNING("WDT result: isWMOOnly=", isWMOOnlyMap, " rootWMO='", wdtInfo.rootWMOPath, "'"); + } else { + LOG_WARNING("No WDT file found at ", wdtPath); + } + } + + bool terrainOk = false; + + if (isWMOOnlyMap) { + // ---- WMO-only map (dungeon/raid/BG): load root WMO directly ---- + LOG_WARNING("WMO-only map detected — loading root WMO: ", wdtInfo.rootWMOPath); + showProgress("Loading instance geometry...", 0.25f); + + // Initialize renderers if they don't exist yet (first login to a WMO-only map). + // On map change, renderers already exist from the previous map. + if (!renderer_->getWMORenderer() || !renderer_->getTerrainManager()) { + renderer_->initializeRenderers(assetManager_, mapName); + } + + // Set map name on WMO renderer and disable terrain streaming (no ADT tiles for instances) + if (renderer_->getWMORenderer()) { + renderer_->getWMORenderer()->setMapName(mapName); + renderer_->getWMORenderer()->setWMOOnlyMap(true); + } + if (renderer_->getTerrainManager()) { + renderer_->getTerrainManager()->setStreamingEnabled(false); + } + + // Spawn player character now that renderers are initialized + if (!app_.playerCharacterSpawned) { + app_.spawnPlayerCharacter(); + if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); + } + + // Load the root WMO + auto* wmoRenderer = renderer_->getWMORenderer(); + LOG_WARNING("WMO-only: wmoRenderer=", (wmoRenderer ? "valid" : "NULL")); + if (wmoRenderer) { + LOG_WARNING("WMO-only: reading root WMO file: ", wdtInfo.rootWMOPath); + std::vector wmoData = assetManager_->readFile(wdtInfo.rootWMOPath); + LOG_WARNING("WMO-only: root WMO data size=", wmoData.size()); + if (!wmoData.empty()) { + pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); + LOG_WARNING("WMO-only: parsed WMO model, nGroups=", wmoModel.nGroups); + + if (wmoModel.nGroups > 0) { + showProgress("Loading instance groups...", 0.35f); + std::string basePath = wdtInfo.rootWMOPath; + std::string extension; + if (basePath.size() > 4) { + extension = basePath.substr(basePath.size() - 4); + std::string extLower = extension; + for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); + if (extLower == ".wmo") { + basePath = basePath.substr(0, basePath.size() - 4); + } + } + + uint32_t loadedGroups = 0; + for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { + char groupSuffix[16]; + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); + std::string groupPath = basePath + groupSuffix; + std::vector groupData = assetManager_->readFile(groupPath); + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); + groupData = assetManager_->readFile(basePath + groupSuffix); + } + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); + groupData = assetManager_->readFile(basePath + groupSuffix); + } + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); + loadedGroups++; + } + + // Update loading progress + if (wmoModel.nGroups > 1) { + float groupProgress = 0.35f + 0.30f * static_cast(gi + 1) / wmoModel.nGroups; + char buf[128]; + snprintf(buf, sizeof(buf), "Loading instance groups... %u / %u", gi + 1, wmoModel.nGroups); + showProgress(buf, groupProgress); + } + } + + LOG_INFO("Loaded ", loadedGroups, " / ", wmoModel.nGroups, " WMO groups for instance"); + } + + // WMO-only maps: MODF uses same format as ADT MODF. + // Apply the same rotation conversion that outdoor WMOs get + // (including the implicit +180° Z yaw), but skip the ZEROPOINT + // position offset for zero-position instances (server sends + // coordinates relative to the WMO, not relative to map corner). + glm::vec3 wmoPos(0.0f); + glm::vec3 wmoRot( + -wdtInfo.rotation[2] * 3.14159f / 180.0f, + -wdtInfo.rotation[0] * 3.14159f / 180.0f, + (wdtInfo.rotation[1] + 180.0f) * 3.14159f / 180.0f + ); + if (wdtInfo.position[0] != 0.0f || wdtInfo.position[1] != 0.0f || wdtInfo.position[2] != 0.0f) { + wmoPos = core::coords::adtToWorld( + wdtInfo.position[0], wdtInfo.position[1], wdtInfo.position[2]); + } + + showProgress("Uploading instance geometry...", 0.70f); + uint32_t wmoModelId = 900000 + mapId; // Unique ID range for instance WMOs + if (wmoRenderer->loadModel(wmoModel, wmoModelId)) { + uint32_t instanceId = wmoRenderer->createInstance(wmoModelId, wmoPos, wmoRot, 1.0f); + if (instanceId > 0) { + LOG_WARNING("Instance WMO loaded: modelId=", wmoModelId, + " instanceId=", instanceId); + LOG_WARNING(" MOHD bbox local: (", + wmoModel.boundingBoxMin.x, ", ", wmoModel.boundingBoxMin.y, ", ", wmoModel.boundingBoxMin.z, + ") to (", wmoModel.boundingBoxMax.x, ", ", wmoModel.boundingBoxMax.y, ", ", wmoModel.boundingBoxMax.z, ")"); + LOG_WARNING(" WMO pos: (", wmoPos.x, ", ", wmoPos.y, ", ", wmoPos.z, + ") rot: (", wmoRot.x, ", ", wmoRot.y, ", ", wmoRot.z, ")"); + LOG_WARNING(" Player render pos: (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")"); + LOG_WARNING(" Player canonical: (", spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")"); + // Show player position in WMO local space + { + glm::mat4 instMat(1.0f); + instMat = glm::translate(instMat, wmoPos); + instMat = glm::rotate(instMat, wmoRot.z, glm::vec3(0,0,1)); + instMat = glm::rotate(instMat, wmoRot.y, glm::vec3(0,1,0)); + instMat = glm::rotate(instMat, wmoRot.x, glm::vec3(1,0,0)); + glm::mat4 invMat = glm::inverse(instMat); + glm::vec3 localPlayer = glm::vec3(invMat * glm::vec4(spawnRender, 1.0f)); + LOG_WARNING(" Player in WMO local: (", localPlayer.x, ", ", localPlayer.y, ", ", localPlayer.z, ")"); + bool inside = localPlayer.x >= wmoModel.boundingBoxMin.x && localPlayer.x <= wmoModel.boundingBoxMax.x && + localPlayer.y >= wmoModel.boundingBoxMin.y && localPlayer.y <= wmoModel.boundingBoxMax.y && + localPlayer.z >= wmoModel.boundingBoxMin.z && localPlayer.z <= wmoModel.boundingBoxMax.z; + LOG_WARNING(" Player inside MOHD bbox: ", inside ? "YES" : "NO"); + } + + // Load doodads from the specified doodad set + auto* m2Renderer = renderer_->getM2Renderer(); + if (m2Renderer && !wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) { + uint32_t setIdx = std::min(static_cast(wdtInfo.doodadSet), + static_cast(wmoModel.doodadSets.size() - 1)); + const auto& doodadSet = wmoModel.doodadSets[setIdx]; + + showProgress("Loading instance doodads...", 0.75f); + glm::mat4 wmoMatrix(1.0f); + wmoMatrix = glm::translate(wmoMatrix, wmoPos); + wmoMatrix = glm::rotate(wmoMatrix, wmoRot.z, glm::vec3(0, 0, 1)); + wmoMatrix = glm::rotate(wmoMatrix, wmoRot.y, glm::vec3(0, 1, 0)); + wmoMatrix = glm::rotate(wmoMatrix, wmoRot.x, glm::vec3(1, 0, 0)); + + uint32_t loadedDoodads = 0; + for (uint32_t di = 0; di < doodadSet.count; di++) { + uint32_t doodadIdx = doodadSet.startIndex + di; + if (doodadIdx >= wmoModel.doodads.size()) break; + + const auto& doodad = wmoModel.doodads[doodadIdx]; + auto nameIt = wmoModel.doodadNames.find(doodad.nameIndex); + if (nameIt == wmoModel.doodadNames.end()) continue; + + std::string m2Path = nameIt->second; + if (m2Path.empty()) continue; + + if (m2Path.size() > 4) { + std::string ext = m2Path.substr(m2Path.size() - 4); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + if (ext == ".mdx" || ext == ".mdl") { + m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2"; + } + } + + std::vector m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) continue; + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + if (m2Model.name.empty()) m2Model.name = m2Path; + + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && m2Model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } + if (!m2Model.isValid()) continue; + + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, + doodad.rotation.y, doodad.rotation.z); + glm::mat4 doodadLocal(1.0f); + doodadLocal = glm::translate(doodadLocal, doodad.position); + doodadLocal *= glm::mat4_cast(fixedRotation); + doodadLocal = glm::scale(doodadLocal, glm::vec3(doodad.scale)); + + glm::mat4 worldMatrix = wmoMatrix * doodadLocal; + glm::vec3 worldPos = glm::vec3(worldMatrix[3]); + + uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; + uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos); + if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true); + loadedDoodads++; + } + LOG_INFO("Loaded ", loadedDoodads, " instance WMO doodads"); + } + } else { + LOG_WARNING("Failed to create instance WMO instance"); + } + } else { + LOG_WARNING("Failed to load instance WMO model"); + } + } else { + LOG_WARNING("Failed to read root WMO file: ", wdtInfo.rootWMOPath); + } + + // Build collision cache for the instance WMO + showProgress("Building collision cache...", 0.88f); + if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); } + wmoRenderer->loadFloorCache(); + if (wmoRenderer->getFloorCacheSize() == 0) { + showProgress("Computing walkable surfaces...", 0.90f); + if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); } + wmoRenderer->precomputeFloorCache(); + } + } + + // Snap player to WMO floor so they don't fall through on first frame + if (wmoRenderer && renderer_) { + glm::vec3 playerPos = renderer_->getCharacterPosition(); + // Query floor with generous height margin above spawn point + auto floor = wmoRenderer->getFloorHeight(playerPos.x, playerPos.y, playerPos.z + 50.0f); + if (floor) { + playerPos.z = *floor + 0.1f; // Small offset above floor + renderer_->getCharacterPosition() = playerPos; + if (gameHandler_) { + glm::vec3 canonical = core::coords::renderToCanonical(playerPos); + gameHandler_->setPosition(canonical.x, canonical.y, canonical.z); + } + LOG_INFO("Snapped player to instance WMO floor: z=", *floor); + } else { + LOG_WARNING("Could not find WMO floor at player spawn (", + playerPos.x, ", ", playerPos.y, ", ", playerPos.z, ")"); + } + } + + // Diagnostic: verify WMO renderer state after instance loading + LOG_WARNING("=== INSTANCE WMO LOAD COMPLETE ==="); + LOG_WARNING(" wmoRenderer models loaded: ", wmoRenderer->getLoadedModelCount()); + LOG_WARNING(" wmoRenderer instances: ", wmoRenderer->getInstanceCount()); + LOG_WARNING(" wmoRenderer floor cache: ", wmoRenderer->getFloorCacheSize()); + + terrainOk = true; // Mark as OK so post-load setup runs + } else { + // ---- Normal ADT-based map ---- + // Compute ADT tile from canonical coordinates + auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y); + std::string adtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + + std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt"; + LOG_INFO("Loading ADT tile [", tileX, ",", tileY, "] from canonical (", + spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")"); + + // Load the initial terrain tile + terrainOk = renderer_->loadTestTerrain(assetManager_, adtPath); + if (!terrainOk) { + LOG_WARNING("Could not load terrain for online world - atmospheric rendering only"); + } else { + LOG_INFO("Online world terrain loading initiated"); + } + + // Set map name on WMO renderer (initializeRenderers handles terrain/minimap/worldMap) + if (renderer_->getWMORenderer()) { + renderer_->getWMORenderer()->setMapName(mapName); + } + + // Character renderer is created inside loadTestTerrain(), so spawn the + // player model now that the renderer actually exists. + if (!app_.playerCharacterSpawned) { + app_.spawnPlayerCharacter(); + if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); + } + + showProgress("Streaming terrain tiles...", 0.35f); + + // Wait for surrounding terrain tiles to stream in + if (terrainOk && renderer_->getTerrainManager() && renderer_->getCamera()) { + auto* terrainMgr = renderer_->getTerrainManager(); + auto* camera = renderer_->getCamera(); + + // Use a small radius for the initial load (just immediate tiles), + // then restore the full radius after entering the game. + // This matches WoW's behavior: load quickly, stream the rest in-game. + const int savedLoadRadius = 4; + terrainMgr->setLoadRadius(3); // 7x7=49 tiles — prevents hitches on spawn + terrainMgr->setUnloadRadius(7); + + // Trigger tile streaming for surrounding area + terrainMgr->update(*camera, 1.0f); + + auto startTime = std::chrono::high_resolution_clock::now(); + auto lastProgressTime = startTime; + const float maxWaitSeconds = 60.0f; + const float stallSeconds = 10.0f; + int initialRemaining = terrainMgr->getRemainingTileCount(); + if (initialRemaining < 1) initialRemaining = 1; + int lastRemaining = initialRemaining; + + // Wait until all pending + ready-queue tiles are finalized + while (terrainMgr->getRemainingTileCount() > 0) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_QUIT) { + window_->setShouldClose(true); + loadingScreen.shutdown(); + return; + } + if (event.type == SDL_WINDOWEVENT && + event.window.event == SDL_WINDOWEVENT_RESIZED) { + int w = event.window.data1; + int h = event.window.data2; + window_->setSize(w, h); + // Vulkan viewport set in command buffer + if (renderer_->getCamera()) { + renderer_->getCamera()->setAspectRatio(static_cast(w) / h); + } + } + } + + // Trigger new streaming — enqueue tiles for background workers + terrainMgr->update(*camera, 0.016f); + + // Process ONE tile per iteration so the progress bar updates + // smoothly between tiles instead of stalling on large batches. + terrainMgr->processOneReadyTile(); + + int remaining = terrainMgr->getRemainingTileCount(); + int loaded = terrainMgr->getLoadedTileCount(); + int total = loaded + remaining; + if (total < 1) total = 1; + float tileProgress = static_cast(loaded) / static_cast(total); + float progress = 0.35f + tileProgress * 0.50f; + + auto now = std::chrono::high_resolution_clock::now(); + float elapsedSec = std::chrono::duration(now - startTime).count(); + + char buf[192]; + if (loaded > 0 && remaining > 0) { + float tilesPerSec = static_cast(loaded) / std::max(elapsedSec, 0.1f); + float etaSec = static_cast(remaining) / std::max(tilesPerSec, 0.1f); + snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles (%.0f tiles/s, ~%.0fs remaining)", + loaded, total, tilesPerSec, etaSec); + } else { + snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles", + loaded, total); + } + + if (loadingScreenOk) { + loadingScreen.setStatus(buf); + loadingScreen.setProgress(progress); + loadingScreen.render(); + window_->swapBuffers(); + } + + if (remaining != lastRemaining) { + lastRemaining = remaining; + lastProgressTime = now; + } + + auto elapsed = std::chrono::high_resolution_clock::now() - startTime; + if (std::chrono::duration(elapsed).count() > maxWaitSeconds) { + LOG_WARNING("Online terrain streaming timeout after ", maxWaitSeconds, "s"); + break; + } + auto stalledFor = std::chrono::high_resolution_clock::now() - lastProgressTime; + if (std::chrono::duration(stalledFor).count() > stallSeconds) { + LOG_WARNING("Online terrain streaming stalled for ", stallSeconds, + "s (remaining=", lastRemaining, "), continuing without full preload"); + break; + } + + // Don't sleep if there are more tiles to finalize — keep processing + if (remaining > 0 && terrainMgr->getReadyQueueCount() == 0) { + SDL_Delay(16); + } + } + + LOG_INFO("Online terrain streaming complete: ", terrainMgr->getLoadedTileCount(), " tiles loaded"); + + // Restore full load radius — remaining tiles stream in-game + terrainMgr->setLoadRadius(savedLoadRadius); + + // Load/precompute collision cache + if (renderer_->getWMORenderer()) { + showProgress("Building collision cache...", 0.88f); + if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); } + renderer_->getWMORenderer()->loadFloorCache(); + if (renderer_->getWMORenderer()->getFloorCacheSize() == 0) { + showProgress("Computing walkable surfaces...", 0.90f); + if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); } + renderer_->getWMORenderer()->precomputeFloorCache(); + } + } + } + } + + // Snap player to loaded terrain so they don't spawn underground + if (renderer_->getCameraController()) { + renderer_->getCameraController()->reset(); + } + + // Test transport disabled — real transports come from server via UPDATEFLAG_TRANSPORT + showProgress("Finalizing world...", 0.94f); + // setupTestTransport(); + + // Connect TransportManager to renderers (must happen AFTER initializeRenderers) + if (gameHandler_ && gameHandler_->getTransportManager()) { + auto* tm = gameHandler_->getTransportManager(); + if (renderer_->getWMORenderer()) tm->setWMORenderer(renderer_->getWMORenderer()); + if (renderer_->getM2Renderer()) tm->setM2Renderer(renderer_->getM2Renderer()); + LOG_WARNING("TransportManager connected: wmoR=", (renderer_->getWMORenderer() ? "yes" : "NULL"), + " m2R=", (renderer_->getM2Renderer() ? "yes" : "NULL")); + } + + // Set up NPC animation callbacks (for online creatures) + showProgress("Preparing creatures...", 0.97f); + if (gameHandler_ && renderer_ && renderer_->getCharacterRenderer()) { + auto* cr = renderer_->getCharacterRenderer(); + auto* spawner = entitySpawner_; + + gameHandler_->setNpcDeathCallback([cr, spawner](uint64_t guid) { + spawner->markCreatureDead(guid); + uint32_t instanceId = spawner->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid); + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death + } + }); + + gameHandler_->setNpcRespawnCallback([cr, spawner](uint64_t guid) { + spawner->unmarkCreatureDead(guid); + uint32_t instanceId = spawner->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid); + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle + } + }); + + gameHandler_->setNpcSwingCallback([cr, spawner](uint64_t guid) { + uint32_t instanceId = spawner->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid); + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1 + } + }); + } + + // Keep the loading screen visible until all spawn/equipment/gameobject queues + // are fully drained. This ensures the player sees a fully populated world + // (character clothed, NPCs placed, game objects loaded) when the screen drops. + { + const float kMinWarmupSeconds = 2.0f; // minimum time to drain network packets + const float kMaxWarmupSeconds = 25.0f; // hard cap to avoid infinite stall + const auto warmupStart = std::chrono::high_resolution_clock::now(); + // Track consecutive idle iterations (all queues empty) to detect convergence + int idleIterations = 0; + const int kIdleThreshold = 5; // require 5 consecutive empty loops (~80ms) + + while (true) { + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_QUIT) { + window_->setShouldClose(true); + if (loadingScreenOk) loadingScreen.shutdown(); + return; + } + if (event.type == SDL_WINDOWEVENT && + event.window.event == SDL_WINDOWEVENT_RESIZED) { + int w = event.window.data1; + int h = event.window.data2; + window_->setSize(w, h); + if (renderer_ && renderer_->getCamera()) { + renderer_->getCamera()->setAspectRatio(static_cast(w) / h); + } + } + } + + // Drain network and process deferred spawn/composite queues while hidden. + if (gameHandler_) gameHandler_->update(1.0f / 60.0f); + + // If a new world entry was deferred during packet processing, + // stop warming up this map — we'll load the new one after cleanup. + if (pendingWorldEntry_) { + LOG_WARNING("loadOnlineWorldTerrain(map ", mapId, + ") — deferred world entry pending, stopping warmup"); + break; + } + + if (world_) world_->update(1.0f / 60.0f); + + // Process all spawn/equipment/transport queues during warmup + entitySpawner_->update(); + if (auto* cr = renderer_ ? renderer_->getCharacterRenderer() : nullptr) { + cr->processPendingNormalMaps(4); + } + app_.updateQuestMarkers(); + + // Update renderer (terrain streaming, animations) + if (renderer_) { + renderer_->update(1.0f / 60.0f); + } + + const auto now = std::chrono::high_resolution_clock::now(); + const float elapsed = std::chrono::duration(now - warmupStart).count(); + + // Check if all queues are drained + bool queuesEmpty = !entitySpawner_->hasWorkPending(); + + if (queuesEmpty) { + idleIterations++; + } else { + idleIterations = 0; + } + + // Don't exit warmup until the ground under the player exists. + // In cities like Stormwind, players stand on WMO floors, not terrain. + // Check BOTH terrain AND WMO floor — require at least one to be valid. + bool groundReady = false; + if (renderer_) { + glm::vec3 renderSpawn = core::coords::canonicalToRender( + glm::vec3(x, y, z)); + float rx = renderSpawn.x, ry = renderSpawn.y, rz = renderSpawn.z; + + // Check WMO floor FIRST (cities like Stormwind stand on WMO floors). + // Terrain exists below WMOs but at the wrong height. + if (auto* wmo = renderer_->getWMORenderer()) { + auto wmoH = wmo->getFloorHeight(rx, ry, rz + 5.0f); + if (wmoH.has_value() && std::abs(*wmoH - rz) < 15.0f) { + groundReady = true; + } + } + // Check terrain — but only if it's close to spawn Z (within 15 units). + // Terrain far below a WMO city doesn't count as ground. + if (!groundReady) { + if (auto* tm = renderer_->getTerrainManager()) { + auto tH = tm->getHeightAt(rx, ry); + if (tH.has_value() && std::abs(*tH - rz) < 15.0f) { + groundReady = true; + } + } + } + // After 5s with enough tiles loaded, accept terrain as ready even if + // the height sample doesn't match spawn Z exactly. This handles cases + // where getHeightAt returns a slightly different value than the server's + // spawn Z (e.g. terrain LOD, MCNK chunk boundaries, or spawn inside a + // building where floor height differs from terrain below). + if (!groundReady && elapsed >= 5.0f) { + if (auto* tm = renderer_->getTerrainManager()) { + if (tm->getLoadedTileCount() >= 4) { + groundReady = true; + LOG_WARNING("Warmup: using tile-count fallback (", tm->getLoadedTileCount(), " tiles) after ", elapsed, "s"); + } + } + } + + if (!groundReady && elapsed > 5.0f && static_cast(elapsed * 2) % 3 == 0) { + LOG_WARNING("Warmup: ground not ready at spawn (", rx, ",", ry, ",", rz, + ") after ", elapsed, "s"); + } + } + + // Exit when: (min time passed AND queues drained AND ground ready) OR hard cap + bool readyToExit = (elapsed >= kMinWarmupSeconds && idleIterations >= kIdleThreshold && groundReady); + if (readyToExit || elapsed >= kMaxWarmupSeconds) { + if (elapsed >= kMaxWarmupSeconds && !groundReady) { + LOG_WARNING("Warmup hit hard cap (", kMaxWarmupSeconds, "s), ground NOT ready — may fall through world"); + } else if (elapsed >= kMaxWarmupSeconds) { + LOG_WARNING("Warmup hit hard cap (", kMaxWarmupSeconds, "s), entering world with pending work"); + } + break; + } + + const float t = std::clamp(elapsed / kMaxWarmupSeconds, 0.0f, 1.0f); + showProgress("Finalizing world sync...", 0.97f + t * 0.025f); + SDL_Delay(16); + } + } + + // Start intro pan right before entering gameplay so it's visible after loading. + if (renderer_->getCameraController()) { + renderer_->getCameraController()->startIntroPan(2.8f, 140.0f); + } + + showProgress("Entering world...", 1.0f); + + // Ensure all GPU resources (textures, buffers, pipelines) created during + // world load are fully flushed before the first render frame. Without this, + // vkCmdBeginRenderPass can crash on NVIDIA 590.x when resources from async + // uploads haven't completed their queue operations. + if (renderer_ && renderer_->getVkContext()) { + vkDeviceWaitIdle(renderer_->getVkContext()->getDevice()); + } + + if (loadingScreenOk) { + loadingScreen.shutdown(); + } + + // Track which map we actually loaded (used by same-map teleport check). + loadedMapId_ = mapId; + + // Clear loading flag and process any deferred world entry. + // A deferred entry occurs when SMSG_NEW_WORLD arrived during our warmup + // (e.g., an area trigger in a dungeon immediately teleporting the player out). + loadingWorld_ = false; + if (pendingWorldEntry_) { + auto entry = *pendingWorldEntry_; + pendingWorldEntry_.reset(); + LOG_WARNING("Processing deferred world entry: map ", entry.mapId); + app_.worldEntryMovementGraceTimer_ = 2.0f; + app_.taxiLandingClampTimer_ = 0.0f; + app_.lastTaxiFlight_ = false; + // Recursive call — sets loadedMapId_ and IN_GAME state for the final map. + loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); + return; // The recursive call handles setState(IN_GAME). + } + + // Only enter IN_GAME when this is the final map (no deferred entry pending). + app_.setState(AppState::IN_GAME); + + // Load addons once per session on first world entry + if (addonManager_ && !app_.addonsLoaded_) { + // Set character name for per-character SavedVariables + if (gameHandler_) { + const std::string& charName = gameHandler_->lookupName(gameHandler_->getPlayerGuid()); + if (!charName.empty()) { + addonManager_->setCharacterName(charName); + } else { + // Fallback: find name from character list + for (const auto& c : gameHandler_->getCharacters()) { + if (c.guid == gameHandler_->getPlayerGuid()) { + addonManager_->setCharacterName(c.name); + break; + } + } + } + } + addonManager_->loadAllAddons(); + app_.addonsLoaded_ = true; + addonManager_->fireEvent("VARIABLES_LOADED"); + addonManager_->fireEvent("PLAYER_LOGIN"); + addonManager_->fireEvent("PLAYER_ENTERING_WORLD"); + } else if (addonManager_ && app_.addonsLoaded_) { + // Subsequent world entries (e.g. teleport, instance entry) + addonManager_->fireEvent("PLAYER_ENTERING_WORLD"); + } +} + +void WorldLoader::startWorldPreload(uint32_t mapId, const std::string& mapName, + float serverX, float serverY) { + cancelWorldPreload(); + if (!assetManager_ || !assetManager_->isInitialized() || mapName.empty()) return; + + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, 0.0f)); + auto [tileX, tileY] = core::coords::canonicalToTile(canonical.x, canonical.y); + + worldPreload_ = std::make_unique(); + worldPreload_->mapId = mapId; + worldPreload_->mapName = mapName; + worldPreload_->centerTileX = tileX; + worldPreload_->centerTileY = tileY; + + LOG_INFO("World preload: starting for map '", mapName, "' tile [", tileX, ",", tileY, "]"); + + // Build list of tiles to preload (radius 1 = 3x3 = 9 tiles, matching load screen) + struct TileJob { int x, y; }; + auto jobs = std::make_shared>(); + // Center tile first (most important) + jobs->push_back({tileX, tileY}); + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) continue; + int tx = tileX + dx, ty = tileY + dy; + if (tx < 0 || tx > 63 || ty < 0 || ty > 63) continue; + jobs->push_back({tx, ty}); + } + } + + // Spawn worker threads (one per tile for maximum parallelism) + auto cancelFlag = &worldPreload_->cancel; + auto* am = assetManager_; + std::string mn = mapName; + + int numWorkers = std::min(static_cast(jobs->size()), 4); + auto nextJob = std::make_shared>(0); + + for (int w = 0; w < numWorkers; w++) { + worldPreload_->workers.emplace_back([am, mn, jobs, nextJob, cancelFlag]() { + while (!cancelFlag->load(std::memory_order_relaxed)) { + int idx = nextJob->fetch_add(1, std::memory_order_relaxed); + if (idx >= static_cast(jobs->size())) break; + + int tx = (*jobs)[idx].x; + int ty = (*jobs)[idx].y; + + // Read ADT file (warms file cache) + std::string adtPath = "World\\Maps\\" + mn + "\\" + mn + "_" + + std::to_string(tx) + "_" + std::to_string(ty) + ".adt"; + am->readFile(adtPath); + if (cancelFlag->load(std::memory_order_relaxed)) break; + + // Read obj0 variant + std::string objPath = "World\\Maps\\" + mn + "\\" + mn + "_" + + std::to_string(tx) + "_" + std::to_string(ty) + "_obj0.adt"; + am->readFile(objPath); + } + LOG_DEBUG("World preload worker finished"); + }); + } +} + +void WorldLoader::cancelWorldPreload() { + if (!worldPreload_) return; + worldPreload_->cancel.store(true, std::memory_order_relaxed); + for (auto& t : worldPreload_->workers) { + if (t.joinable()) t.join(); + } + LOG_INFO("World preload: cancelled (map=", worldPreload_->mapName, + " tile=[", worldPreload_->centerTileX, ",", worldPreload_->centerTileY, "])"); + worldPreload_.reset(); +} + +void WorldLoader::saveLastWorldInfo(uint32_t mapId, const std::string& mapName, + float serverX, float serverY) { +#ifdef _WIN32 + const char* base = std::getenv("APPDATA"); + std::string dir = base ? std::string(base) + "\\wowee" : "."; +#else + const char* home = std::getenv("HOME"); + std::string dir = home ? std::string(home) + "/.wowee" : "."; +#endif + std::filesystem::create_directories(dir); + std::ofstream f(dir + "/last_world.cfg"); + if (f) { + f << mapId << "\n" << mapName << "\n" << serverX << "\n" << serverY << "\n"; + } +} + +WorldLoader::LastWorldInfo WorldLoader::loadLastWorldInfo() const { +#ifdef _WIN32 + const char* base = std::getenv("APPDATA"); + std::string dir = base ? std::string(base) + "\\wowee" : "."; +#else + const char* home = std::getenv("HOME"); + std::string dir = home ? std::string(home) + "/.wowee" : "."; +#endif + LastWorldInfo info; + std::ifstream f(dir + "/last_world.cfg"); + if (!f) return info; + std::string line; + try { + if (std::getline(f, line)) info.mapId = static_cast(std::stoul(line)); + if (std::getline(f, line)) info.mapName = line; + if (std::getline(f, line)) info.x = std::stof(line); + if (std::getline(f, line)) info.y = std::stof(line); + } catch (...) { + LOG_WARNING("Malformed last_world.cfg, ignoring saved position"); + return info; + } + info.valid = !info.mapName.empty(); + return info; +} + +} // namespace core +} // namespace wowee