diff --git a/CMakeLists.txt b/CMakeLists.txt index 14f55887..1f33a0a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -284,6 +284,7 @@ set(WOWEE_SOURCES src/pipeline/m2_loader.cpp src/pipeline/wmo_loader.cpp src/pipeline/adt_loader.cpp + src/pipeline/wdt_loader.cpp src/pipeline/dbc_layout.cpp src/pipeline/terrain_mesh.cpp @@ -401,6 +402,7 @@ set(WOWEE_HEADERS include/pipeline/m2_loader.hpp include/pipeline/wmo_loader.hpp include/pipeline/adt_loader.hpp + include/pipeline/wdt_loader.hpp include/pipeline/dbc_loader.hpp include/pipeline/terrain_mesh.hpp diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index e21c8085..ebee07e1 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -321,6 +321,10 @@ public: // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); + // Battleground + bool hasPendingBgInvite() const; + void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + // Logout commands void requestLogout(); void cancelLogout(); @@ -1189,8 +1193,13 @@ private: void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set); void handleMoveKnockBack(network::Packet& packet); + // ---- Area trigger detection ---- + void loadAreaTriggerDbc(); + void checkAreaTriggers(); + // ---- Arena / Battleground handlers ---- void handleBattlefieldStatus(network::Packet& packet); + void handleInstanceDifficulty(network::Packet& packet); void handleArenaTeamCommandResult(network::Packet& packet); void handleArenaTeamQueryResponse(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); @@ -1477,12 +1486,40 @@ private: std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; + + // ---- Area trigger detection ---- + struct AreaTriggerEntry { + uint32_t id = 0; + uint32_t mapId = 0; + float x = 0, y = 0, z = 0; // canonical WoW coords (converted from DBC) + float radius = 0; + float boxLength = 0, boxWidth = 0, boxHeight = 0; + float boxYaw = 0; + }; + bool areaTriggerDbcLoaded_ = false; + std::vector areaTriggers_; + std::unordered_set activeAreaTriggers_; // triggers player is currently inside + float areaTriggerCheckTimer_ = 0.0f; + float castTimeTotal = 0.0f; std::array actionBar{}; std::vector playerAuras; std::vector targetAuras; uint64_t petGuid_ = 0; + // ---- Battleground queue state ---- + struct BgQueueSlot { + uint32_t queueSlot = 0; + uint32_t bgTypeId = 0; + uint8_t arenaType = 0; + uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress + }; + std::array bgQueues_{}; + + // Instance difficulty + uint32_t instanceDifficulty_ = 0; + bool instanceIsHeroic_ = false; + // ---- Phase 4: Group ---- GroupListData partyData; bool pendingGroupInvite = false; diff --git a/include/pipeline/wdt_loader.hpp b/include/pipeline/wdt_loader.hpp new file mode 100644 index 00000000..c61f8bb5 --- /dev/null +++ b/include/pipeline/wdt_loader.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace pipeline { + +struct WDTInfo { + uint32_t mphdFlags = 0; + bool isWMOOnly() const { return mphdFlags & 0x01; } // WDTF_GLOBAL_WMO + + std::string rootWMOPath; // from MWMO chunk (null-terminated string) + + // MODF placement (only valid for WMO-only maps): + float position[3] = {}; // ADT placement space coords + float rotation[3] = {}; // degrees + uint16_t flags = 0; + uint16_t doodadSet = 0; +}; + +WDTInfo parseWDT(const std::vector& data); + +} // namespace pipeline +} // namespace wowee diff --git a/src/core/application.cpp b/src/core/application.cpp index 7e75ede3..fa93bc8a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -32,6 +32,7 @@ #include #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" +#include "pipeline/wdt_loader.hpp" #include "pipeline/dbc_loader.hpp" #include "ui/ui_manager.hpp" #include "auth/auth_handler.hpp" @@ -3185,6 +3186,59 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float 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.) + if (loadedMapId_ != 0xFFFFFFFF) { + LOG_INFO("Map change: cleaning up old map ", loadedMapId_, " before loading map ", mapId); + + // Clear entity instances from old map + creatureInstances_.clear(); + creatureModelIds_.clear(); + creatureRenderPosCache_.clear(); + creatureWeaponsAttached_.clear(); + creatureWeaponAttachAttempts_.clear(); + deadCreatureGuids_.clear(); + nonRenderableCreatureDisplayIds_.clear(); + creaturePermanentFailureGuids_.clear(); + + pendingCreatureSpawns_.clear(); + pendingCreatureSpawnGuids_.clear(); + creatureSpawnRetryCounts_.clear(); + + playerInstances_.clear(); + onlinePlayerAppearance_.clear(); + pendingOnlinePlayerEquipment_.clear(); + deferredEquipmentQueue_.clear(); + pendingPlayerSpawns_.clear(); + pendingPlayerSpawnGuids_.clear(); + + gameObjectInstances_.clear(); + pendingGameObjectSpawns_.clear(); + pendingTransportMoves_.clear(); + pendingTransportDoodadBatches_.clear(); + + if (renderer) { + // Clear all world geometry from old map + if (auto* wmo = renderer->getWMORenderer()) { + wmo->clearInstances(); + } + if (auto* m2 = renderer->getM2Renderer()) { + m2->clear(); + } + 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(); + } + + // 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) { @@ -3310,135 +3364,341 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float showProgress("Loading terrain...", 0.20f); - // 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 - bool 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"); + // 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); + } } - // Character renderer is created inside loadTestTerrain(), so spawn the - // player model now that the renderer actually exists. - if (!playerCharacterSpawned) { - spawnPlayerCharacter(); - loadEquippedWeapons(); - } + bool terrainOk = false; - showProgress("Streaming terrain tiles...", 0.35f); + if (isWMOOnlyMap) { + // ---- WMO-only map (dungeon/raid/BG): load root WMO directly ---- + LOG_INFO("WMO-only map detected — loading root WMO: ", wdtInfo.rootWMOPath); + showProgress("Loading instance geometry...", 0.25f); - // Wait for surrounding terrain tiles to stream in - if (terrainOk && renderer->getTerrainManager() && renderer->getCamera()) { - auto* terrainMgr = renderer->getTerrainManager(); - auto* camera = renderer->getCamera(); + // Still call loadTestTerrain with a dummy path to initialize all renderers + // (terrain, WMO, M2, character). The terrain load will fail gracefully. + auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y); + std::string dummyAdtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + + std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt"; + renderer->loadTestTerrain(assetManager.get(), dummyAdtPath); - // Trigger tile streaming for surrounding area - terrainMgr->update(*camera, 1.0f); + // Disable terrain streaming — no ADT tiles for WMO-only maps + if (renderer->getTerrainManager()) { + renderer->getTerrainManager()->setStreamingEnabled(false); + } - 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; + // Spawn player character now that renderers are initialized + if (!playerCharacterSpawned) { + spawnPlayerCharacter(); + loadEquippedWeapons(); + } - // 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); + // Load the root WMO + auto* wmoRenderer = renderer->getWMORenderer(); + if (wmoRenderer) { + std::vector wmoData = assetManager->readFile(wdtInfo.rootWMOPath); + if (!wmoData.empty()) { + pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); + + 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 = std::tolower(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"); } - } - // Trigger new streaming — enqueue tiles for background workers - terrainMgr->update(*camera, 0.016f); + // WMO-only maps: MODF position is at world origin (always 0,0,0 in practice). + // Unlike ADT MODF which uses placement space, WMO-only maps place the WMO + // directly in render coordinates with no offset or yaw bias. + glm::vec3 wmoPos(0.0f); + glm::vec3 wmoRot(0.0f); + if (wdtInfo.position[0] != 0.0f || wdtInfo.position[1] != 0.0f || wdtInfo.position[2] != 0.0f) { + // Non-zero placement — convert from ADT space (rare/never happens, but be safe) + wmoPos = core::coords::adtToWorld( + wdtInfo.position[0], wdtInfo.position[1], wdtInfo.position[2]); + wmoRot = glm::vec3( + -wdtInfo.rotation[2] * 3.14159f / 180.0f, + -wdtInfo.rotation[0] * 3.14159f / 180.0f, + (wdtInfo.rotation[1] + 180.0f) * 3.14159f / 180.0f + ); + } - // Process ONE tile per iteration so loading screen updates after each - terrainMgr->processOneReadyTile(); + 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_INFO("Instance WMO loaded: modelId=", wmoModelId, + " instanceId=", instanceId, + " pos=(", wmoPos.x, ", ", wmoPos.y, ", ", wmoPos.z, ")"); - 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; + // 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]; - auto now = std::chrono::high_resolution_clock::now(); - float elapsedSec = std::chrono::duration(now - startTime).count(); + 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)); - 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); + 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 = std::tolower(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.y, + doodad.rotation.x, 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)); + m2Renderer->loadModel(m2Model, doodadModelId); + m2Renderer->createInstance(doodadModelId, worldPos, glm::vec3(0.0f), doodad.scale); + 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 { - snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles", - loaded, total); + LOG_WARNING("Failed to read root WMO file: ", wdtInfo.rootWMOPath); } - 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); + // 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(); } } - LOG_INFO("Online terrain streaming complete: ", terrainMgr->getLoadedTileCount(), " tiles loaded"); + 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/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); + // 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"); + } + + // Character renderer is created inside loadTestTerrain(), so spawn the + // player model now that the renderer actually exists. + if (!playerCharacterSpawned) { + spawnPlayerCharacter(); + 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(); + + // 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 loading screen updates after each + 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"); + + // Load/precompute collision cache + if (renderer->getWMORenderer()) { + showProgress("Building collision cache...", 0.88f); if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } - renderer->getWMORenderer()->precomputeFloorCache(); + renderer->getWMORenderer()->loadFloorCache(); + if (renderer->getWMORenderer()->getFloorCacheSize() == 0) { + showProgress("Computing walkable surfaces...", 0.90f); + if (loadingScreenOk) { loadingScreen.render(); window->swapBuffers(); } + renderer->getWMORenderer()->precomputeFloorCache(); + } } } } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ae8250d7..24f8956f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -513,6 +513,9 @@ void GameHandler::resetDbcCaches() { taxiNodes_.clear(); taxiPathEdges_.clear(); taxiPathNodes_.clear(); + areaTriggerDbcLoaded_ = false; + areaTriggers_.clear(); + activeAreaTriggers_.clear(); talentDbcLoaded_ = false; talentCache_.clear(); talentTabCache_.clear(); @@ -720,6 +723,13 @@ void GameHandler::update(float deltaTime) { timeSinceLastMoveHeartbeat_ = 0.0f; } + // Check area triggers (instance portals, tavern rests, etc.) + areaTriggerCheckTimer_ += deltaTime; + if (areaTriggerCheckTimer_ >= 0.25f) { + areaTriggerCheckTimer_ = 0.0f; + checkAreaTriggers(); + } + // Update cast timer (Phase 3) if (pendingGameObjectInteractGuid_ != 0 && (autoAttacking || autoAttackRequested_)) { @@ -2683,7 +2693,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_TRANSFER_PENDING: { // SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data uint32_t pendingMapId = packet.readUInt32(); - LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); + LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); // Optional: if remaining data, there's a transport entry + mapId if (packet.getReadPos() + 8 <= packet.getSize()) { uint32_t transportEntry = packet.readUInt32(); @@ -2750,6 +2760,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: LOG_INFO("Battleground player left"); break; + case Opcode::SMSG_INSTANCE_DIFFICULTY: + handleInstanceDifficulty(packet); + break; case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT: handleArenaTeamCommandResult(packet); break; @@ -8655,6 +8668,14 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; } + // Store queue state + if (queueSlot < bgQueues_.size()) { + bgQueues_[queueSlot].queueSlot = queueSlot; + bgQueues_[queueSlot].bgTypeId = bgTypeId; + bgQueues_[queueSlot].arenaType = arenaType; + bgQueues_[queueSlot].statusId = statusId; + } + switch (statusId) { case 0: // STATUS_NONE LOG_INFO("Battlefield status: NONE for ", bgName); @@ -8680,6 +8701,183 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { } } +bool GameHandler::hasPendingBgInvite() const { + for (const auto& slot : bgQueues_) { + if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN + } + return false; +} + +void GameHandler::acceptBattlefield(uint32_t queueSlot) { + if (state != WorldState::IN_WORLD) return; + if (!socket) return; + + // Find first WAIT_JOIN slot if no specific slot given + const BgQueueSlot* slot = nullptr; + if (queueSlot == 0xFFFFFFFF) { + for (const auto& s : bgQueues_) { + if (s.statusId == 2) { slot = &s; break; } + } + } else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) { + slot = &bgQueues_[queueSlot]; + } + + if (!slot) { + addSystemChatMessage("No battleground invitation pending."); + return; + } + + // CMSG_BATTLEFIELD_PORT: arenaType(1) + unk(1) + bgTypeId(4) + unk(2) + action(1) = 9 bytes + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); + pkt.writeUInt8(slot->arenaType); + pkt.writeUInt8(0x00); + pkt.writeUInt32(slot->bgTypeId); + pkt.writeUInt16(0x0000); + pkt.writeUInt8(1); // 1 = accept, 0 = decline + + socket->send(pkt); + + addSystemChatMessage("Accepting battleground invitation..."); + LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); +} + +void GameHandler::handleInstanceDifficulty(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 8) return; + instanceDifficulty_ = packet.readUInt32(); + uint32_t isHeroic = packet.readUInt32(); + instanceIsHeroic_ = (isHeroic != 0); + LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); +} + +void GameHandler::loadAreaTriggerDbc() { + if (areaTriggerDbcLoaded_) return; + areaTriggerDbcLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("AreaTrigger.dbc"); + if (!dbc || !dbc->isLoaded()) { + LOG_WARNING("Failed to load AreaTrigger.dbc"); + return; + } + + areaTriggers_.reserve(dbc->getRecordCount()); + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + AreaTriggerEntry at; + at.id = dbc->getUInt32(i, 0); + at.mapId = dbc->getUInt32(i, 1); + // DBC stores positions in server/wire format (X=west, Y=north) — swap to canonical + at.x = dbc->getFloat(i, 3); // canonical X (north) = DBC field 3 (Y_wire) + at.y = dbc->getFloat(i, 2); // canonical Y (west) = DBC field 2 (X_wire) + at.z = dbc->getFloat(i, 4); + at.radius = dbc->getFloat(i, 5); + at.boxLength = dbc->getFloat(i, 6); + at.boxWidth = dbc->getFloat(i, 7); + at.boxHeight = dbc->getFloat(i, 8); + at.boxYaw = dbc->getFloat(i, 9); + areaTriggers_.push_back(at); + } + + LOG_WARNING("Loaded ", areaTriggers_.size(), " area triggers from AreaTrigger.dbc"); +} + +void GameHandler::checkAreaTriggers() { + if (state != WorldState::IN_WORLD || !socket) return; + if (onTaxiFlight_ || taxiClientActive_) return; + + loadAreaTriggerDbc(); + if (areaTriggers_.empty()) return; + + const float px = movementInfo.x; + const float py = movementInfo.y; + const float pz = movementInfo.z; + + // Debug: log player position periodically to verify trigger proximity + static int debugCounter = 0; + if (++debugCounter >= 4) { // every ~1s at 0.25s interval + debugCounter = 0; + int mapTriggerCount = 0; + float closestDist = 999999.0f; + uint32_t closestId = 0; + float closestX = 0, closestY = 0, closestZ = 0; + for (const auto& at : areaTriggers_) { + if (at.mapId != currentMapId_) continue; + mapTriggerCount++; + float dx = px - at.x, dy = py - at.y, dz = pz - at.z; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + if (dist < closestDist) { closestDist = dist; closestId = at.id; closestX = at.x; closestY = at.y; closestZ = at.z; } + } + LOG_WARNING("AreaTrigger check: player=(", px, ", ", py, ", ", pz, + ") map=", currentMapId_, " triggers_on_map=", mapTriggerCount, + " closest=AT", closestId, " at(", closestX, ", ", closestY, ", ", closestZ, ") dist=", closestDist); + // Log AT 2173 (Stormwind tram entrance) specifically + for (const auto& at : areaTriggers_) { + if (at.id == 2173) { + float dx = px - at.x, dy = py - at.y, dz = pz - at.z; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + LOG_WARNING(" AT2173: map=", at.mapId, " pos=(", at.x, ", ", at.y, ", ", at.z, + ") r=", at.radius, " box=(", at.boxLength, ", ", at.boxWidth, ", ", at.boxHeight, ") dist=", dist); + break; + } + } + } + + for (const auto& at : areaTriggers_) { + if (at.mapId != currentMapId_) continue; + + bool inside = false; + if (at.radius > 0.0f) { + // Sphere trigger — use generous minimum radius since WMO collision + // may block the player from reaching triggers inside doorways/hallways + float effectiveRadius = std::max(at.radius, 45.0f); + float dx = px - at.x; + float dy = py - at.y; + float dz = pz - at.z; + float distSq = dx * dx + dy * dy + dz * dz; + inside = (distSq <= effectiveRadius * effectiveRadius); + } else if (at.boxLength > 0.0f || at.boxWidth > 0.0f || at.boxHeight > 0.0f) { + // Box trigger (axis-aligned or rotated) + float dx = px - at.x; + float dy = py - at.y; + float dz = pz - at.z; + + // Rotate into box-local space + float cosYaw = std::cos(-at.boxYaw); + float sinYaw = std::sin(-at.boxYaw); + float localX = dx * cosYaw - dy * sinYaw; + float localY = dx * sinYaw + dy * cosYaw; + + inside = (std::abs(localX) <= at.boxLength * 0.5f && + std::abs(localY) <= at.boxWidth * 0.5f && + std::abs(dz) <= at.boxHeight * 0.5f); + } + + if (inside) { + // Only fire once per entry (don't re-send while standing inside) + if (activeAreaTriggers_.count(at.id) == 0) { + activeAreaTriggers_.insert(at.id); + + // Move player to trigger center so the server's distance check passes + // (WMO collision may prevent the client from physically reaching the trigger) + movementInfo.x = at.x; + movementInfo.y = at.y; + movementInfo.z = at.z; + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); + + network::Packet pkt(wireOpcode(Opcode::CMSG_AREATRIGGER)); + pkt.writeUInt32(at.id); + socket->send(pkt); + LOG_WARNING("Fired CMSG_AREATRIGGER: id=", at.id, + " at (", at.x, ", ", at.y, ", ", at.z, ")"); + } + } else { + // Player left the trigger — allow re-fire on re-entry + activeAreaTriggers_.erase(at.id); + } + } +} + void GameHandler::handleArenaTeamCommandResult(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) return; uint32_t command = packet.readUInt32(); @@ -11960,7 +12158,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { float serverZ = packet.readFloat(); float orientation = packet.readFloat(); - LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId, + LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId, " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", " orient=", orientation); @@ -12032,6 +12230,8 @@ void GameHandler::handleNewWorld(network::Packet& packet) { worldStates_.clear(); worldStateMapId_ = mapId; worldStateZoneId_ = 0; + activeAreaTriggers_.clear(); + areaTriggerCheckTimer_ = -5.0f; // 5-second cooldown after map transfer stopAutoAttack(); casting = false; currentCastSpellId = 0; diff --git a/src/pipeline/wdt_loader.cpp b/src/pipeline/wdt_loader.cpp new file mode 100644 index 00000000..b6af879a --- /dev/null +++ b/src/pipeline/wdt_loader.cpp @@ -0,0 +1,110 @@ +#include "pipeline/wdt_loader.hpp" +#include "core/logger.hpp" +#include + +namespace wowee { +namespace pipeline { + +namespace { + +uint32_t readU32(const uint8_t* data, size_t offset) { + uint32_t v; + std::memcpy(&v, data + offset, 4); + return v; +} + +uint16_t readU16(const uint8_t* data, size_t offset) { + uint16_t v; + std::memcpy(&v, data + offset, 2); + return v; +} + +float readF32(const uint8_t* data, size_t offset) { + float v; + std::memcpy(&v, data + offset, 4); + return v; +} + +// Chunk magic constants (little-endian) +constexpr uint32_t MVER = 0x5245564D; // "REVM" +constexpr uint32_t MPHD = 0x4448504D; // "DHPM" +constexpr uint32_t MAIN = 0x4E49414D; // "NIAM" +constexpr uint32_t MWMO = 0x4F4D574D; // "OMWM" +constexpr uint32_t MODF = 0x46444F4D; // "FDOM" + +} // anonymous namespace + +WDTInfo parseWDT(const std::vector& data) { + WDTInfo info; + + if (data.size() < 8) { + LOG_WARNING("WDT data too small (", data.size(), " bytes)"); + return info; + } + + size_t offset = 0; + + while (offset + 8 <= data.size()) { + uint32_t magic = readU32(data.data(), offset); + uint32_t chunkSize = readU32(data.data(), offset + 4); + + if (offset + 8 + chunkSize > data.size()) { + LOG_WARNING("WDT chunk extends beyond file at offset ", offset); + break; + } + + const uint8_t* chunkData = data.data() + offset + 8; + + if (magic == MVER) { + if (chunkSize >= 4) { + uint32_t version = readU32(chunkData, 0); + LOG_DEBUG("WDT version: ", version); + } + } else if (magic == MPHD) { + if (chunkSize >= 4) { + info.mphdFlags = readU32(chunkData, 0); + LOG_DEBUG("WDT MPHD flags: 0x", std::hex, info.mphdFlags, std::dec); + } + } else if (magic == MWMO) { + // Null-terminated WMO path string(s) + if (chunkSize > 0) { + const char* str = reinterpret_cast(chunkData); + size_t len = std::strlen(str); + if (len > 0) { + info.rootWMOPath = std::string(str, len); + LOG_DEBUG("WDT root WMO: ", info.rootWMOPath); + } + } + } else if (magic == MODF) { + // MODF entry is 64 bytes (same layout as ADT MODF) + if (chunkSize >= 64) { + // nameId at offset 0 (unused for WDT — path comes from MWMO) + // uniqueId at offset 4 + info.position[0] = readF32(chunkData, 8); + info.position[1] = readF32(chunkData, 12); + info.position[2] = readF32(chunkData, 16); + info.rotation[0] = readF32(chunkData, 20); + info.rotation[1] = readF32(chunkData, 24); + info.rotation[2] = readF32(chunkData, 28); + // extents at 32-55 + info.flags = readU16(chunkData, 56); + info.doodadSet = readU16(chunkData, 58); + LOG_DEBUG("WDT MODF placement: pos=(", info.position[0], ", ", + info.position[1], ", ", info.position[2], ") rot=(", + info.rotation[0], ", ", info.rotation[1], ", ", + info.rotation[2], ") doodadSet=", info.doodadSet); + } + } + + offset += 8 + chunkSize; + } + + LOG_WARNING("WDT parse result: mphdFlags=0x", std::hex, info.mphdFlags, std::dec, + " isWMOOnly=", info.isWMOOnly(), + " rootWMO='", info.rootWMOPath, "'"); + + return info; +} + +} // namespace pipeline +} // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index ea6c5c15..07c19570 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2765,6 +2765,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { isChannelCommand = true; switchChatType = 9; } else if (cmdLower == "join") { + // /join with no args: accept pending BG invite if any + if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { + gameHandler.acceptBattlefield(); + chatInputBuffer[0] = '\0'; + return; + } // /join ChannelName [password] if (spacePos != std::string::npos) { std::string rest = command.substr(spacePos + 1);