diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ebf2404a..add60a7b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -281,6 +281,7 @@ public: Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } bool consumeOnlineEquipmentDirty() { bool d = onlineEquipDirty_; onlineEquipDirty_ = false; return d; } + void resetEquipmentDirtyTracking() { lastEquipDisplayIds_ = {}; onlineEquipDirty_ = true; } void unequipToBackpack(EquipSlot equipSlot); // Targeting diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 6ba1c4fe..87c60a66 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -171,6 +171,7 @@ public: * Get number of active instances */ uint32_t getInstanceCount() const { return instances.size(); } + size_t getLoadedModelCount() const { return loadedModels.size(); } /** * Remove models that have no instances referencing them @@ -262,6 +263,9 @@ public: */ std::optional getFloorHeight(float glX, float glY, float glZ, float* outNormalZ = nullptr) const; + /** Dump diagnostic info about WMO groups overlapping a position */ + void debugDumpGroupsAtPosition(float glX, float glY, float glZ) const; + /** * Check wall collision and adjust position * @param from Starting position diff --git a/src/core/application.cpp b/src/core/application.cpp index da49887d..1bdbecf5 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -86,12 +86,94 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { 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 369: return "DeeprunTram"; - case 530: return "Outland"; + 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 ""; } } @@ -379,6 +461,14 @@ void Application::run() { uiManager->getGameScreen().triggerDing(99); } } + // F8: Debug WMO floor at current position + else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) { + if (renderer && renderer->getWMORenderer()) { + glm::vec3 pos = renderer->getCharacterPosition(); + LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z); + } + } } } @@ -3255,6 +3345,11 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float 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(); @@ -3509,20 +3604,20 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float LOG_INFO("Loaded ", loadedGroups, " / ", wmoModel.nGroups, " WMO groups for instance"); } - // 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. + // 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(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) { - // 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 - ); } showProgress("Uploading instance geometry...", 0.70f); @@ -3530,9 +3625,30 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float 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, ")"); + 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(); @@ -3619,6 +3735,31 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float } } + // 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 ---- @@ -4960,7 +5101,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // aggressive and can make NPCs invisible (targetable but not rendered). // Keep default model geosets for online creatures until this path is made // data-accurate per display model. - static constexpr bool kEnableNpcHumanoidOverrides = false; + static constexpr bool kEnableNpcHumanoidOverrides = true; // Set geosets for humanoid NPCs based on CreatureDisplayInfoExtra if (kEnableNpcHumanoidOverrides && @@ -6360,8 +6501,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t return; } - // Freeze animation — gameobjects are static until interacted with - m2Renderer->setInstanceAnimationFrozen(instanceId, true); + // Freeze animation for static gameobjects, but let portals/effects animate + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::tolower); + bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || + lowerPath.find("portalfx") != std::string::npos || + lowerPath.find("spellportal") != std::string::npos); + if (!isAnimatedEffect) { + m2Renderer->setInstanceAnimationFrozen(instanceId, true); + } gameObjectInstances_[guid] = {modelId, instanceId, false}; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 6f02589a..e184f842 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -659,7 +659,17 @@ void CameraController::update(float deltaTime) { grounded = false; } else { - // Exiting water — give a small upward boost to help climb onto shore. + // Exiting water — boost upward to help climb onto shore/stairs. + if (wasSwimming) { + // Anchor lastGroundZ to current position so WMO floor probes + // start from a sensible height instead of stale pre-swim values. + lastGroundZ = targetPos.z; + grounded = true; // Treat as grounded so step-up budget is full + // Small upward boost to clear stair lip geometry + if (verticalVelocity < 1.5f) { + verticalVelocity = 1.5f; + } + } swimming = false; if (glm::length(movement) > 0.001f) { diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 67d486b3..88d1dd13 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -522,10 +522,18 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { wmoMatrix = glm::rotate(wmoMatrix, rot.y, glm::vec3(0, 1, 0)); wmoMatrix = glm::rotate(wmoMatrix, rot.x, glm::vec3(1, 0, 0)); - const auto& doodadSet = wmoModel.doodadSets[0]; + // Load doodads from set 0 (global) + placement-specific set + std::vector setsToLoad = {0}; + if (placement.doodadSet > 0 && placement.doodadSet < wmoModel.doodadSets.size()) { + setsToLoad.push_back(placement.doodadSet); + } + std::unordered_set loadedDoodadIndices; + for (uint32_t setIdx : setsToLoad) { + const auto& doodadSet = wmoModel.doodadSets[setIdx]; for (uint32_t di = 0; di < doodadSet.count; di++) { uint32_t doodadIdx = doodadSet.startIndex + di; if (doodadIdx >= wmoModel.doodads.size()) break; + if (!loadedDoodadIndices.insert(doodadIdx).second) continue; const auto& doodad = wmoModel.doodads[doodadIdx]; auto nameIt = wmoModel.doodadNames.find(doodad.nameIndex); @@ -623,6 +631,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { doodadReady.modelMatrix = worldMatrix; pending->wmoDoodads.push_back(std::move(doodadReady)); } + } } PendingTile::WMOReady ready; @@ -1311,6 +1320,7 @@ void TerrainManager::unloadAll() { pendingTiles.clear(); finalizingTiles_.clear(); placedDoodadIds.clear(); + placedWmoIds.clear(); LOG_INFO("Unloading all terrain tiles"); loadedTiles.clear(); @@ -1358,6 +1368,7 @@ void TerrainManager::softReset() { pendingTiles.clear(); finalizingTiles_.clear(); placedDoodadIds.clear(); + placedWmoIds.clear(); // Clear tile cache — keys are (x,y) without map name, so stale entries from // a different map with overlapping coordinates would produce wrong geometry. diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index b71f0287..7c2558d5 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -533,10 +533,8 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { // "city01" etc are exterior cityscape shells in large WMOs isCityShell = (lower.find("city") == 0 && lower.size() <= 8); } - // Flag 0x80 on INDOOR groups in large WMOs = interior cathedral shell - bool hasFlag80 = (wmoGroup.flags & 0x80) != 0; bool isIndoor = (wmoGroup.flags & 0x2000) != 0; - if ((nVerts < 100 && isLargeWmo) || (alwaysDraw && nVerts < 5000) || (isFacade && isLargeWmo) || (isCityShell && isLargeWmo) || (hasFlag80 && isIndoor && isLargeWmo)) { + if ((nVerts < 100 && isLargeWmo && !isIndoor) || (alwaysDraw && nVerts < 5000 && isLargeWmo && !isIndoor) || (isFacade && isLargeWmo && !isIndoor) || (isCityShell && !isIndoor && isLargeWmo)) { resources.isLOD = true; } modelData.groups.push_back(resources); @@ -954,9 +952,7 @@ void WMORenderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tra } } - // NOTE: Don't rebuild spatial index on every transform update - causes flickering - // Spatial grid is only used for collision queries, render iterates all instances - // rebuildSpatialIndex(); + rebuildSpatialIndex(); } void WMORenderer::addDoodadToInstance(uint32_t instanceId, uint32_t m2InstanceId, const glm::mat4& localTransform) { @@ -1228,8 +1224,11 @@ void WMORenderer::precomputeFloorCache() { samplesChecked++; - // getFloorHeight will compute and cache the result - getFloorHeight(sampleX, sampleY, refZ); + // Query floor height and store result in the precomputed grid + auto h = getFloorHeight(sampleX, sampleY, refZ); + if (h) { + precomputedFloorGrid[key] = *h; + } } } } @@ -2902,6 +2901,90 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ return bestFloor; } +void WMORenderer::debugDumpGroupsAtPosition(float glX, float glY, float glZ) const { + LOG_WARNING("=== WMO Floor Debug at render(", glX, ", ", glY, ", ", glZ, ") ==="); + + glm::vec3 worldOrigin(glX, glY, glZ + 500.0f); + glm::vec3 worldDir(0.0f, 0.0f, -1.0f); + + int totalInstancesChecked = 0; + int totalGroupsOverlapping = 0; + int totalFloorHits = 0; + + for (const auto& instance : instances) { + auto it = loadedModels.find(instance.modelId); + if (it == loadedModels.end()) continue; + const ModelData& model = it->second; + + // Check instance world bounds + if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || + glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || + glZ < instance.worldBoundsMin.z - 20.0f || glZ > instance.worldBoundsMax.z + 20.0f) { + continue; + } + totalInstancesChecked++; + LOG_WARNING(" Instance modelId=", instance.modelId, + " worldBounds=(", instance.worldBoundsMin.x, ",", instance.worldBoundsMin.y, ",", instance.worldBoundsMin.z, + ")-(", instance.worldBoundsMax.x, ",", instance.worldBoundsMax.y, ",", instance.worldBoundsMax.z, + ") groups=", model.groups.size()); + + glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(worldOrigin, 1.0f)); + glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(worldDir, 0.0f))); + + for (size_t gi = 0; gi < model.groups.size(); ++gi) { + // Check world-space group bounds + if (gi < instance.worldGroupBounds.size()) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (glX < gMin.x || glX > gMax.x || + glY < gMin.y || glY > gMax.y) { + continue; + } + } + totalGroupsOverlapping++; + const auto& group = model.groups[gi]; + + // Count floor triangles in this group under the player + int floorTris = 0; + float bestHitZ = -999999.0f; + const auto& verts = group.collisionVertices; + const auto& indices = group.collisionIndices; + for (size_t ti = 0; ti + 2 < indices.size(); ti += 3) { + const glm::vec3& v0 = verts[indices[ti]]; + const glm::vec3& v1 = verts[indices[ti + 1]]; + const glm::vec3& v2 = verts[indices[ti + 2]]; + float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2); + if (t <= 0.0f) t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1); + if (t > 0.0f) { + glm::vec3 hitLocal = localOrigin + localDir * t; + glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f)); + floorTris++; + totalFloorHits++; + if (hitWorld.z > bestHitZ) bestHitZ = hitWorld.z; + } + } + + glm::vec3 gWorldMin(0), gWorldMax(0); + if (gi < instance.worldGroupBounds.size()) { + gWorldMin = instance.worldGroupBounds[gi].first; + gWorldMax = instance.worldGroupBounds[gi].second; + } + LOG_WARNING(" Group[", gi, "] flags=0x", std::hex, group.groupFlags, std::dec, + " verts=", group.collisionVertices.size(), + " tris=", group.collisionIndices.size()/3, + " batches=", group.mergedBatches.size(), + " isLOD=", group.isLOD, + " floorHits=", floorTris, + " bestHitZ=", bestHitZ, + " wBounds=(", gWorldMin.x, ",", gWorldMin.y, ",", gWorldMin.z, + ")-(", gWorldMax.x, ",", gWorldMax.y, ",", gWorldMax.z, ")"); + } + } + + LOG_WARNING("=== Total: ", totalInstancesChecked, " instances, ", + totalGroupsOverlapping, " overlapping groups, ", + totalFloorHits, " floor hits ==="); +} + bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3& adjustedPos, bool insideWMO) const { QueryTimer timer(&queryTimeMs, &queryCallCount); adjustedPos = to;