From 770819bef2fb049fb5c7c37c353ff583bf633880 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Fri, 29 May 2026 21:16:12 -0500 Subject: [PATCH 01/35] feat: Implement Detour navigation mesh manager and related collision queries - Added DetourNavMeshManager for managing navigation meshes. - Introduced MapCollisionQueriesReal to handle real map collision queries using DetourNavMeshManager. - Updated WorldSession to include navMeshState for creature chase movement. - Enhanced WorldSessionCombat to utilize navMesh for creature movement. - Created MmapGenerator for generating navigation mesh tiles from .map data. - Updated configuration to include collision data root for pathfinding. - Added tests for new collision query implementations and creature chase movement. - Introduced mmap_generator tool for generating navmesh tiles. --- CMakeLists.txt | 11 + .../combat/CreatureChaseMovement.cpp | 79 +++++ .../combat/CreatureChaseMovement.h | 31 +- src/application/ports/IMapCollisionQueries.h | 40 +++ src/infrastructure/CMakeLists.txt | 3 + .../collision/DetourNavMeshManager.cpp | 301 ++++++++++++++++ .../collision/DetourNavMeshManager.h | 55 +++ .../collision/MapCollisionQueriesReal.cpp | 42 +++ .../collision/MapCollisionQueriesReal.h | 29 ++ .../network/sessions/WorldSession.h | 3 + .../worldsession/WorldSessionCombat.cpp | 15 +- .../world/MapCollisionQueriesStub.cpp | 21 ++ .../world/MapCollisionQueriesStub.h | 3 + src/world/WorldApplication.cpp | 10 +- .../unit/combat/CreatureChaseMovementTest.cpp | 106 ++++++ .../MapCollisionQueriesStubTests.cpp | 30 ++ tools/vmap/CMakeLists.txt | 1 + tools/vmap/mmap_generator/CMakeLists.txt | 15 + tools/vmap/mmap_generator/MmapGenerator.cpp | 335 ++++++++++++++++++ tools/vmap/mmap_generator/MmapGenerator.h | 60 ++++ tools/vmap/mmap_generator/main.cpp | 83 +++++ worldserver.yaml | 7 + 22 files changed, 1270 insertions(+), 10 deletions(-) create mode 100644 src/infrastructure/collision/DetourNavMeshManager.cpp create mode 100644 src/infrastructure/collision/DetourNavMeshManager.h create mode 100644 src/infrastructure/collision/MapCollisionQueriesReal.cpp create mode 100644 src/infrastructure/collision/MapCollisionQueriesReal.h create mode 100644 tools/vmap/mmap_generator/CMakeLists.txt create mode 100644 tools/vmap/mmap_generator/MmapGenerator.cpp create mode 100644 tools/vmap/mmap_generator/MmapGenerator.h create mode 100644 tools/vmap/mmap_generator/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index afe5807..ec48ee6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,6 +137,17 @@ FetchContent_MakeAvailable(yaml-cpp) FetchContent_Declare(lua_cmake URL https://github.com/walterschell/Lua/archive/refs/tags/v5.4.7.zip) FetchContent_MakeAvailable(lua_cmake) +# RecastNavigation (Recast + Detour for navmesh pathfinding) +FetchContent_Declare(recastnavigation + GIT_REPOSITORY https://github.com/recastnavigation/recastnavigation.git + GIT_TAG 9a0c2173b6a121db4414d0e4cf8899aeaa7faed7 +) +set(RECASTNAVIGATION_DEMO OFF CACHE BOOL "" FORCE) +set(RECASTNAVIGATION_TESTS OFF CACHE BOOL "" FORCE) +set(RECASTNAVIGATION_STATIC ON CACHE BOOL "" FORCE) +set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(recastnavigation) + # MariaDB Connector/C++ FetchContent_Declare(mariadb-connector-cpp URL https://github.com/mariadb-corporation/mariadb-connector-cpp/archive/refs/tags/1.1.7.zip) FetchContent_Populate(mariadb-connector-cpp) diff --git a/src/application/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index d2bbe29..041a5cc 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -1,11 +1,13 @@ #include "CreatureChaseMovement.h" #include +#include #include using Firelands::MOVEMENTFLAG_FORWARD; using Firelands::MOVEMENTFLAG_NONE; using Firelands::MovementInfo; +using Firelands::Vec3; namespace application::combat { @@ -124,4 +126,81 @@ bool ChaseTargetRelocated(float lastX, float lastY, float lastZ, float newX, flo return (dx * dx + dy * dy + dz * dz) > thresholdSq; } +std::vector ComputeNavMeshPath(uint32_t mapId, + MovementInfo const &start, float targetX, + float targetY, float targetZ, + Firelands::IMapCollisionQueries const *collision) { + std::vector waypoints; + if (!collision) + return waypoints; + + Firelands::FindPathRequest req; + req.mapId = mapId; + req.startX = start.x; + req.startY = start.y; + req.startZ = start.z; + req.endX = targetX; + req.endY = targetY; + req.endZ = targetZ; + req.smoothPath = true; + req.allowPartialPath = true; + + auto result = collision->FindPath(req); + if (result.status == Firelands::FindPathStatus::Complete || + result.status == Firelands::FindPathStatus::Partial) { + waypoints = std::move(result.waypoints); + } + return waypoints; +} + +CreatureChaseStepResult StepCreatureAlongNavMeshPath( + MovementInfo const ¤t, float targetX, float targetY, float targetZ, + float deltaSeconds, CreatureChaseConfig const &config, + ChaseNavMeshState &state, + Firelands::IMapCollisionQueries const *collision, + uint32_t mapId) { + bool const targetRelocated = + ChaseTargetRelocated(state.lastTargetX, state.lastTargetY, + state.lastTargetZ, targetX, targetY, targetZ); + + if (targetRelocated || state.waypoints.empty()) { + state.lastTargetX = targetX; + state.lastTargetY = targetY; + state.lastTargetZ = targetZ; + state.currentWaypoint = 0; + state.waypoints.clear(); + } + + if (targetRelocated && collision) { + state.waypoints = ComputeNavMeshPath(mapId, current, targetX, targetY, targetZ, collision); + if (!state.waypoints.empty()) { + state.waypoints.push_back(Vec3{targetX, targetY, targetZ}); + state.currentWaypoint = 0; + } + } + + if (state.waypoints.empty() || state.currentWaypoint >= state.waypoints.size()) { + return StepCreatureTowardTarget(current, targetX, targetY, targetZ, + deltaSeconds, config); + } + + while (state.currentWaypoint < state.waypoints.size()) { + Vec3 const &wp = state.waypoints[state.currentWaypoint]; + float const dx = current.x - wp.x; + float const dy = current.y - wp.y; + float const distSq = dx * dx + dy * dy; + + if (distSq < 0.25f) { + ++state.currentWaypoint; + continue; + } + + auto step = StepCreatureTowardTarget(current, wp.x, wp.y, wp.z, deltaSeconds, config); + return step; + } + + return StepCreatureTowardTarget(current, targetX, targetY, targetZ, + deltaSeconds, config); +} + } // namespace application::combat diff --git a/src/application/combat/CreatureChaseMovement.h b/src/application/combat/CreatureChaseMovement.h index 1e22ded..3ff5396 100644 --- a/src/application/combat/CreatureChaseMovement.h +++ b/src/application/combat/CreatureChaseMovement.h @@ -1,12 +1,13 @@ #pragma once #include +#include +#include namespace application::combat { struct CreatureChaseConfig { float runSpeedYardsPerSec = 7.0f; - /// Halt this far from the target center (Cataclysm chase contact distance). float stopDistanceYards = 1.0f; float zBlendPerTick = 0.35f; }; @@ -17,26 +18,44 @@ struct CreatureChaseStepResult { Firelands::MovementInfo position{}; }; -/// Last point on the approach line, `stopDistanceYards` from the target center. +struct ChaseNavMeshState { + std::vector waypoints; + size_t currentWaypoint = 0; + float lastTargetX = 0.0f; + float lastTargetY = 0.0f; + float lastTargetZ = 0.0f; +}; + Firelands::MovementInfo ComputeChaseStandPosition(Firelands::MovementInfo const &from, float targetX, float targetY, float targetZ, float stopDistanceYards); -/// Advances `current` toward (`targetX`,`targetY`,`targetZ`) by `deltaSeconds`. CreatureChaseStepResult StepCreatureTowardTarget(Firelands::MovementInfo const ¤t, float targetX, float targetY, float targetZ, float deltaSeconds, CreatureChaseConfig const &config); -/// Simulates repeated `StepCreatureTowardTarget` for up to `maxDeltaSeconds` (one tick per -/// 0.2s slice). Used to build a single client spline instead of restarting animation every tick. CreatureChaseStepResult ProjectCreatureTowardTarget(Firelands::MovementInfo const ¤t, float targetX, float targetY, float targetZ, float maxDeltaSeconds, CreatureChaseConfig const &config); -/// Ref `ChaseMovementGenerator`: replan when `target->GetPosition() != _lastTargetPosition`. bool ChaseTargetRelocated(float lastX, float lastY, float lastZ, float newX, float newY, float newZ, float thresholdYards = 0.5f); +std::vector ComputeNavMeshPath( + uint32_t mapId, + Firelands::MovementInfo const &start, + float targetX, float targetY, float targetZ, + Firelands::IMapCollisionQueries const *collision); + +CreatureChaseStepResult StepCreatureAlongNavMeshPath( + Firelands::MovementInfo const ¤t, + float targetX, float targetY, float targetZ, + float deltaSeconds, + CreatureChaseConfig const &config, + ChaseNavMeshState &state, + Firelands::IMapCollisionQueries const *collision, + uint32_t mapId); + } // namespace application::combat diff --git a/src/application/ports/IMapCollisionQueries.h b/src/application/ports/IMapCollisionQueries.h index 9da25ad..1de4d5c 100644 --- a/src/application/ports/IMapCollisionQueries.h +++ b/src/application/ports/IMapCollisionQueries.h @@ -2,9 +2,40 @@ #define FIRELANDS_APPLICATION_PORTS_I_MAP_COLLISION_QUERIES_H #include +#include namespace Firelands { +struct Vec3 { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +enum class FindPathStatus : uint8_t { + Complete, + Partial, + NoPath, + NavMeshMissing +}; + +struct FindPathRequest { + uint32_t mapId = 0; + float startX = 0.0f; + float startY = 0.0f; + float startZ = 0.0f; + float endX = 0.0f; + float endY = 0.0f; + float endZ = 0.0f; + bool smoothPath = true; + bool allowPartialPath = true; +}; + +struct FindPathResult { + std::vector waypoints; + FindPathStatus status = FindPathStatus::NoPath; +}; + /// Port for mmap/vmap-backed queries (LoS, height, navmesh). Stub implementation /// returns permissive values until extracted data is wired. class IMapCollisionQueries { @@ -17,6 +48,15 @@ class IMapCollisionQueries { /// Line of sight between two world points. Stub returns true (open line). virtual bool LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, float y1, float z1) const = 0; + + /// Navmesh pathfinding. Returns waypoints from start to end following the + /// navmesh. When no navmesh data is available, returns NavMeshMissing. + virtual FindPathResult FindPath(FindPathRequest const& req) const = 0; + + /// Height at (x,y) on the collision mesh. Returns z if known, otherwise + /// returns the fallback `zHint`. + virtual float GetHeight(uint32_t mapId, float x, float y, + float zHint) const = 0; }; } // namespace Firelands diff --git a/src/infrastructure/CMakeLists.txt b/src/infrastructure/CMakeLists.txt index 878cd31..1e4b899 100644 --- a/src/infrastructure/CMakeLists.txt +++ b/src/infrastructure/CMakeLists.txt @@ -63,6 +63,8 @@ add_library(FirelandsInfrastructure STATIC network/rest/RestAuthServer.cpp scripting/LuaGameScriptHost.cpp world/MapCollisionQueriesStub.cpp + collision/DetourNavMeshManager.cpp + collision/MapCollisionQueriesReal.cpp ) set_source_files_properties(scripting/LuaGameScriptHost.cpp @@ -74,6 +76,7 @@ target_link_libraries(FirelandsInfrastructure PUBLIC Boost::thread PUBLIC nlohmann_json::nlohmann_json PUBLIC Lua::Library + PUBLIC Detour PRIVATE ZLIB::ZLIB ) target_precompile_headers(FirelandsInfrastructure PRIVATE ${PROJECT_PCH_HEADERS}) diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp new file mode 100644 index 0000000..49da279 --- /dev/null +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -0,0 +1,301 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Firelands { + +namespace { +constexpr float kTileSize = 533.33333f; +constexpr uint32_t kTileCountPerAxis = 64; + +uint32_t WorldToTileX(float x) { + return static_cast(std::floor(32.0f - (x / kTileSize))); +} + +uint32_t WorldToTileY(float y) { + return static_cast(std::floor(32.0f - (y / kTileSize))); +} + +float TileToWorldX(uint32_t tx) { + return (32.0f - static_cast(tx) - 0.5f) * kTileSize; +} + +float TileToWorldY(uint32_t ty) { + return (32.0f - static_cast(ty) - 0.5f) * kTileSize; +} + +} // namespace + +DetourNavMeshManager::DetourNavMeshManager(std::string dataRoot, + DetourNavMeshConfig config) + : _dataRoot(std::move(dataRoot)), _config(std::move(config)) {} + +DetourNavMeshManager::~DetourNavMeshManager() { + for (auto& [mapId, entry] : _loadedMaps) { + if (entry.navQuery) { + dtFreeNavMeshQuery(entry.navQuery); + } + if (entry.navMesh) { + dtFreeNavMesh(entry.navMesh); + } + } + _loadedMaps.clear(); +} + +bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, + uint32_t tileY, + dtNavMesh* navMesh) const { + std::filesystem::path mmapDir = + std::filesystem::path(_dataRoot) / "mmaps"; + std::string fileName = + (mmapDir / (std::to_string(mapId) + "_" + std::to_string(tileX) + "_" + + std::to_string(tileY) + ".mmtile")) + .string(); + + FILE* file = fopen(fileName.c_str(), "rb"); + if (!file) + return false; + + fseek(file, 0, SEEK_END); + long fileSize = ftell(file); + fseek(file, 0, SEEK_SET); + + if (fileSize < 20) { + fclose(file); + return false; + } + + std::vector data(fileSize); + size_t readSize = fread(data.data(), 1, fileSize, file); + fclose(file); + + if (readSize != static_cast(fileSize)) + return false; + + dtMeshHeader const* header = + reinterpret_cast(data.data()); + if (header->magic != DT_NAVMESH_MAGIC || header->version != DT_NAVMESH_VERSION) + return false; + + dtStatus status = navMesh->addTile(data.data(), static_cast(fileSize), + DT_TILE_FREE_DATA, 0, nullptr); + return dtStatusSucceed(status); +} + +bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { + if (_loadedMaps.count(mapId) > 0) + return true; + + dtNavMeshParams params{}; + float const navOrigin[3] = {-kTileSize * kTileCountPerAxis / 2.0f, + -kTileSize * kTileCountPerAxis / 2.0f, 0.0f}; + dtVcopy(params.orig, navOrigin); + params.tileWidth = kTileSize; + params.tileHeight = kTileSize; + params.maxTiles = static_cast(kTileCountPerAxis * kTileCountPerAxis); + params.maxPolys = 1 << 16; + + dtNavMesh* navMesh = dtAllocNavMesh(); + if (!navMesh) + return false; + + dtStatus status = navMesh->init(¶ms); + if (dtStatusFailed(status)) { + dtFreeNavMesh(navMesh); + return false; + } + + bool anyTileLoaded = false; + for (uint32_t ty = 0; ty < kTileCountPerAxis; ++ty) { + for (uint32_t tx = 0; tx < kTileCountPerAxis; ++tx) { + if (ReadMmapTile(mapId, tx, ty, navMesh)) + anyTileLoaded = true; + } + } + + if (!anyTileLoaded) { + dtFreeNavMesh(navMesh); + return false; + } + + dtNavMeshQuery* navQuery = dtAllocNavMeshQuery(); + if (!navQuery) { + dtFreeNavMesh(navMesh); + return false; + } + + status = navQuery->init(navMesh, _config.maxNavMeshNodes); + if (dtStatusFailed(status)) { + dtFreeNavMeshQuery(navQuery); + dtFreeNavMesh(navMesh); + return false; + } + + _loadedMaps[mapId] = {navMesh, navQuery}; + return true; +} + +void DetourNavMeshManager::UnloadMapNavMesh(uint32_t mapId) { + auto it = _loadedMaps.find(mapId); + if (it == _loadedMaps.end()) + return; + + if (it->second.navQuery) + dtFreeNavMeshQuery(it->second.navQuery); + if (it->second.navMesh) + dtFreeNavMesh(it->second.navMesh); + + _loadedMaps.erase(it); +} + +bool DetourNavMeshManager::IsNavMeshLoaded(uint32_t mapId) const { + return _loadedMaps.count(mapId) > 0; +} + +void DetourNavMeshManager::RemoveDuplicateWaypoints( + std::vector& waypoints) { + if (waypoints.size() <= 1) + return; + + std::vector filtered; + filtered.push_back(waypoints.front()); + + for (size_t i = 1; i < waypoints.size(); ++i) { + float const dx = waypoints[i].x - filtered.back().x; + float const dy = waypoints[i].y - filtered.back().y; + float const dz = waypoints[i].z - filtered.back().z; + float const distSq = dx * dx + dy * dy + dz * dz; + + if (distSq > 0.5f * 0.5f) + filtered.push_back(waypoints[i]); + } + + if (filtered.back().x != waypoints.back().x || + filtered.back().y != waypoints.back().y || + filtered.back().z != waypoints.back().z) { + filtered.push_back(waypoints.back()); + } + + waypoints = std::move(filtered); +} + +void DetourNavMeshManager::SmoothPath(std::vector& waypoints) { + if (waypoints.size() <= 2) + return; + + constexpr size_t kMaxIterations = 4; + constexpr float kSmoothFactor = 0.5f; + + for (size_t iter = 0; iter < kMaxIterations; ++iter) { + for (size_t i = 1; i < waypoints.size() - 1; ++i) { + waypoints[i].x = + waypoints[i].x * kSmoothFactor + + (waypoints[i - 1].x + waypoints[i + 1].x) * (0.5f - kSmoothFactor / 2.0f); + waypoints[i].y = + waypoints[i].y * kSmoothFactor + + (waypoints[i - 1].y + waypoints[i + 1].y) * (0.5f - kSmoothFactor / 2.0f); + waypoints[i].z = + waypoints[i].z * kSmoothFactor + + (waypoints[i - 1].z + waypoints[i + 1].z) * (0.5f - kSmoothFactor / 2.0f); + } + } +} + +FindPathResult DetourNavMeshManager::FindPath( + FindPathRequest const& req) const { + FindPathResult result; + result.status = FindPathStatus::NavMeshMissing; + + auto it = _loadedMaps.find(req.mapId); + if (it == _loadedMaps.end()) + return result; + + dtNavMeshQuery* navQuery = it->second.navQuery; + dtNavMesh const* navMesh = it->second.navMesh; + if (!navQuery || !navMesh) + return result; + + float const searchExtents[3] = {_config.maxSearchRadius, + _config.maxSearchRadius, + _config.maxSearchRadius}; + + dtPolyRef startRef = 0; + dtPolyRef endRef = 0; + float startNearest[3]{}; + float endNearest[3]{}; + + dtStatus status = navQuery->findNearestPoly( + &req.startX, &req.startY, &req.startZ, searchExtents, nullptr, &startRef, + startNearest); + if (dtStatusFailed(status) || startRef == 0) { + result.status = FindPathStatus::NoPath; + return result; + } + + status = navQuery->findNearestPoly(&req.endX, &req.endY, &req.endZ, + searchExtents, nullptr, &endRef, endNearest); + if (dtStatusFailed(status) || endRef == 0) { + result.status = FindPathStatus::NoPath; + return result; + } + + dtPolyRef pathPolys[256]; + int pathCount = 0; + float straightPath[256 * 3]; + unsigned char straightPathFlags[256]; + dtPolyRef straightPathPolys[256]; + int straightPathCount = 0; + + dtQueryFilter filter; + filter.setIncludeFlags(0xFFFF); + filter.setExcludeFlags(0); + + status = navQuery->findPath(startRef, endRef, startNearest, endNearest, + &filter, pathPolys, &pathCount, + _config.maxPathPolys); + if (dtStatusFailed(status) || pathCount == 0) { + result.status = FindPathStatus::NoPath; + return result; + } + + status = navQuery->findStraightPath( + startNearest, endNearest, pathPolys, pathCount, straightPath, + straightPathFlags, straightPathPolys, &straightPathCount, + _config.maxPathPolys, 0); + if (dtStatusFailed(status) || straightPathCount == 0) { + result.status = FindPathStatus::NoPath; + return result; + } + + result.waypoints.reserve(straightPathCount); + for (int i = 0; i < straightPathCount; ++i) { + result.waypoints.push_back( + Vec3{straightPath[i * 3], straightPath[i * 3 + 1], + straightPath[i * 3 + 2]}); + } + + RemoveDuplicateWaypoints(result.waypoints); + + if (req.smoothPath) + SmoothPath(result.waypoints); + + bool const reachedEnd = + (pathCount > 0 && pathPolys[pathCount - 1] == endRef); + result.status = + reachedEnd ? FindPathStatus::Complete : FindPathStatus::Partial; + + if (!reachedEnd && !req.allowPartialPath) { + result.waypoints.clear(); + result.status = FindPathStatus::NoPath; + } + + return result; +} + +} // namespace Firelands diff --git a/src/infrastructure/collision/DetourNavMeshManager.h b/src/infrastructure/collision/DetourNavMeshManager.h new file mode 100644 index 0000000..b0f3446 --- /dev/null +++ b/src/infrastructure/collision/DetourNavMeshManager.h @@ -0,0 +1,55 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_COLLISION_DETOUR_NAV_MESH_MANAGER_H +#define FIRELANDS_INFRASTRUCTURE_COLLISION_DETOUR_NAV_MESH_MANAGER_H + +#include +#include +#include +#include +#include + +class dtNavMesh; +class dtNavMeshQuery; + +namespace Firelands { + +struct DetourNavMeshConfig { + float maxSearchRadius = 100.0f; + float maxPathPolys = 256; + int maxNavMeshNodes = 4096; +}; + +class DetourNavMeshManager { +public: + explicit DetourNavMeshManager(std::string dataRoot, + DetourNavMeshConfig config = {}); + ~DetourNavMeshManager(); + + DetourNavMeshManager(DetourNavMeshManager const&) = delete; + DetourNavMeshManager& operator=(DetourNavMeshManager const&) = delete; + + bool LoadMapNavMesh(uint32_t mapId); + void UnloadMapNavMesh(uint32_t mapId); + bool IsNavMeshLoaded(uint32_t mapId) const; + bool HasDataRoot() const { return !_dataRoot.empty(); } + + FindPathResult FindPath(FindPathRequest const& req) const; + +private: + struct MapNavMesh { + dtNavMesh* navMesh = nullptr; + dtNavMeshQuery* navQuery = nullptr; + }; + + bool ReadMmapTile(uint32_t mapId, uint32_t tileX, uint32_t tileY, + dtNavMesh* navMesh) const; + static void RemoveDuplicateWaypoints(std::vector& waypoints); + static void SmoothPath(std::vector& waypoints); + + std::string _dataRoot; + DetourNavMeshConfig _config; + std::unordered_map _loadedMaps; +}; + +} // namespace Firelands + +#endif diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.cpp b/src/infrastructure/collision/MapCollisionQueriesReal.cpp new file mode 100644 index 0000000..a750c61 --- /dev/null +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -0,0 +1,42 @@ +#include + +namespace Firelands { + +MapCollisionQueriesReal::MapCollisionQueriesReal(std::string dataRoot) + : _navMeshManager(std::move(dataRoot)) {} + +bool MapCollisionQueriesReal::IsNavMeshDataAvailable(uint32_t mapId) const { + if (_navMeshManager.IsNavMeshLoaded(mapId)) + return true; + return _navMeshManager.LoadMapNavMesh(mapId); +} + +bool MapCollisionQueriesReal::LineOfSight(uint32_t /*mapId*/, float x0, + float y0, float z0, float x1, + float y1, float z1) const { + // TODO: Integrate VMapManager2 for real LoS checks + (void)x0; (void)y0; (void)z0; + (void)x1; (void)y1; (void)z1; + return true; +} + +FindPathResult MapCollisionQueriesReal::FindPath( + FindPathRequest const& req) const { + if (!_navMeshManager.IsNavMeshLoaded(req.mapId)) { + if (!_navMeshManager.LoadMapNavMesh(req.mapId)) { + FindPathResult result; + result.status = FindPathStatus::NavMeshMissing; + return result; + } + } + + return _navMeshManager.FindPath(req); +} + +float MapCollisionQueriesReal::GetHeight(uint32_t /*mapId*/, float /*x*/, + float /*y*/, float zHint) const { + // TODO: Integrate VMapManager2 for real height queries + return zHint; +} + +} // namespace Firelands diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.h b/src/infrastructure/collision/MapCollisionQueriesReal.h new file mode 100644 index 0000000..ccc1764 --- /dev/null +++ b/src/infrastructure/collision/MapCollisionQueriesReal.h @@ -0,0 +1,29 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_COLLISION_MAP_COLLISION_QUERIES_REAL_H +#define FIRELANDS_INFRASTRUCTURE_COLLISION_MAP_COLLISION_QUERIES_REAL_H + +#include +#include +#include +#include + +namespace Firelands { + +class MapCollisionQueriesReal final : public IMapCollisionQueries { +public: + explicit MapCollisionQueriesReal(std::string dataRoot); + ~MapCollisionQueriesReal() override = default; + + bool IsNavMeshDataAvailable(uint32_t mapId) const override; + bool LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, + float y1, float z1) const override; + FindPathResult FindPath(FindPathRequest const& req) const override; + float GetHeight(uint32_t mapId, float x, float y, + float zHint) const override; + +private: + mutable DetourNavMeshManager _navMeshManager; +}; + +} // namespace Firelands + +#endif diff --git a/src/infrastructure/network/sessions/WorldSession.h b/src/infrastructure/network/sessions/WorldSession.h index dc706b8..6bed0b9 100644 --- a/src/infrastructure/network/sessions/WorldSession.h +++ b/src/infrastructure/network/sessions/WorldSession.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -205,6 +206,8 @@ class WorldSession : public IAuthSession, std::optional activeSpline; /// Last chase destination used for spline replanning (ref `_lastTargetPosition`). std::optional lastChaseTargetPos; + /// Navmesh pathfinding state for creature chase movement. + application::combat::ChaseNavMeshState navMeshState; std::chrono::steady_clock::time_point nextMeleeSwingAt{}; std::chrono::steady_clock::time_point nextSpellTryAt{}; std::vector combatSpells; diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 470de0d..e276889 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -343,8 +343,19 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, application::combat::CreatureChaseConfig config{}; config.stopDistanceYards = stopDistanceYards; - auto const projected = application::combat::ProjectCreatureTowardTarget( - from, targetX, targetY, targetZ, kCreatureSplineHorizonSeconds, config); + auto const collisionQueries = WorldService::Instance().GetCollisionQueries(); + bool const useNavMesh = + collisionQueries && collisionQueries->IsNavMeshDataAvailable(map->GetMapId()); + + CreatureChaseStepResult projected; + if (useNavMesh && !returnHomeSpline) { + projected = application::combat::StepCreatureAlongNavMeshPath( + from, targetX, targetY, targetZ, kCreatureSplineHorizonSeconds, config, + runtime.navMeshState, collisionQueries.get(), map->GetMapId()); + } else { + projected = application::combat::ProjectCreatureTowardTarget( + from, targetX, targetY, targetZ, kCreatureSplineHorizonSeconds, config); + } if (!returnHomeSpline) { float const distToPlayerSq = diff --git a/src/infrastructure/world/MapCollisionQueriesStub.cpp b/src/infrastructure/world/MapCollisionQueriesStub.cpp index f1a20fd..691fff6 100644 --- a/src/infrastructure/world/MapCollisionQueriesStub.cpp +++ b/src/infrastructure/world/MapCollisionQueriesStub.cpp @@ -17,4 +17,25 @@ bool MapCollisionQueriesStub::LineOfSight(uint32_t /*mapId*/, float /*x0*/, return true; } +FindPathResult MapCollisionQueriesStub::FindPath( + FindPathRequest const& req) const { + (void)_dataRoot; + FindPathResult result; + result.status = FindPathStatus::NavMeshMissing; + + if (!_dataRoot.empty()) { + result.waypoints.push_back( + Vec3{req.startX, req.startY, req.startZ}); + result.waypoints.push_back(Vec3{req.endX, req.endY, req.endZ}); + result.status = FindPathStatus::Complete; + } + return result; +} + +float MapCollisionQueriesStub::GetHeight(uint32_t /*mapId*/, float /*x*/, + float /*y*/, float zHint) const { + (void)_dataRoot; + return zHint; +} + } // namespace Firelands diff --git a/src/infrastructure/world/MapCollisionQueriesStub.h b/src/infrastructure/world/MapCollisionQueriesStub.h index 678eb7f..aadd868 100644 --- a/src/infrastructure/world/MapCollisionQueriesStub.h +++ b/src/infrastructure/world/MapCollisionQueriesStub.h @@ -14,6 +14,9 @@ class MapCollisionQueriesStub final : public IMapCollisionQueries { bool IsNavMeshDataAvailable(uint32_t mapId) const override; bool LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, float y1, float z1) const override; + FindPathResult FindPath(FindPathRequest const& req) const override; + float GetHeight(uint32_t mapId, float x, float y, + float zHint) const override; private: std::string _dataRoot; diff --git a/src/world/WorldApplication.cpp b/src/world/WorldApplication.cpp index 4c92282..9d94717 100644 --- a/src/world/WorldApplication.cpp +++ b/src/world/WorldApplication.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -99,8 +100,13 @@ int RunWorldGameStack(std::shared_ptr tui_runtime, const std::string collisionRoot = config.GetNested({"Collision", "DataRoot"}, ""); - WorldService::Instance().SetCollisionQueries( - std::make_shared(collisionRoot)); + if (!collisionRoot.empty()) { + WorldService::Instance().SetCollisionQueries( + std::make_shared(collisionRoot)); + } else { + WorldService::Instance().SetCollisionQueries( + std::make_shared(collisionRoot)); + } std::string dbUser = config.GetNested({"Database", "User"}, "firelands"); diff --git a/tests/unit/combat/CreatureChaseMovementTest.cpp b/tests/unit/combat/CreatureChaseMovementTest.cpp index 6dc4c40..e3355c0 100644 --- a/tests/unit/combat/CreatureChaseMovementTest.cpp +++ b/tests/unit/combat/CreatureChaseMovementTest.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -12,6 +13,28 @@ Firelands::MovementInfo MakePos(float x, float y, float z) { return m; } +class MockCollisionQueries : public Firelands::IMapCollisionQueries { +public: + bool IsNavMeshDataAvailable(uint32_t) const override { return navMeshAvailable; } + bool LineOfSight(uint32_t, float, float, float, float, float, float) const override { + return true; + } + Firelands::FindPathResult FindPath(Firelands::FindPathRequest const&) const override { + Firelands::FindPathResult result; + if (!navMeshAvailable) { + result.status = Firelands::FindPathStatus::NavMeshMissing; + return result; + } + result.status = Firelands::FindPathStatus::Complete; + result.waypoints.push_back({0.0f, 0.0f, 0.0f}); + result.waypoints.push_back({5.0f, 0.0f, 0.0f}); + result.waypoints.push_back({10.0f, 0.0f, 0.0f}); + return result; + } + float GetHeight(uint32_t, float, float, float zHint) const override { return zHint; } + bool navMeshAvailable = false; +}; + } // namespace TEST(CreatureChaseMovementTest, StepsTowardTargetAtConfiguredSpeed) { @@ -65,3 +88,86 @@ TEST(CreatureChaseMovementTest, StandPositionIsStopDistanceFromTarget) { EXPECT_NEAR(stand.x, 1.0f, 0.01f); EXPECT_NEAR(stand.y, 0.f, 0.01f); } + +TEST(CreatureChaseMovementTest, ComputeNavMeshPathReturnsEmptyWhenNoCollision) { + auto const waypoints = application::combat::ComputeNavMeshPath( + 0, MakePos(0, 0, 0), 10, 0, 0, nullptr); + EXPECT_TRUE(waypoints.empty()); +} + +TEST(CreatureChaseMovementTest, ComputeNavMeshPathReturnsEmptyWhenNavMeshMissing) { + MockCollisionQueries mock; + mock.navMeshAvailable = false; + auto const waypoints = application::combat::ComputeNavMeshPath( + 0, MakePos(0, 0, 0), 10, 0, 0, &mock); + EXPECT_TRUE(waypoints.empty()); +} + +TEST(CreatureChaseMovementTest, ComputeNavMeshPathReturnsWaypointsWhenAvailable) { + MockCollisionQueries mock; + mock.navMeshAvailable = true; + auto const waypoints = application::combat::ComputeNavMeshPath( + 0, MakePos(0, 0, 0), 10, 0, 0, &mock); + ASSERT_EQ(waypoints.size(), 3u); + EXPECT_FLOAT_EQ(waypoints[0].x, 0.0f); + EXPECT_FLOAT_EQ(waypoints[2].x, 10.0f); +} + +TEST(CreatureChaseMovementTest, StepAlongNavMeshAdvancesToFirstWaypoint) { + MockCollisionQueries mock; + mock.navMeshAvailable = true; + application::combat::CreatureChaseConfig config{}; + config.runSpeedYardsPerSec = 10.0f; + config.stopDistanceYards = 1.0f; + + application::combat::ChaseNavMeshState state; + state.lastTargetX = 10.0f; + state.lastTargetY = 0.0f; + state.lastTargetZ = 0.0f; + state.waypoints.push_back(Firelands::Vec3{0.0f, 0.0f, 0.0f}); + state.waypoints.push_back(Firelands::Vec3{5.0f, 0.0f, 0.0f}); + state.waypoints.push_back(Firelands::Vec3{10.0f, 0.0f, 0.0f}); + state.currentWaypoint = 1; + + auto const result = application::combat::StepCreatureAlongNavMeshPath( + MakePos(0.0f, 0.0f, 0.0f), 10.0f, 0.0f, 0.0f, 0.5f, config, state, &mock, 0u); + + EXPECT_TRUE(result.moved); + EXPECT_GT(result.position.x, 0.0f); + EXPECT_LT(result.position.x, 6.0f); +} + +TEST(CreatureChaseMovementTest, StepAlongNavMeshFallsBackToDirectWhenNoWaypoints) { + application::combat::CreatureChaseConfig config{}; + config.runSpeedYardsPerSec = 10.0f; + config.stopDistanceYards = 2.0f; + + application::combat::ChaseNavMeshState state; + auto const result = application::combat::StepCreatureAlongNavMeshPath( + MakePos(0.0f, 0.0f, 0.0f), 20.0f, 0.0f, 0.0f, 0.5f, config, state, nullptr, 0u); + + EXPECT_TRUE(result.moved); + EXPECT_NEAR(result.position.x, 5.0f, 0.05f); +} + +TEST(CreatureChaseMovementTest, StepAlongNavMeshReplansWhenTargetRelocated) { + MockCollisionQueries mock; + mock.navMeshAvailable = true; + application::combat::CreatureChaseConfig config{}; + config.runSpeedYardsPerSec = 10.0f; + config.stopDistanceYards = 1.0f; + + application::combat::ChaseNavMeshState state; + state.lastTargetX = 0.0f; + state.lastTargetY = 0.0f; + state.lastTargetZ = 0.0f; + state.waypoints.push_back(Firelands::Vec3{0.0f, 0.0f, 0.0f}); + state.waypoints.push_back(Firelands::Vec3{5.0f, 0.0f, 0.0f}); + state.currentWaypoint = 1; + + auto const result = application::combat::StepCreatureAlongNavMeshPath( + MakePos(0.0f, 0.0f, 0.0f), 50.0f, 0.0f, 0.0f, 0.5f, config, state, &mock, 0u); + + EXPECT_TRUE(result.moved); + EXPECT_EQ(state.lastTargetX, 50.0f); +} diff --git a/tests/unit/infrastructure/MapCollisionQueriesStubTests.cpp b/tests/unit/infrastructure/MapCollisionQueriesStubTests.cpp index a165cb9..8870a67 100644 --- a/tests/unit/infrastructure/MapCollisionQueriesStubTests.cpp +++ b/tests/unit/infrastructure/MapCollisionQueriesStubTests.cpp @@ -15,3 +15,33 @@ TEST(MapCollisionQueriesStub, NavMeshUnavailableWhenDataRootEmpty) { MapCollisionQueriesStub withData("/tmp/maps"); EXPECT_TRUE(withData.IsNavMeshDataAvailable(0)); } + +TEST(MapCollisionQueriesStub, FindPathReturnsNavMeshMissingWhenEmpty) { + MapCollisionQueriesStub stub(""); + FindPathRequest req; + req.mapId = 0; + req.startX = 0; req.startY = 0; req.startZ = 0; + req.endX = 10; req.endY = 10; req.endZ = 0; + auto const result = stub.FindPath(req); + EXPECT_EQ(result.status, FindPathStatus::NavMeshMissing); + EXPECT_TRUE(result.waypoints.empty()); +} + +TEST(MapCollisionQueriesStub, FindPathReturnsDirectLineWhenDataRootSet) { + MapCollisionQueriesStub stub("/data/maps"); + FindPathRequest req; + req.mapId = 0; + req.startX = 0; req.startY = 0; req.startZ = 0; + req.endX = 10; req.endY = 0; req.endZ = 0; + auto const result = stub.FindPath(req); + EXPECT_EQ(result.status, FindPathStatus::Complete); + ASSERT_EQ(result.waypoints.size(), 2u); + EXPECT_FLOAT_EQ(result.waypoints[0].x, 0.0f); + EXPECT_FLOAT_EQ(result.waypoints[1].x, 10.0f); +} + +TEST(MapCollisionQueriesStub, GetHeightReturnsHint) { + MapCollisionQueriesStub stub(""); + EXPECT_FLOAT_EQ(stub.GetHeight(0, 100.0f, 200.0f, 50.0f), 50.0f); + EXPECT_FLOAT_EQ(stub.GetHeight(0, 100.0f, 200.0f, -10.0f), -10.0f); +} diff --git a/tools/vmap/CMakeLists.txt b/tools/vmap/CMakeLists.txt index 37dc017..a081d4a 100644 --- a/tools/vmap/CMakeLists.txt +++ b/tools/vmap/CMakeLists.txt @@ -2,3 +2,4 @@ add_subdirectory(common) add_subdirectory(map_extractor) add_subdirectory(vmap4_extractor) add_subdirectory(vmap4_assembler) +add_subdirectory(mmap_generator) diff --git a/tools/vmap/mmap_generator/CMakeLists.txt b/tools/vmap/mmap_generator/CMakeLists.txt new file mode 100644 index 0000000..76a4312 --- /dev/null +++ b/tools/vmap/mmap_generator/CMakeLists.txt @@ -0,0 +1,15 @@ +add_executable(firelands-mmap-generator + main.cpp + MmapGenerator.cpp +) + +target_include_directories(firelands-mmap-generator PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(firelands-mmap-generator + PRIVATE Recast + PRIVATE Detour +) + +target_precompile_headers(firelands-mmap-generator PRIVATE ${PROJECT_PCH_HEADERS}) diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp new file mode 100644 index 0000000..2090683 --- /dev/null +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -0,0 +1,335 @@ +#include "MmapGenerator.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace Firelands { + +namespace { + +constexpr float kTileSize = 533.33333f; +constexpr float kMapOrigin = -17066.66656f; + +float TileOriginX(uint32_t tileX) { + return kMapOrigin + static_cast(tileX) * kTileSize; +} + +float TileOriginY(uint32_t tileY) { + return kMapOrigin + static_cast(tileY) * kTileSize; +} + +struct MmapTileHeader { + uint32_t mmapMagic; + uint32_t dtVersion; + uint32_t mmapSize; + unsigned char usesLiquids; + unsigned char padding[3]; +}; + +void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t tileX, uint32_t tileY, + std::string const& outputPath) { + dtMeshTile const* tile = navMesh->getTile(0); + if (!tile || !tile->data || tile->dataSize == 0) + return; + + MmapTileHeader header{}; + header.mmapMagic = 'M' | ('M' << 8) | ('A' << 16) | ('P' << 24); + header.dtVersion = DT_NAVMESH_VERSION; + header.mmapSize = static_cast(tile->dataSize); + header.usesLiquids = 0; + + std::string fileName = outputPath + "/" + + std::to_string(0) + "_" + + std::to_string(tileX) + "_" + + std::to_string(tileY) + ".mmtile"; + + FILE* file = fopen(fileName.c_str(), "wb"); + if (!file) + return; + + fwrite(&header, sizeof(header), 1, file); + fwrite(tile->data, tile->dataSize, 1, file); + fclose(file); +} + +} // namespace + +MmapGenerator::MmapGenerator(MmapGeneratorConfig config) + : _config(std::move(config)) {} + +bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, + TileTerrainData& out) const { + std::string const fileName = + std::filesystem::path(_config.mapsDir) / + (std::to_string(_config.mapId) + + std::to_string(tileY) + + std::to_string(tileX) + ".map"); + + FILE* file = fopen(fileName.c_str(), "rb"); + if (!file) + return false; + + char magic[5] = {}; + if (fread(magic, 1, 4, file) != 4 || std::memcmp(magic, "MAPS", 4) != 0) { + fclose(file); + return false; + } + + uint32_t header[3] = {}; + fread(header, sizeof(uint32_t), 3, file); + + out.cellWidth = kTileSize / static_cast(header[1]); + out.cellHeight = kTileSize / static_cast(header[2]); + out.width = static_cast(header[1]); + out.height = static_cast(header[2]); + out.minX = TileOriginX(tileX); + out.minY = TileOriginY(tileY); + + out.heights.resize(out.width * out.height); + + for (int y = 0; y < out.height; ++y) { + for (int x = 0; x < out.width; ++x) { + float h = 0.0f; + fread(&h, sizeof(float), 1, file); + out.heights[y * out.width + x] = h; + } + } + + fclose(file); + return true; +} + +bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, + uint32_t tileX, uint32_t tileY, + std::string const& outputPath) const { + float const bmin[3] = {terrain.minX, terrain.minY, + -500.0f}; + float const bmax[3] = {terrain.minX + kTileSize, + terrain.minY + kTileSize, 500.0f}; + + int const tileWidth = + static_cast(kTileSize / _config.cellSize + 0.5f); + int const tileHeight = + static_cast(kTileSize / _config.cellSize + 0.5f); + + rcContext ctx; + + rcHeightfield* solid = rcAllocHeightfield(); + if (!solid) + return false; + if (!rcCreateHeightfield(&ctx, *solid, tileWidth, tileHeight, bmin, bmax, + _config.cellSize, _config.cellHeight)) { + rcFreeHeightField(solid); + return false; + } + + unsigned char* triAreas = new unsigned char[terrain.width * terrain.height * 2]; + std::memset(triAreas, 0, terrain.width * terrain.height * 2); + + std::vector verts; + verts.reserve(terrain.width * terrain.height * 3); + for (int y = 0; y < terrain.height; ++y) { + for (int x = 0; x < terrain.width; ++x) { + float wx = terrain.minX + static_cast(x) * terrain.cellWidth; + float wy = terrain.minY + static_cast(y) * terrain.cellHeight; + float h = terrain.heights[y * terrain.width + x]; + verts.push_back(wx); + verts.push_back(wy); + verts.push_back(h); + } + } + + std::vector tris; + for (int y = 0; y < terrain.height - 1; ++y) { + for (int x = 0; x < terrain.width - 1; ++x) { + int const a = y * terrain.width + x; + int const b = y * terrain.width + x + 1; + int const c = (y + 1) * terrain.width + x; + int const d = (y + 1) * terrain.width + x + 1; + tris.push_back(a); tris.push_back(c); tris.push_back(b); + tris.push_back(b); tris.push_back(c); tris.push_back(d); + } + } + + rcRasterizeTriangles(&ctx, verts.data(), static_cast(verts.size()) / 3, + tris.data(), triAreas, + static_cast(tris.size()) / 3, *solid, 0); + delete[] triAreas; + + rcFilterLowHangingWalkableObstacles(&ctx, static_cast(_config.agentMaxClimb / _config.cellHeight), *solid); + rcFilterLedgeSpans(&ctx, static_cast(_config.agentHeight / _config.cellHeight), + static_cast(_config.agentMaxClimb / _config.cellHeight), *solid); + rcFilterWalkableLowHeightSpans(&ctx, static_cast(_config.agentHeight / _config.cellHeight), *solid); + + rcCompactHeightfield* chf = rcAllocCompactHeightfield(); + if (!chf) { + rcFreeHeightField(solid); + return false; + } + if (!rcBuildCompactHeightfield(&ctx, static_cast(_config.agentMaxClimb / _config.cellHeight), + static_cast(_config.agentHeight / _config.cellHeight), *solid, *chf)) { + rcFreeCompactHeightfield(chf); + rcFreeHeightField(solid); + return false; + } + rcFreeHeightField(solid); + + if (!rcErodeWalkableArea(&ctx, static_cast(_config.agentRadius / _config.cellSize), *chf)) { + rcFreeCompactHeightfield(chf); + return false; + } + + if (!rcBuildRegionsMonotone(&ctx, *chf, 0, _config.minRegionArea, _config.mergeRegionArea)) { + rcFreeCompactHeightfield(chf); + return false; + } + + rcContourSet* cset = rcAllocContourSet(); + if (!cset) { + rcFreeCompactHeightfield(chf); + return false; + } + if (!rcBuildContours(&ctx, *chf, _config.maxSimplificationError, + _config.maxEdgeLen, *cset)) { + rcFreeContourSet(cset); + rcFreeCompactHeightfield(chf); + return false; + } + + rcPolyMesh* pmesh = rcAllocPolyMesh(); + if (!pmesh) { + rcFreeContourSet(cset); + rcFreeCompactHeightfield(chf); + return false; + } + if (!rcBuildPolyMesh(&ctx, *cset, _config.maxVertsPerPoly, *pmesh)) { + rcFreePolyMesh(pmesh); + rcFreeContourSet(cset); + rcFreeCompactHeightfield(chf); + return false; + } + + rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail(); + if (!dmesh) { + rcFreePolyMesh(pmesh); + rcFreeContourSet(cset); + rcFreeCompactHeightfield(chf); + return false; + } + if (!rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, _config.detailSampleDist, + _config.detailSampleMaxError, *dmesh)) { + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + rcFreeContourSet(cset); + rcFreeCompactHeightfield(chf); + return false; + } + + rcFreeCompactHeightfield(chf); + rcFreeContourSet(cset); + + for (int i = 0; i < pmesh->npolys; ++i) { + if (pmesh->areas[i] == RC_WALKABLE_AREA) + pmesh->flags[i] = 0x01; + } + + dtNavMeshCreateParams params{}; + std::memset(¶ms, 0, sizeof(params)); + params.verts = pmesh->verts; + params.vertCount = pmesh->nverts; + params.polys = pmesh->polys; + params.polyAreas = pmesh->areas; + params.polyFlags = pmesh->flags; + params.polyCount = pmesh->npolys; + params.nvp = pmesh->nvp; + params.detailMeshes = dmesh->meshes; + params.detailVerts = dmesh->verts; + params.detailVertsCount = dmesh->nverts; + params.detailTris = dmesh->tris; + params.detailTriCount = dmesh->ntris; + params.walkableHeight = _config.agentHeight; + params.walkableRadius = _config.agentRadius; + params.walkableClimb = _config.agentMaxClimb; + rcVcopy(params.bmin, pmesh->bmin); + rcVcopy(params.bmax, pmesh->bmax); + params.cs = _config.cellSize; + params.ch = _config.cellHeight; + params.buildBvTree = true; + + unsigned char* navData = nullptr; + int navDataSize = 0; + if (!dtCreateNavMeshData(¶ms, &navData, &navDataSize)) { + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return false; + } + + dtNavMesh* tileNavMesh = dtAllocNavMesh(); + if (!tileNavMesh) { + dtFree(navData); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return false; + } + + dtNavMeshParams navParams{}; + rcVcopy(navParams.orig, bmin); + navParams.tileWidth = kTileSize; + navParams.tileHeight = kTileSize; + navParams.maxTiles = 1; + navParams.maxPolys = 1 << 16; + + dtStatus status = tileNavMesh->init(&navParams); + if (dtStatusFailed(status)) { + dtFreeNavMesh(tileNavMesh); + dtFree(navData); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return false; + } + + status = tileNavMesh->addTile(navData, navDataSize, DT_TILE_FREE_DATA, 0, nullptr); + if (dtStatusFailed(status)) { + dtFreeNavMesh(tileNavMesh); + dtFree(navData); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return false; + } + + SaveNavMeshTile(tileNavMesh, tileX, tileY, outputPath); + + dtFreeNavMesh(tileNavMesh); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return true; +} + +bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { + TileTerrainData terrain; + if (!LoadTerrainData(tileX, tileY, terrain)) + return false; + + std::filesystem::create_directories(_config.mmapsDir); + return BuildTileNavMesh(terrain, tileX, tileY, _config.mmapsDir); +} + +bool MmapGenerator::GenerateAllTiles() { + bool anySuccess = false; + for (uint32_t tileY = 0; tileY < 64; ++tileY) { + for (uint32_t tileX = 0; tileX < 64; ++tileX) { + if (Generate(tileX, tileY)) + anySuccess = true; + } + } + return anySuccess; +} + +} // namespace Firelands diff --git a/tools/vmap/mmap_generator/MmapGenerator.h b/tools/vmap/mmap_generator/MmapGenerator.h new file mode 100644 index 0000000..c5e749e --- /dev/null +++ b/tools/vmap/mmap_generator/MmapGenerator.h @@ -0,0 +1,60 @@ +#ifndef FIRELANDS_MMAP_GENERATOR_MMAP_GENERATOR_H +#define FIRELANDS_MMAP_GENERATOR_MMAP_GENERATOR_H + +#include +#include +#include + +namespace Firelands { + +struct MmapGeneratorConfig { + std::string mapsDir; + std::string vmapsDir; + std::string mmapsDir; + uint32_t mapId = 0; + + float cellSize = 0.3f; + float cellHeight = 0.2f; + float agentHeight = 2.0f; + float agentRadius = 0.6f; + float agentMaxClimb = 0.9f; + float agentMaxSlope = 45.0f; + float minRegionArea = 10.0f; + float mergeRegionArea = 20.0f; + float maxEdgeLen = 12.0f; + float maxSimplificationError = 1.3f; + int maxVertsPerPoly = 6; + float detailSampleDist = 6.0f; + float detailSampleMaxError = 1.0f; + + static MmapGeneratorConfig Default() { return {}; } +}; + +class MmapGenerator { +public: + explicit MmapGenerator(MmapGeneratorConfig config); + + bool Generate(uint32_t tileX, uint32_t tileY); + bool GenerateAllTiles(); + +private: + struct TileTerrainData { + std::vector heights; + int width = 0; + int height = 0; + float minX = 0.0f; + float minY = 0.0f; + float cellWidth = 0.0f; + float cellHeight = 0.0f; + }; + + bool LoadTerrainData(uint32_t tileX, uint32_t tileY, TileTerrainData& out) const; + bool BuildTileNavMesh(TileTerrainData const& terrain, uint32_t tileX, + uint32_t tileY, std::string const& outputPath) const; + + MmapGeneratorConfig _config; +}; + +} // namespace Firelands + +#endif diff --git a/tools/vmap/mmap_generator/main.cpp b/tools/vmap/mmap_generator/main.cpp new file mode 100644 index 0000000..6f962b9 --- /dev/null +++ b/tools/vmap/mmap_generator/main.cpp @@ -0,0 +1,83 @@ +#include "MmapGenerator.h" + +#include +#include +#include +#include + +void PrintUsage() { + printf("firelands-mmap-generator — Build navmesh tiles from server .map data\n"); + printf("Usage: firelands-mmap-generator -m -i -o [-v ]\n"); + printf("\n"); + printf(" -m Map ID to generate navmesh for (e.g. 0 for Eastern Kingdoms)\n"); + printf(" -i Input directory containing server .map files\n"); + printf(" -o Output directory for .mmtile files\n"); + printf(" -v Optional: vmaps directory for building collision\n"); + printf(" -t Optional: generate only tile (x,y) instead of all\n"); + printf(" -h Show this help\n"); +} + +int main(int argc, char* argv[]) { + Firelands::MmapGeneratorConfig config = Firelands::MmapGeneratorConfig::Default(); + bool hasMapId = false; + bool hasInput = false; + bool hasOutput = false; + bool singleTile = false; + uint32_t tileX = 0; + uint32_t tileY = 0; + + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "-h") == 0) { + PrintUsage(); + return 0; + } + if (std::strcmp(argv[i], "-m") == 0 && i + 1 < argc) { + config.mapId = static_cast(std::atoi(argv[++i])); + hasMapId = true; + } else if (std::strcmp(argv[i], "-i") == 0 && i + 1 < argc) { + config.mapsDir = argv[++i]; + hasInput = true; + } else if (std::strcmp(argv[i], "-o") == 0 && i + 1 < argc) { + config.mmapsDir = argv[++i]; + hasOutput = true; + } else if (std::strcmp(argv[i], "-v") == 0 && i + 1 < argc) { + config.vmapsDir = argv[++i]; + } else if (std::strcmp(argv[i], "-t") == 0 && i + 2 < argc) { + tileX = static_cast(std::atoi(argv[++i])); + tileY = static_cast(std::atoi(argv[++i])); + singleTile = true; + } + } + + if (!hasMapId || !hasInput || !hasOutput) { + PrintUsage(); + return 1; + } + + if (!std::filesystem::exists(config.mapsDir)) { + fprintf(stderr, "Error: maps directory not found: %s\n", config.mapsDir.c_str()); + return 1; + } + + Firelands::MmapGenerator generator(std::move(config)); + + if (singleTile) { + printf("Generating navmesh for map %u tile (%u,%u)...\n", + config.mapId, tileX, tileY); + if (!generator.Generate(tileX, tileY)) { + fprintf(stderr, "Failed to generate tile (%u,%u).\n", tileX, tileY); + return 1; + } + printf("Tile (%u,%u) generated successfully.\n", tileX, tileY); + } else { + printf("Generating navmesh for map %u (all tiles)...\n", config.mapId); + if (!generator.GenerateAllTiles()) { + fprintf(stderr, "No tiles were generated. Check that .map files exist " + "in the input directory.\n"); + return 1; + } + printf("Navmesh generation complete.\n"); + } + + return 0; +} diff --git a/worldserver.yaml b/worldserver.yaml index ee07d28..8784507 100644 --- a/worldserver.yaml +++ b/worldserver.yaml @@ -66,3 +66,10 @@ Motd: # from a loose client file. Data: DbcPath: "data/dbc" + +# Collision & pathfinding data root (extracted from client via firelands-* tools). +# When set, enables real LineOfSight and navmesh pathfinding via Detour. +# Expected layout: /vmaps/ and /mmaps/ +# Leave empty to use the permissive stub (no collision, straight-line movement). +Collision: + DataRoot: "" From 0629b5f6a8052fe0bae2c3bcfa8bf9d7734d9d2a Mon Sep 17 00:00:00 2001 From: HkevinH Date: Fri, 29 May 2026 21:26:16 -0500 Subject: [PATCH 02/35] Add VMapManager2 and WorldModelRuntime for enhanced map collision handling - Introduced VMapManager2 class to manage virtual map loading and collision detection. - Implemented WorldModelRuntime to handle the reading and processing of world model data. - Added support for loading VMAP files and managing their associated models and spawns. - Created necessary data structures such as LoadedTile and LoadedMap for efficient map management. - Implemented ray intersection and height retrieval functionalities for collision detection. - Added unit tests for VMapManager2 and WorldModelRuntime to ensure correctness of the new features. - Updated CMakeLists.txt to include new test files for VMapManager2. --- src/infrastructure/CMakeLists.txt | 2 + .../collision/DetourNavMeshManager.h | 1 + .../collision/MapCollisionQueriesReal.cpp | 25 +- .../collision/MapCollisionQueriesReal.h | 2 + src/infrastructure/collision/VMapManager2.cpp | 328 ++++++++++++++ src/infrastructure/collision/VMapManager2.h | 46 ++ .../collision/WorldModelRuntime.cpp | 408 ++++++++++++++++++ .../collision/WorldModelRuntime.h | 106 +++++ tests/CMakeLists.txt | 1 + .../unit/infrastructure/VMapManager2Tests.cpp | 188 ++++++++ 10 files changed, 1096 insertions(+), 11 deletions(-) create mode 100644 src/infrastructure/collision/VMapManager2.cpp create mode 100644 src/infrastructure/collision/VMapManager2.h create mode 100644 src/infrastructure/collision/WorldModelRuntime.cpp create mode 100644 src/infrastructure/collision/WorldModelRuntime.h create mode 100644 tests/unit/infrastructure/VMapManager2Tests.cpp diff --git a/src/infrastructure/CMakeLists.txt b/src/infrastructure/CMakeLists.txt index 1e4b899..ef5e247 100644 --- a/src/infrastructure/CMakeLists.txt +++ b/src/infrastructure/CMakeLists.txt @@ -65,6 +65,8 @@ add_library(FirelandsInfrastructure STATIC world/MapCollisionQueriesStub.cpp collision/DetourNavMeshManager.cpp collision/MapCollisionQueriesReal.cpp + collision/VMapManager2.cpp + collision/WorldModelRuntime.cpp ) set_source_files_properties(scripting/LuaGameScriptHost.cpp diff --git a/src/infrastructure/collision/DetourNavMeshManager.h b/src/infrastructure/collision/DetourNavMeshManager.h index b0f3446..3e9c0b4 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.h +++ b/src/infrastructure/collision/DetourNavMeshManager.h @@ -31,6 +31,7 @@ class DetourNavMeshManager { void UnloadMapNavMesh(uint32_t mapId); bool IsNavMeshLoaded(uint32_t mapId) const; bool HasDataRoot() const { return !_dataRoot.empty(); } + std::string const& GetDataRoot() const { return _dataRoot; } FindPathResult FindPath(FindPathRequest const& req) const; diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.cpp b/src/infrastructure/collision/MapCollisionQueriesReal.cpp index a750c61..960cbed 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.cpp +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -3,7 +3,7 @@ namespace Firelands { MapCollisionQueriesReal::MapCollisionQueriesReal(std::string dataRoot) - : _navMeshManager(std::move(dataRoot)) {} + : _navMeshManager(dataRoot), _vmapManager() {} bool MapCollisionQueriesReal::IsNavMeshDataAvailable(uint32_t mapId) const { if (_navMeshManager.IsNavMeshLoaded(mapId)) @@ -11,12 +11,13 @@ bool MapCollisionQueriesReal::IsNavMeshDataAvailable(uint32_t mapId) const { return _navMeshManager.LoadMapNavMesh(mapId); } -bool MapCollisionQueriesReal::LineOfSight(uint32_t /*mapId*/, float x0, - float y0, float z0, float x1, - float y1, float z1) const { - // TODO: Integrate VMapManager2 for real LoS checks - (void)x0; (void)y0; (void)z0; - (void)x1; (void)y1; (void)z1; +bool MapCollisionQueriesReal::LineOfSight(uint32_t mapId, float x0, float y0, + float z0, float x1, float y1, + float z1) const { + if (_navMeshManager.HasDataRoot() && !_vmapManager.IsMapLoaded(mapId)) + _vmapManager.LoadMap(mapId, _navMeshManager.GetDataRoot()); + if (_vmapManager.IsMapLoaded(mapId)) + return _vmapManager.LineOfSight(x0, y0, z0, x1, y1, z1); return true; } @@ -29,13 +30,15 @@ FindPathResult MapCollisionQueriesReal::FindPath( return result; } } - return _navMeshManager.FindPath(req); } -float MapCollisionQueriesReal::GetHeight(uint32_t /*mapId*/, float /*x*/, - float /*y*/, float zHint) const { - // TODO: Integrate VMapManager2 for real height queries +float MapCollisionQueriesReal::GetHeight(uint32_t mapId, float x, float y, + float zHint) const { + if (_navMeshManager.HasDataRoot() && !_vmapManager.IsMapLoaded(mapId)) + _vmapManager.LoadMap(mapId, _navMeshManager.GetDataRoot()); + if (_vmapManager.IsMapLoaded(mapId)) + return _vmapManager.GetHeight(x, y, zHint); return zHint; } diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.h b/src/infrastructure/collision/MapCollisionQueriesReal.h index ccc1764..59bb6f5 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.h +++ b/src/infrastructure/collision/MapCollisionQueriesReal.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -22,6 +23,7 @@ class MapCollisionQueriesReal final : public IMapCollisionQueries { private: mutable DetourNavMeshManager _navMeshManager; + mutable VMapManager2 _vmapManager; }; } // namespace Firelands diff --git a/src/infrastructure/collision/VMapManager2.cpp b/src/infrastructure/collision/VMapManager2.cpp new file mode 100644 index 0000000..572d940 --- /dev/null +++ b/src/infrastructure/collision/VMapManager2.cpp @@ -0,0 +1,328 @@ +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Firelands { + +namespace { + +constexpr float kTileSize = 533.33333f; +constexpr float kMapOrigin = -17066.66656f; + +Vec3 Sub(Vec3 const& a, Vec3 const& b) { + return {a.x - b.x, a.y - b.y, a.z - b.z}; +} + +float Dot(Vec3 const& a, Vec3 const& b) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +Vec3 Normalize(Vec3 const& v) { + float len = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (len < 1e-8f) return {0, 0, 1}; + return {v.x / len, v.y / len, v.z / len}; +} + +std::vector ReadEntireFile(std::string const& path) { + std::vector data; + FILE* f = fopen(path.c_str(), "rb"); + if (!f) return data; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz > 0) { + data.resize(sz); + fread(data.data(), 1, sz, f); + } + fclose(f); + return data; +} + +} // namespace + +struct VMapManager2::LoadedTile { + uint32_t tileX = 0; + uint32_t tileY = 0; + std::vector spawns; + std::vector> models; + bool loaded = false; + + bool Load(uint32_t mapId, std::string const& dataRoot, uint32_t tx, + uint32_t ty) { + tileX = tx; + tileY = ty; + + std::string tilePath = std::filesystem::path(dataRoot) / "vmaps" / + (std::to_string(mapId) + "_" + + std::to_string(tileY) + "_" + + std::to_string(tileX) + ".vmtile"); + + auto tileData = ReadEntireFile(tilePath); + if (tileData.size() < 12) + return false; + + char magic[9] = {}; + std::memcpy(magic, tileData.data(), 8); + if (std::memcmp(magic, "VMAP_4.8", 8) != 0) + return false; + + size_t offset = 8; + uint32_t nSpawns = 0; + std::memcpy(&nSpawns, tileData.data() + offset, 4); + offset += 4; + + spawns.resize(nSpawns); + for (uint32_t i = 0; i < nSpawns; ++i) { + if (offset + 34 > tileData.size()) + break; + ModelSpawn& sp = spawns[i]; + + sp.flags = tileData[offset++]; + sp.adtId = tileData[offset++]; + std::memcpy(&sp.ID, tileData.data() + offset, 4); + offset += 4; + sp.iPos.x = 0; std::memcpy(&sp.iPos.x, tileData.data() + offset, 4); offset += 4; + sp.iPos.y = 0; std::memcpy(&sp.iPos.y, tileData.data() + offset, 4); offset += 4; + sp.iPos.z = 0; std::memcpy(&sp.iPos.z, tileData.data() + offset, 4); offset += 4; + sp.iRot.x = 0; std::memcpy(&sp.iRot.x, tileData.data() + offset, 4); offset += 4; + sp.iRot.y = 0; std::memcpy(&sp.iRot.y, tileData.data() + offset, 4); offset += 4; + sp.iRot.z = 0; std::memcpy(&sp.iRot.z, tileData.data() + offset, 4); offset += 4; + std::memcpy(&sp.iScale, tileData.data() + offset, 4); + offset += 4; + + if (sp.flags & 0x02) { + sp.iBound.lo.x = 0; std::memcpy(&sp.iBound.lo.x, tileData.data() + offset, 4); offset += 4; + sp.iBound.lo.y = 0; std::memcpy(&sp.iBound.lo.y, tileData.data() + offset, 4); offset += 4; + sp.iBound.lo.z = 0; std::memcpy(&sp.iBound.lo.z, tileData.data() + offset, 4); offset += 4; + sp.iBound.hi.x = 0; std::memcpy(&sp.iBound.hi.x, tileData.data() + offset, 4); offset += 4; + sp.iBound.hi.y = 0; std::memcpy(&sp.iBound.hi.y, tileData.data() + offset, 4); offset += 4; + sp.iBound.hi.z = 0; std::memcpy(&sp.iBound.hi.z, tileData.data() + offset, 4); offset += 4; + } + + uint32_t nameLen = 0; + std::memcpy(&nameLen, tileData.data() + offset, 4); + offset += 4; + if (nameLen > 0 && offset + nameLen <= tileData.size()) { + sp.name.assign(reinterpret_cast(tileData.data() + offset), nameLen); + offset += nameLen; + } + } + + for (auto const& sp : spawns) { + if (sp.name.empty()) + continue; + std::string modelPath = std::filesystem::path(dataRoot) / "vmaps" / + (sp.name + ".vmo"); + auto modelData = ReadEntireFile(modelPath); + if (modelData.empty()) + continue; + auto model = std::make_unique(); + if (model->Read(modelData)) + models.push_back(std::move(model)); + } + + loaded = true; + return true; + } +}; + +struct VMapManager2::LoadedMap { + uint32_t mapId = 0; + std::string dataRoot; + BoundingIntervalHierarchy treeBih; + std::vector> spawnIndex; + std::unordered_map> tiles; + bool loaded = false; + + bool Load(uint32_t id, std::string const& root) { + mapId = id; + dataRoot = root; + + std::string treePath = std::filesystem::path(dataRoot) / "vmaps" / + (std::to_string(mapId) + ".vmtree"); + auto treeData = ReadEntireFile(treePath); + if (treeData.size() < 16) + return false; + + char magic[9] = {}; + std::memcpy(magic, treeData.data(), 8); + if (std::memcmp(magic, "VMAP_4.8", 8) != 0) + return false; + + size_t offset = 8; + while (offset + 4 <= treeData.size()) { + char tag[5] = {}; + std::memcpy(tag, treeData.data() + offset, 4); + offset += 4; + + if (std::memcmp(tag, "NODE", 4) == 0) { + treeBih.Read(treeData, offset); + } else if (std::memcmp(tag, "SIDX", 4) == 0) { + if (offset + 4 > treeData.size()) + break; + uint32_t mapSpawnsSize = 0; + std::memcpy(&mapSpawnsSize, treeData.data() + offset, 4); + offset += 4; + spawnIndex.resize(mapSpawnsSize); + for (uint32_t i = 0; i < mapSpawnsSize; ++i) { + if (offset + 8 > treeData.size()) + break; + uint32_t spawnId = 0; + std::memcpy(&spawnId, treeData.data() + offset, 4); + offset += 4; + uint32_t treeIdx = 0; + std::memcpy(&treeIdx, treeData.data() + offset, 4); + offset += 4; + spawnIndex[i] = {spawnId, treeIdx}; + } + } else { + if (offset + 4 > treeData.size()) + break; + uint32_t chunkSize = 0; + std::memcpy(&chunkSize, treeData.data() + offset, 4); + offset += 4; + offset += chunkSize; + } + } + + loaded = true; + return true; + } + + LoadedTile* GetTile(uint32_t tileX, uint32_t tileY) { + uint32_t key = (tileX << 16) | tileY; + auto it = tiles.find(key); + if (it != tiles.end()) + return it->second.get(); + + auto tile = std::make_unique(); + if (!tile->Load(mapId, dataRoot, tileX, tileY)) + return nullptr; + auto* ptr = tile.get(); + tiles[key] = std::move(tile); + return ptr; + } +}; + +VMapManager2::VMapManager2() = default; +VMapManager2::~VMapManager2() = default; + +bool VMapManager2::LoadMap(uint32_t mapId, std::string const& dataRoot) { + if (_loadedMaps.count(mapId) > 0) + return _loadedMaps[mapId]->loaded; + + auto map = std::make_unique(); + if (!map->Load(mapId, dataRoot)) + return false; + _loadedMaps[mapId] = std::move(map); + _currentMapId = mapId; + return true; +} + +void VMapManager2::UnloadMap(uint32_t mapId) { + _loadedMaps.erase(mapId); + if (_currentMapId == mapId) + _currentMapId = 0; +} + +bool VMapManager2::IsMapLoaded(uint32_t mapId) const { + auto it = _loadedMaps.find(mapId); + return it != _loadedMaps.end() && it->second->loaded; +} + +uint32_t VMapManager2::WorldToTileX(float x) const { + return static_cast(std::floor((x - kMapOrigin) / kTileSize)); +} + +uint32_t VMapManager2::WorldToTileY(float y) const { + return static_cast(std::floor((y - kMapOrigin) / kTileSize)); +} + +float VMapManager2::TileOriginX(uint32_t tx) const { + return kMapOrigin + static_cast(tx) * kTileSize; +} + +float VMapManager2::TileOriginY(uint32_t ty) const { + return kMapOrigin + static_cast(ty) * kTileSize; +} + +VMapManager2::LoadedTile const* VMapManager2::GetTileForPosition( + float x, float y) const { + auto it = _loadedMaps.find(_currentMapId); + if (it == _loadedMaps.end()) + return nullptr; + + uint32_t tx = WorldToTileX(x); + uint32_t ty = WorldToTileY(y); + return it->second->GetTile(tx, ty); +} + +bool VMapManager2::LineOfSight(float x0, float y0, float z0, float x1, + float y1, float z1) const { + auto it = _loadedMaps.find(_currentMapId); + if (it == _loadedMaps.end()) + return true; + + Vec3 start = {x0, y0, z0}; + Vec3 end = {x1, y1, z1}; + Vec3 dir = Sub(end, start); + float maxDist = std::sqrt(Dot(dir, dir)); + if (maxDist < 0.01f) + return true; + Vec3 rayDir = Normalize(dir); + + uint32_t startTX = WorldToTileX(x0); + uint32_t startTY = WorldToTileY(y0); + uint32_t endTX = WorldToTileX(x1); + uint32_t endTY = WorldToTileY(y1); + + uint32_t minTX = std::min(startTX, endTX); + uint32_t maxTX = std::max(startTX, endTX); + uint32_t minTY = std::min(startTY, endTY); + uint32_t maxTY = std::max(startTY, endTY); + + for (uint32_t ty = minTY; ty <= maxTY; ++ty) { + for (uint32_t tx = minTX; tx <= maxTX; ++tx) { + auto const* tile = it->second->GetTile(tx, ty); + if (!tile) + continue; + + for (auto const& model : tile->models) { + for (auto const& group : model->GetGroups()) { + float hitDist = maxDist; + if (group.RayIntersects(start, rayDir, maxDist, hitDist)) + return false; + } + } + } + } + + return true; +} + +float VMapManager2::GetHeight(float x, float y, float zHint) const { + auto const* tile = GetTileForPosition(x, y); + if (!tile) + return zHint; + + float bestZ = -1e30f; + for (auto const& model : tile->models) { + for (auto const& group : model->GetGroups()) { + float h = group.GetHeightAt(x, y); + if (h > bestZ) + bestZ = h; + } + } + + if (bestZ > -1e29f) + return bestZ; + return zHint; +} + +} // namespace Firelands diff --git a/src/infrastructure/collision/VMapManager2.h b/src/infrastructure/collision/VMapManager2.h new file mode 100644 index 0000000..df15978 --- /dev/null +++ b/src/infrastructure/collision/VMapManager2.h @@ -0,0 +1,46 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_COLLISION_VMAP_MANAGER2_H +#define FIRELANDS_INFRASTRUCTURE_COLLISION_VMAP_MANAGER2_H + +#include +#include +#include +#include +#include + +namespace Firelands { + +class WorldModelRuntime; + +class VMapManager2 { +public: + VMapManager2(); + ~VMapManager2(); + + VMapManager2(VMapManager2 const&) = delete; + VMapManager2& operator=(VMapManager2 const&) = delete; + + bool LoadMap(uint32_t mapId, std::string const& dataRoot); + void UnloadMap(uint32_t mapId); + bool IsMapLoaded(uint32_t mapId) const; + + bool LineOfSight(float x0, float y0, float z0, float x1, float y1, + float z1) const; + float GetHeight(float x, float y, float zHint) const; + +private: + struct LoadedMap; + struct LoadedTile; + + LoadedTile const* GetTileForPosition(float x, float y) const; + uint32_t WorldToTileX(float x) const; + uint32_t WorldToTileY(float y) const; + float TileOriginX(uint32_t tx) const; + float TileOriginY(uint32_t ty) const; + + std::unordered_map> _loadedMaps; + uint32_t _currentMapId = 0; +}; + +} // namespace Firelands + +#endif diff --git a/src/infrastructure/collision/WorldModelRuntime.cpp b/src/infrastructure/collision/WorldModelRuntime.cpp new file mode 100644 index 0000000..ca4f6a9 --- /dev/null +++ b/src/infrastructure/collision/WorldModelRuntime.cpp @@ -0,0 +1,408 @@ +#include + +#include +#include +#include + +namespace Firelands { + +namespace { + +uint32_t ReadU32(uint8_t const* data, size_t& offset) { + uint32_t val = 0; + std::memcpy(&val, data + offset, 4); + offset += 4; + return val; +} + +float ReadFloat(uint8_t const* data, size_t& offset) { + uint32_t bits = ReadU32(data, offset); + float f = 0.0f; + std::memcpy(&f, &bits, 4); + return f; +} + +Vec3 ReadVec3(uint8_t const* data, size_t& offset) { + Vec3 v; + v.x = ReadFloat(data, offset); + v.y = ReadFloat(data, offset); + v.z = ReadFloat(data, offset); + return v; +} + +Vec3 Cross(Vec3 const& a, Vec3 const& b) { + return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x}; +} + +float Dot(Vec3 const& a, Vec3 const& b) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +Vec3 Sub(Vec3 const& a, Vec3 const& b) { + return {a.x - b.x, a.y - b.y, a.z - b.z}; +} + +Vec3 Normalize(Vec3 const& v) { + float len = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (len < 1e-8f) + return {0, 0, 1}; + return {v.x / len, v.y / len, v.z / len}; +} + +bool RayAABBIntersect(Vec3 const& orig, Vec3 const& dir, AaBox3 const& box, + float& tNear, float& tFar) { + tNear = -1e30f; + tFar = 1e30f; + + float t1 = (box.lo.x - orig.x) / (dir.x + 1e-8f); + float t2 = (box.hi.x - orig.x) / (dir.x + 1e-8f); + if (t1 > t2) + std::swap(t1, t2); + tNear = std::max(tNear, t1); + tFar = std::min(tFar, t2); + if (tNear > tFar) + return false; + + t1 = (box.lo.y - orig.y) / (dir.y + 1e-8f); + t2 = (box.hi.y - orig.y) / (dir.y + 1e-8f); + if (t1 > t2) + std::swap(t1, t2); + tNear = std::max(tNear, t1); + tFar = std::min(tFar, t2); + if (tNear > tFar) + return false; + + t1 = (box.lo.z - orig.z) / (dir.z + 1e-8f); + t2 = (box.hi.z - orig.z) / (dir.z + 1e-8f); + if (t1 > t2) + std::swap(t1, t2); + tNear = std::max(tNear, t1); + tFar = std::min(tFar, t2); + return tNear <= tFar; +} + +} // namespace + +// --- GroupModelRuntime --- + +bool GroupModelRuntime::Read(std::vector const& data, size_t& offset) { + uint8_t const* buf = data.data(); + size_t const size = data.size(); + + _bound.lo = ReadVec3(buf, offset); + _bound.hi = ReadVec3(buf, offset); + if (offset + 8 > size) + return false; + _mogpFlags = ReadU32(buf, offset); + _groupWMOID = ReadU32(buf, offset); + + while (offset + 4 <= size) { + char tag[5] = {}; + std::memcpy(tag, buf + offset, 4); + offset += 4; + + if (offset + 4 > size) + return false; + uint32_t chunkSize = ReadU32(buf, offset); + size_t chunkEnd = offset + chunkSize; + + if (chunkEnd > size) + return false; + + if (std::memcmp(tag, "VERT", 4) == 0) { + uint32_t vertCount = ReadU32(buf, offset); + _vertices.resize(vertCount); + for (uint32_t i = 0; i < vertCount; ++i) + _vertices[i] = ReadVec3(buf, offset); + } else if (std::memcmp(tag, "TRIM", 4) == 0) { + uint32_t triCount = ReadU32(buf, offset); + _triangles.resize(triCount); + for (uint32_t i = 0; i < triCount; ++i) { + _triangles[i].idx0 = ReadU32(buf, offset); + _triangles[i].idx1 = ReadU32(buf, offset); + _triangles[i].idx2 = ReadU32(buf, offset); + } + } else if (std::memcmp(tag, "MBIH", 4) == 0) { + // Embedded BIH: skip for now, use brute force triangle test + offset = chunkEnd; + } else if (std::memcmp(tag, "LIQU", 4) == 0) { + offset = chunkEnd; + } else { + offset = chunkEnd; + } + } + return true; +} + +bool GroupModelRuntime::RayTriangleIntersect(Vec3 const& orig, Vec3 const& dir, + Vec3 const& v0, Vec3 const& v1, + Vec3 const& v2, float& t) const { + constexpr float kEpsilon = 1e-8f; + Vec3 e1 = Sub(v1, v0); + Vec3 e2 = Sub(v2, v0); + Vec3 pvec = Cross(dir, e2); + float det = Dot(e1, pvec); + + if (std::abs(det) < kEpsilon) + return false; + float invDet = 1.0f / det; + Vec3 tvec = Sub(orig, v0); + float u = Dot(tvec, pvec) * invDet; + if (u < 0.0f || u > 1.0f) + return false; + Vec3 qvec = Cross(tvec, e1); + float v = Dot(dir, qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) + return false; + t = Dot(e2, qvec) * invDet; + return t > kEpsilon; +} + +bool GroupModelRuntime::RayIntersects(Vec3 const& rayStart, Vec3 const& rayDir, + float maxDist, float& hitDist) const { + hitDist = maxDist; + bool hit = false; + for (auto const& tri : _triangles) { + if (tri.idx0 >= _vertices.size() || tri.idx1 >= _vertices.size() || + tri.idx2 >= _vertices.size()) + continue; + float t = 0.0f; + if (RayTriangleIntersect(rayStart, rayDir, _vertices[tri.idx0], + _vertices[tri.idx1], _vertices[tri.idx2], t)) { + if (t < hitDist) { + hitDist = t; + hit = true; + } + } + } + return hit && hitDist < maxDist; +} + +float GroupModelRuntime::GetHeightAt(float x, float y) const { + Vec3 rayStart = {x, y, 10000.0f}; + Vec3 rayDir = {0.0f, 0.0f, -1.0f}; + float hitDist = 20000.0f; + if (RayIntersects(rayStart, rayDir, 20000.0f, hitDist)) + return 10000.0f - hitDist; + return -1e30f; +} + +// --- WorldModelRuntime --- + +bool WorldModelRuntime::Read(std::vector const& data) { + uint8_t const* buf = data.data(); + size_t const size = data.size(); + size_t offset = 8; + + while (offset + 4 <= size) { + char tag[5] = {}; + std::memcpy(tag, buf + offset, 4); + offset += 4; + + if (std::memcmp(tag, "WMOD", 4) == 0) { + if (offset + 4 > size) + return false; + uint32_t chunkSize = ReadU32(buf, offset); + if (offset + 4 > size) + return false; + _rootWMOID = ReadU32(buf, offset); + offset += 4; + } else if (std::memcmp(tag, "GMOD", 4) == 0) { + if (offset + 4 > size) + return false; + uint32_t chunkSize = ReadU32(buf, offset); + uint32_t count = ReadU32(buf, offset); + _groups.resize(count); + size_t gmodEnd = offset + chunkSize - 4; + for (uint32_t i = 0; i < count && offset < gmodEnd; ++i) { + _groups[i].Read(data, offset); + } + offset = gmodEnd; + } else if (std::memcmp(tag, "GBIH", 4) == 0) { + uint32_t chunkSize = ReadU32(buf, offset); + offset += chunkSize; + } else { + if (offset + 4 > size) + return false; + uint32_t chunkSize = ReadU32(buf, offset); + offset += chunkSize; + } + } + return true; +} + +// --- BoundingIntervalHierarchy --- + +bool BoundingIntervalHierarchy::Read(std::vector const& data, + size_t& offset) { + uint8_t const* buf = data.data(); + size_t const size = data.size(); + + if (offset + 36 > size) + return false; + _bounds.lo = ReadVec3(buf, offset); + _bounds.hi = ReadVec3(buf, offset); + + uint32_t treeSize = ReadU32(buf, offset); + _tree.resize(treeSize); + for (uint32_t i = 0; i < treeSize; ++i) { + _tree[i] = ReadU32(buf, offset); + } + + _objCount = ReadU32(buf, offset); + _objects.resize(_objCount); + for (uint32_t i = 0; i < _objCount; ++i) { + _objects[i] = ReadU32(buf, offset); + } + return true; +} + +bool BoundingIntervalHierarchy::ReadFromData(uint8_t const* data, + size_t dataSize) { + size_t offset = 0; + std::vector wrapper; + wrapper.assign(data, data + dataSize); + return Read(wrapper, offset); +} + +void BoundingIntervalHierarchy::BuildRecursive( + uint32_t const* tree, std::vector& stacked, + uint32_t nodeOffset) const { + if (nodeOffset + 2 >= _tree.size()) + return; + + BIHNode node = BIHNode::Read(tree, nodeOffset); + if (node.IsLeaf()) { + for (uint32_t i = 0; i < node.leafCount; ++i) { + uint32_t idx = node.childOffset + i; + if (idx < _objects.size()) + stacked.push_back(_objects[idx]); + } + } else { + BuildRecursive(tree, stacked, nodeOffset + 3); + if (node.bvh2) { + BuildRecursive(tree, stacked, nodeOffset + 9); + } else { + BuildRecursive(tree, stacked, node.childOffset); + } + } +} + +float BoundingIntervalHierarchy::RayIntersectInternal( + uint32_t const* tree, std::vector const& verts, + std::vector const& tris, Vec3 const& rayStart, + Vec3 const& rayDir, float maxDist, float& hitDist, bool stopAtFirst, + uint32_t nodeOffset) const { + + if (nodeOffset + 2 >= _tree.size()) + return hitDist; + + BIHNode node = BIHNode::Read(tree, nodeOffset); + + if (node.IsLeaf()) { + GroupModelRuntime tmp; + for (uint32_t i = 0; i < node.leafCount; ++i) { + uint32_t idx = node.childOffset + i; + if (idx >= _objects.size()) + continue; + uint32_t triIdx = _objects[idx]; + if (triIdx >= tris.size()) + continue; + auto const& tri = tris[triIdx]; + if (tri.idx0 >= verts.size() || tri.idx1 >= verts.size() || + tri.idx2 >= verts.size()) + continue; + float t = 0.0f; + if (tmp.RayTriangleIntersect(rayStart, rayDir, verts[tri.idx0], + verts[tri.idx1], verts[tri.idx2], t)) { + if (t > 0.0f && t < hitDist) { + hitDist = t; + if (stopAtFirst) + return hitDist; + } + } + } + return hitDist; + } + + float planeDist = node.bvh2 ? node.planeL : 0.0f; + float startDist = 0.0f; + float endDist = maxDist; + + if (node.axis < 3) { + float origin = (&rayStart.x)[node.axis]; + float direction = (&rayDir.x)[node.axis]; + if (std::abs(direction) > 1e-8f) { + startDist = (planeDist - origin) / direction; + endDist = startDist; + } else { + if (origin < planeDist) { + startDist = -1e30f; + endDist = 1e30f; + } + } + } + + uint32_t nearChild = nodeOffset + 3; + uint32_t farChild = node.bvh2 ? nodeOffset + 9 : node.childOffset; + + if (startDist >= 0.0f || endDist < 0.0f) { + hitDist = RayIntersectInternal(tree, verts, tris, rayStart, rayDir, maxDist, + hitDist, stopAtFirst, nearChild); + if (stopAtFirst && hitDist < maxDist) + return hitDist; + hitDist = RayIntersectInternal(tree, verts, tris, rayStart, rayDir, maxDist, + hitDist, stopAtFirst, farChild); + } else { + hitDist = RayIntersectInternal(tree, verts, tris, rayStart, rayDir, maxDist, + hitDist, stopAtFirst, farChild); + if (stopAtFirst && hitDist < maxDist) + return hitDist; + hitDist = RayIntersectInternal(tree, verts, tris, rayStart, rayDir, maxDist, + hitDist, stopAtFirst, nearChild); + } + return hitDist; +} + +void BoundingIntervalHierarchy::RayIntersect( + std::vector const& vertices, + std::vector const& triangles, Vec3 const& rayStart, + Vec3 const& rayDir, float maxDist, float& hitDist, + bool stopAtFirst) const { + RayIntersectInternal(_tree.data(), vertices, triangles, rayStart, rayDir, + maxDist, hitDist, stopAtFirst, 0); +} + +float BoundingIntervalHierarchy::GetHeightAt( + std::vector const& vertices, + std::vector const& triangles, float x, float y) const { + + Vec3 rayStart = {x, y, _bounds.hi.z + 100.0f}; + Vec3 rayDir = {0.0f, 0.0f, -1.0f}; + float hitDist = (_bounds.hi.z - _bounds.lo.z) + 200.0f; + + RayIntersectInternal(_tree.data(), vertices, triangles, rayStart, rayDir, + hitDist, hitDist, true, 0); + if (hitDist < (_bounds.hi.z - _bounds.lo.z) + 200.0f) + return _bounds.hi.z + 100.0f - hitDist; + return -1e30f; +} + +BIHNode BIHNode::Read(uint32_t const* treeData, uint32_t index) { + BIHNode node; + uint32_t header = treeData[index]; + node.axis = (header >> 30) & 0x3; + node.bvh2 = (header >> 29) & 0x1; + node.childOffset = header & 0x1FFFFFFF; + + if (node.IsLeaf()) { + node.leafCount = treeData[index + 1]; + } else if (node.bvh2) { + std::memcpy(&node.planeL, &treeData[index + 1], 4); + std::memcpy(&node.planeR, &treeData[index + 2], 4); + } + return node; +} + +} // namespace Firelands diff --git a/src/infrastructure/collision/WorldModelRuntime.h b/src/infrastructure/collision/WorldModelRuntime.h new file mode 100644 index 0000000..d3c5575 --- /dev/null +++ b/src/infrastructure/collision/WorldModelRuntime.h @@ -0,0 +1,106 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_COLLISION_WORLD_MODEL_RUNTIME_H +#define FIRELANDS_INFRASTRUCTURE_COLLISION_WORLD_MODEL_RUNTIME_H + +#include +#include +#include +#include + +namespace Firelands { + +struct AaBox3 { + Vec3 lo; + Vec3 hi; +}; + +struct MeshTriangle { + uint32_t idx0 = 0; + uint32_t idx1 = 0; + uint32_t idx2 = 0; +}; + +struct ModelSpawn { + uint8_t flags = 0; + uint8_t adtId = 0; + uint32_t ID = 0; + Vec3 iPos; + Vec3 iRot; + float iScale = 1.0f; + AaBox3 iBound; + std::string name; +}; + +class GroupModelRuntime { +public: + bool Read(std::vector const& data, size_t& offset); + bool RayIntersects(Vec3 const& rayStart, Vec3 const& rayDir, + float maxDist, float& hitDist) const; + float GetHeightAt(float x, float y) const; + AaBox3 const& GetBounds() const { return _bound; } + +private: + AaBox3 _bound; + uint32_t _mogpFlags = 0; + uint32_t _groupWMOID = 0; + std::vector _vertices; + std::vector _triangles; + bool RayTriangleIntersect(Vec3 const& orig, Vec3 const& dir, + Vec3 const& v0, Vec3 const& v1, + Vec3 const& v2, float& t) const; +}; + +class WorldModelRuntime { +public: + bool Read(std::vector const& data); + uint32_t GetRootWMOID() const { return _rootWMOID; } + std::vector const& GetGroups() const { return _groups; } + +private: + uint32_t _rootWMOID = 0; + std::vector _groups; +}; + +struct BIHNode { + static BIHNode Read(uint32_t const* treeData, uint32_t index); + bool IsLeaf() const { return axis == 3; } + uint32_t axis = 0; + uint32_t childOffset = 0; + uint32_t leafCount = 0; + bool bvh2 = false; + float planeL = 0.0f; + float planeR = 0.0f; +}; + +class BoundingIntervalHierarchy { +public: + bool Read(std::vector const& data, size_t& offset); + bool ReadFromData(uint8_t const* data, size_t dataSize); + void RayIntersect(std::vector const& vertices, + std::vector const& triangles, + Vec3 const& rayStart, Vec3 const& rayDir, + float maxDist, float& hitDist, bool stopAtFirst) const; + float GetHeightAt(std::vector const& vertices, + std::vector const& triangles, + float x, float y) const; + AaBox3 const& GetBounds() const { return _bounds; } + uint32_t GetObjectCount() const { return _objCount; } + uint32_t GetObjectIndex(uint32_t i) const { return _objects[i]; } + +private: + AaBox3 _bounds; + std::vector _tree; + std::vector _objects; + uint32_t _objCount = 0; + + float RayIntersectInternal(uint32_t const* tree, std::vector const& verts, + std::vector const& tris, + Vec3 const& rayStart, Vec3 const& rayDir, + float maxDist, float& hitDist, + bool stopAtFirst, uint32_t nodeOffset) const; + void BuildRecursive(uint32_t const* tree, std::vector& stacked, + uint32_t nodeOffset) const; +}; + +} // namespace Firelands + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d6507a8..23be6c4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -112,6 +112,7 @@ add_executable(FirelandsUnitTests unit/infrastructure/LuaGameScriptHostTests.cpp unit/infrastructure/MapAuraTickerTests.cpp unit/infrastructure/MapCollisionQueriesStubTests.cpp + unit/infrastructure/VMapManager2Tests.cpp unit/infrastructure/SpellCastTablesSpellPowerTests.cpp unit/infrastructure/SpellCastTablesDurationTests.cpp # TODO: fix missing headers for vmap/tools tests diff --git a/tests/unit/infrastructure/VMapManager2Tests.cpp b/tests/unit/infrastructure/VMapManager2Tests.cpp new file mode 100644 index 0000000..bab17d3 --- /dev/null +++ b/tests/unit/infrastructure/VMapManager2Tests.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +#include +#include + +using namespace Firelands; + +namespace { + +std::vector MakeMinimalVmoData() { + // Build a synthetic .vmo with one GroupModel containing a single triangle + struct WriteBuf { + std::vector buf; + void WriteU32(uint32_t v) { + buf.push_back(v & 0xFF); + buf.push_back((v >> 8) & 0xFF); + buf.push_back((v >> 16) & 0xFF); + buf.push_back((v >> 24) & 0xFF); + } + void WriteF32(float v) { + uint32_t i; + std::memcpy(&i, &v, 4); + WriteU32(i); + } + void WriteTag(const char* tag) { + for (int i = 0; i < 4; ++i) buf.push_back(tag[i]); + } + }; + + WriteBuf w; + w.WriteTag("VMAP_4.8"); + + // WMOD chunk: RootWMOID=100, flags=0 + w.WriteTag("WMOD"); + w.WriteU32(8); + w.WriteU32(100); + w.WriteU32(0); + + // GMOD chunk: 1 group + w.WriteTag("GMOD"); + size_t gmodSizePos = w.buf.size(); + w.WriteU32(0); + w.WriteU32(1); // count + + // GroupModel: bound, mogpFlags, groupWMOID + // AaBox3 lo: (-10, -10, -1), hi: (10, 10, 3) + w.WriteF32(-10.0f); w.WriteF32(-10.0f); w.WriteF32(-1.0f); + w.WriteF32(10.0f); w.WriteF32(10.0f); w.WriteF32(3.0f); + w.WriteU32(0); // mogpFlags + w.WriteU32(200); // groupWMOID + + // VERT chunk: 3 vertices (a triangle on the ground) + w.WriteTag("VERT"); + w.WriteU32(4 + 4 + 3*12); // chunkSize = uint32 count + 3 * Vec3 + w.WriteU32(3); // vertexCount + w.WriteF32(0.0f); w.WriteF32(0.0f); w.WriteF32(0.0f); // v0 + w.WriteF32(10.0f); w.WriteF32(0.0f); w.WriteF32(0.0f); // v1 + w.WriteF32(0.0f); w.WriteF32(10.0f); w.WriteF32(0.0f); // v2 + + // TRIM chunk: 1 triangle + w.WriteTag("TRIM"); + w.WriteU32(4 + 4 + 3*4); // chunkSize = uint32 count + MeshTriangle + w.WriteU32(1); // triCount + w.WriteU32(0); w.WriteU32(1); w.WriteU32(2); + + // MBIH chunk: minimal BIH + w.WriteTag("MBIH"); + w.WriteU32(24 + 4 + 4*3 + 4 + 4); // chunkSize + // bounds + w.WriteF32(-10.0f); w.WriteF32(-10.0f); w.WriteF32(-1.0f); + w.WriteF32(10.0f); w.WriteF32(10.0f); w.WriteF32(3.0f); + // treeSize=3 (leaf node) + w.WriteU32(3); + // node[0] = leaf (axis=3, childOffset=0) + // bits 31-30=3, bit29=0, bits28-0=0 + w.WriteU32((3u << 30) | 0u); + w.WriteU32(1); // leafCount=1 + w.WriteU32(0); // padding + // object count = 1 + w.WriteU32(1); + w.WriteU32(0); // object[0]=0 (index into triangles) + + // LIQU chunk: empty + w.WriteTag("LIQU"); + w.WriteU32(0); + + // Fix GMOD chunkSize + uint32_t gmodSize = static_cast(w.buf.size() - gmodSizePos - 4); + w.buf[gmodSizePos + 0] = gmodSize & 0xFF; + w.buf[gmodSizePos + 1] = (gmodSize >> 8) & 0xFF; + w.buf[gmodSizePos + 2] = (gmodSize >> 16) & 0xFF; + w.buf[gmodSizePos + 3] = (gmodSize >> 24) & 0xFF; + + return w.buf; +} + +std::vector MakeMinimalVmoWithoutGroups() { + struct WriteBuf { + std::vector buf; + void WriteU32(uint32_t v) { + buf.push_back(v & 0xFF); + buf.push_back((v >> 8) & 0xFF); + buf.push_back((v >> 16) & 0xFF); + buf.push_back((v >> 24) & 0xFF); + } + void WriteTag(const char* tag) { + for (int i = 0; i < 4; ++i) buf.push_back(tag[i]); + } + }; + WriteBuf w; + w.WriteTag("VMAP_4.8"); + w.WriteTag("WMOD"); + w.WriteU32(8); + w.WriteU32(42); + w.WriteU32(0); + return w.buf; +} + +} // namespace + +TEST(WorldModelRuntime, ReadsMinimalVmo) { + auto data = MakeMinimalVmoData(); + WorldModelRuntime model; + ASSERT_TRUE(model.Read(data)); + EXPECT_EQ(model.GetRootWMOID(), 100u); + ASSERT_EQ(model.GetGroups().size(), 1u); + EXPECT_EQ(model.GetGroups()[0].GetBounds().lo.x, -10.0f); + EXPECT_EQ(model.GetGroups()[0].GetBounds().hi.z, 3.0f); +} + +TEST(WorldModelRuntime, VmoWithoutGroupsParsesCorrectly) { + auto data = MakeMinimalVmoWithoutGroups(); + WorldModelRuntime model; + ASSERT_TRUE(model.Read(data)); + EXPECT_EQ(model.GetRootWMOID(), 42u); + EXPECT_TRUE(model.GetGroups().empty()); +} + +TEST(GroupModelRuntime, RayHitsTriangle) { + auto data = MakeMinimalVmoData(); + WorldModelRuntime model; + ASSERT_TRUE(model.Read(data)); + ASSERT_EQ(model.GetGroups().size(), 1u); + + auto const& group = model.GetGroups()[0]; + Vec3 start = {3.0f, 3.0f, 10.0f}; + Vec3 dir = {0.0f, 0.0f, -1.0f}; + float hitDist = 100.0f; + EXPECT_TRUE(group.RayIntersects(start, dir, 100.0f, hitDist)); + EXPECT_NEAR(hitDist, 10.0f, 0.1f); +} + +TEST(GroupModelRuntime, RayMissesTriangleFromBelow) { + auto data = MakeMinimalVmoData(); + WorldModelRuntime model; + ASSERT_TRUE(model.Read(data)); + auto const& group = model.GetGroups()[0]; + Vec3 start = {3.0f, 3.0f, -5.0f}; + Vec3 dir = {0.0f, 0.0f, -1.0f}; + float hitDist = 100.0f; + EXPECT_FALSE(group.RayIntersects(start, dir, 100.0f, hitDist)); +} + +TEST(GroupModelRuntime, GetHeightAtReturnsGroundZ) { + auto data = MakeMinimalVmoData(); + WorldModelRuntime model; + ASSERT_TRUE(model.Read(data)); + auto const& group = model.GetGroups()[0]; + float h = group.GetHeightAt(3.0f, 3.0f); + EXPECT_NEAR(h, 0.0f, 0.1f); +} + +TEST(BoundingIntervalHierarchy, ReadsAndIntersects) { + auto data = MakeMinimalVmoData(); + WorldModelRuntime model; + ASSERT_TRUE(model.Read(data)); + auto const& group = model.GetGroups()[0]; + + (void)group; // BIH is embedded in the group model, tested via RayIntersects + SUCCEED(); +} + +TEST(VMapManager2, IsMapLoadedReturnsFalseForUnloadedMap) { + VMapManager2 mgr; + EXPECT_FALSE(mgr.IsMapLoaded(0)); + EXPECT_FALSE(mgr.IsMapLoaded(530)); +} From e54524bbdf1f265b0493d07320947e9a548fcd9d Mon Sep 17 00:00:00 2001 From: HkevinH Date: Fri, 29 May 2026 22:55:16 -0500 Subject: [PATCH 03/35] feat(collision): enhance pathfinding with improved query filtering and add C++17 support for dependencies --- CMakeLists.txt | 17 +++++++++++++++-- .../collision/DetourNavMeshManager.cpp | 17 +++++++++-------- src/infrastructure/collision/VMapManager2.cpp | 16 ++++++++-------- .../collision/WorldModelRuntime.h | 6 +++--- .../worldsession/WorldSessionCombat.cpp | 2 +- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ec48ee6..494e167 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(Firelands CXX) +project(Firelands CXX C) # --- Policies --- if(POLICY CMP0135) @@ -13,6 +13,8 @@ set(CMAKE_POLICY_VERSION_MINIMUM 3.10) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # spdlog 1.14.x bundled fmt breaks under C++20 (FMT_STRING / consteval on Apple Clang). @@ -132,6 +134,8 @@ FetchContent_MakeAvailable(json) FetchContent_Declare(yaml-cpp URL https://github.com/jbeder/yaml-cpp/archive/refs/tags/0.8.0.zip) set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(yaml-cpp) +# yaml-cpp 0.8.0 missing with C++20 – force include it +target_compile_options(yaml-cpp PRIVATE -include cstdint) # Lua FetchContent_Declare(lua_cmake URL https://github.com/walterschell/Lua/archive/refs/tags/v5.4.7.zip) @@ -140,13 +144,16 @@ FetchContent_MakeAvailable(lua_cmake) # RecastNavigation (Recast + Detour for navmesh pathfinding) FetchContent_Declare(recastnavigation GIT_REPOSITORY https://github.com/recastnavigation/recastnavigation.git - GIT_TAG 9a0c2173b6a121db4414d0e4cf8899aeaa7faed7 + GIT_TAG main ) set(RECASTNAVIGATION_DEMO OFF CACHE BOOL "" FORCE) set(RECASTNAVIGATION_TESTS OFF CACHE BOOL "" FORCE) set(RECASTNAVIGATION_STATIC ON CACHE BOOL "" FORCE) set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(recastnavigation) +# RecastNavigation defaults to C++98 which breaks with GCC 16 . +# Force C++17 for Recast (it's a static lib, no ABI concerns). +set_target_properties(Recast PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) # MariaDB Connector/C++ FetchContent_Declare(mariadb-connector-cpp URL https://github.com/mariadb-corporation/mariadb-connector-cpp/archive/refs/tags/1.1.7.zip) @@ -159,6 +166,8 @@ set(BUILD_TESTING OFF CACHE BOOL "" FORCE) set(BUILD_TESTS_ONLY OFF CACHE BOOL "" FORCE) include_directories(SYSTEM ${mariadb-connector-c_SOURCE_DIR}/include ${mariadb-connector-c_BINARY_DIR}/include) add_subdirectory(${mariadb-connector-cpp_SOURCE_DIR} ${mariadb-connector-cpp_BINARY_DIR}) +# MariaDB Connector/C++ 1.1.7 missing with new GCC (C++ only) +target_compile_options(mariadbcpp_obj PRIVATE $<$:-include> $<$:cstdint>) if(APPLE) set(MARIADB_EXTRA_LIBS "-framework CoreFoundation -framework Security") @@ -197,7 +206,9 @@ target_link_libraries(auth PRIVATE OpenSSL::SSL PRIVATE OpenSSL::Crypto PRIVATE Boost::thread + PRIVATE mswsock ) +target_link_options(auth PRIVATE -static-libstdc++ -static-libgcc) # WORLD SERVER add_executable(world @@ -218,7 +229,9 @@ target_link_libraries(world PRIVATE OpenSSL::SSL PRIVATE OpenSSL::Crypto PRIVATE Boost::thread + PRIVATE mswsock ) +target_link_options(world PRIVATE -static-libstdc++ -static-libgcc) # DEVTOOLS CLI add_executable(FirelandsDevTools diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index 49da279..c271ed6 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -229,17 +229,22 @@ FindPathResult DetourNavMeshManager::FindPath( dtPolyRef endRef = 0; float startNearest[3]{}; float endNearest[3]{}; + float const startPos[3] = {req.startX, req.startY, req.startZ}; + float const endPos[3] = {req.endX, req.endY, req.endZ}; + + dtQueryFilter filter; + filter.setIncludeFlags(0xFFFF); + filter.setExcludeFlags(0); dtStatus status = navQuery->findNearestPoly( - &req.startX, &req.startY, &req.startZ, searchExtents, nullptr, &startRef, - startNearest); + startPos, searchExtents, &filter, &startRef, startNearest); if (dtStatusFailed(status) || startRef == 0) { result.status = FindPathStatus::NoPath; return result; } - status = navQuery->findNearestPoly(&req.endX, &req.endY, &req.endZ, - searchExtents, nullptr, &endRef, endNearest); + status = navQuery->findNearestPoly(endPos, searchExtents, &filter, + &endRef, endNearest); if (dtStatusFailed(status) || endRef == 0) { result.status = FindPathStatus::NoPath; return result; @@ -252,10 +257,6 @@ FindPathResult DetourNavMeshManager::FindPath( dtPolyRef straightPathPolys[256]; int straightPathCount = 0; - dtQueryFilter filter; - filter.setIncludeFlags(0xFFFF); - filter.setExcludeFlags(0); - status = navQuery->findPath(startRef, endRef, startNearest, endNearest, &filter, pathPolys, &pathCount, _config.maxPathPolys); diff --git a/src/infrastructure/collision/VMapManager2.cpp b/src/infrastructure/collision/VMapManager2.cpp index 572d940..37859f1 100644 --- a/src/infrastructure/collision/VMapManager2.cpp +++ b/src/infrastructure/collision/VMapManager2.cpp @@ -58,10 +58,10 @@ struct VMapManager2::LoadedTile { tileX = tx; tileY = ty; - std::string tilePath = std::filesystem::path(dataRoot) / "vmaps" / - (std::to_string(mapId) + "_" + - std::to_string(tileY) + "_" + - std::to_string(tileX) + ".vmtile"); + std::string tilePath = (std::filesystem::path(dataRoot) / "vmaps" / + (std::to_string(mapId) + "_" + + std::to_string(tileY) + "_" + + std::to_string(tileX) + ".vmtile")).string(); auto tileData = ReadEntireFile(tilePath); if (tileData.size() < 12) @@ -117,8 +117,8 @@ struct VMapManager2::LoadedTile { for (auto const& sp : spawns) { if (sp.name.empty()) continue; - std::string modelPath = std::filesystem::path(dataRoot) / "vmaps" / - (sp.name + ".vmo"); + std::string modelPath = (std::filesystem::path(dataRoot) / "vmaps" / + (sp.name + ".vmo")).string(); auto modelData = ReadEntireFile(modelPath); if (modelData.empty()) continue; @@ -144,8 +144,8 @@ struct VMapManager2::LoadedMap { mapId = id; dataRoot = root; - std::string treePath = std::filesystem::path(dataRoot) / "vmaps" / - (std::to_string(mapId) + ".vmtree"); + std::string treePath = (std::filesystem::path(dataRoot) / "vmaps" / + (std::to_string(mapId) + ".vmtree")).string(); auto treeData = ReadEntireFile(treePath); if (treeData.size() < 16) return false; diff --git a/src/infrastructure/collision/WorldModelRuntime.h b/src/infrastructure/collision/WorldModelRuntime.h index d3c5575..c870821 100644 --- a/src/infrastructure/collision/WorldModelRuntime.h +++ b/src/infrastructure/collision/WorldModelRuntime.h @@ -37,6 +37,9 @@ class GroupModelRuntime { float maxDist, float& hitDist) const; float GetHeightAt(float x, float y) const; AaBox3 const& GetBounds() const { return _bound; } + bool RayTriangleIntersect(Vec3 const& orig, Vec3 const& dir, + Vec3 const& v0, Vec3 const& v1, + Vec3 const& v2, float& t) const; private: AaBox3 _bound; @@ -44,9 +47,6 @@ class GroupModelRuntime { uint32_t _groupWMOID = 0; std::vector _vertices; std::vector _triangles; - bool RayTriangleIntersect(Vec3 const& orig, Vec3 const& dir, - Vec3 const& v0, Vec3 const& v1, - Vec3 const& v2, float& t) const; }; class WorldModelRuntime { diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index e276889..9bdfda8 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -347,7 +347,7 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, bool const useNavMesh = collisionQueries && collisionQueries->IsNavMeshDataAvailable(map->GetMapId()); - CreatureChaseStepResult projected; + application::combat::CreatureChaseStepResult projected; if (useNavMesh && !returnHomeSpline) { projected = application::combat::StepCreatureAlongNavMeshPath( from, targetX, targetY, targetZ, kCreatureSplineHorizonSeconds, config, From 08e227c2e6306f462bf92abab7a0881168ed66dd Mon Sep 17 00:00:00 2001 From: HkevinH Date: Fri, 29 May 2026 22:55:25 -0500 Subject: [PATCH 04/35] fix(mmap_generator): update file path handling and include DetourNavMeshBuilder --- tools/vmap/mmap_generator/CMakeLists.txt | 2 +- tools/vmap/mmap_generator/MmapGenerator.cpp | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/vmap/mmap_generator/CMakeLists.txt b/tools/vmap/mmap_generator/CMakeLists.txt index 76a4312..b8f47ef 100644 --- a/tools/vmap/mmap_generator/CMakeLists.txt +++ b/tools/vmap/mmap_generator/CMakeLists.txt @@ -12,4 +12,4 @@ target_link_libraries(firelands-mmap-generator PRIVATE Detour ) -target_precompile_headers(firelands-mmap-generator PRIVATE ${PROJECT_PCH_HEADERS}) +set_target_properties(firelands-mmap-generator PROPERTIES SKIP_PRECOMPILE_HEADERS ON) diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index 2090683..abd66c9 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -67,10 +68,10 @@ MmapGenerator::MmapGenerator(MmapGeneratorConfig config) bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, TileTerrainData& out) const { std::string const fileName = - std::filesystem::path(_config.mapsDir) / - (std::to_string(_config.mapId) + - std::to_string(tileY) + - std::to_string(tileX) + ".map"); + (std::filesystem::path(_config.mapsDir) / + (std::to_string(_config.mapId) + + std::to_string(tileY) + + std::to_string(tileX) + ".map")).string(); FILE* file = fopen(fileName.c_str(), "rb"); if (!file) From d65d970de75c6af9d23e2714cc3534c7030a80ff Mon Sep 17 00:00:00 2001 From: HkevinH Date: Fri, 29 May 2026 23:18:04 -0500 Subject: [PATCH 05/35] refactor(mmap_generator): enhance SaveNavMeshTile function and update terrain data loading --- tools/vmap/map_extractor/WdtReader.cpp | 10 ++++++++-- tools/vmap/mmap_generator/MmapGenerator.cpp | 21 ++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/tools/vmap/map_extractor/WdtReader.cpp b/tools/vmap/map_extractor/WdtReader.cpp index d90b75f..cb12909 100644 --- a/tools/vmap/map_extractor/WdtReader.cpp +++ b/tools/vmap/map_extractor/WdtReader.cpp @@ -4,8 +4,14 @@ namespace Firelands::VMap::MapExtractor { -static constexpr uint32_t kTagMVER = 0x4D524556u; // 'REVM' (reversed) "MVER" -static constexpr uint32_t kTagMAIN = 0x4E49414Du; // 'NIAM' (reversed) "MAIN" +static constexpr uint32_t RawTag(char a, char b, char c, char d) { + return (static_cast(d) << 24u) | + (static_cast(c) << 16u) | + (static_cast(b) << 8u) | + (static_cast(a)); +} +static constexpr uint32_t kTagMVER = RawTag('R','E','V','M'); +static constexpr uint32_t kTagMAIN = RawTag('N','I','A','M'); static inline uint32_t Read32(const uint8_t* p) { uint32_t v; std::memcpy(&v, p, 4); return v; diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index abd66c9..bfe0353 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include namespace Firelands { @@ -34,7 +36,8 @@ struct MmapTileHeader { unsigned char padding[3]; }; -void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t tileX, uint32_t tileY, +void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t mapId, + uint32_t tileX, uint32_t tileY, std::string const& outputPath) { dtMeshTile const* tile = navMesh->getTile(0); if (!tile || !tile->data || tile->dataSize == 0) @@ -47,9 +50,9 @@ void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t tileX, uint32_t tileY, header.usesLiquids = 0; std::string fileName = outputPath + "/" + - std::to_string(0) + "_" + - std::to_string(tileX) + "_" + - std::to_string(tileY) + ".mmtile"; + std::to_string(mapId) + "_" + + std::to_string(tileX) + "_" + + std::to_string(tileY) + ".mmtile"; FILE* file = fopen(fileName.c_str(), "wb"); if (!file) @@ -67,11 +70,11 @@ MmapGenerator::MmapGenerator(MmapGeneratorConfig config) bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, TileTerrainData& out) const { + std::ostringstream ss; + ss << std::setfill('0') << std::setw(3) << _config.mapId + << std::setw(2) << tileY << std::setw(2) << tileX << ".map"; std::string const fileName = - (std::filesystem::path(_config.mapsDir) / - (std::to_string(_config.mapId) + - std::to_string(tileY) + - std::to_string(tileX) + ".map")).string(); + (std::filesystem::path(_config.mapsDir) / ss.str()).string(); FILE* file = fopen(fileName.c_str(), "rb"); if (!file) @@ -305,7 +308,7 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, return false; } - SaveNavMeshTile(tileNavMesh, tileX, tileY, outputPath); + SaveNavMeshTile(tileNavMesh, _config.mapId, tileX, tileY, outputPath); dtFreeNavMesh(tileNavMesh); rcFreePolyMeshDetail(dmesh); From d8385a2040a58993fa6045240694f1d40f3c00f7 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Fri, 29 May 2026 23:59:57 -0500 Subject: [PATCH 06/35] feat(mmap_generator): enhance terrain data loading and add progress reporting for tile generation --- MmapGenerator.cpp | 346 ++++++++++++++++++++ tools/vmap/mmap_generator/MmapGenerator.cpp | 240 +++++++++----- tools/vmap/mmap_generator/main.cpp | 18 +- 3 files changed, 518 insertions(+), 86 deletions(-) create mode 100644 MmapGenerator.cpp diff --git a/MmapGenerator.cpp b/MmapGenerator.cpp new file mode 100644 index 0000000..49bed7f --- /dev/null +++ b/MmapGenerator.cpp @@ -0,0 +1,346 @@ +#include "MmapGenerator.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Firelands { + +namespace { + +constexpr float kTileSize = 533.33333f; +constexpr float kMapOrigin = -17066.66656f; + +float TileOriginX(uint32_t tileX) { + return kMapOrigin + static_cast(tileX) * kTileSize; +} + +float TileOriginY(uint32_t tileY) { + return kMapOrigin + static_cast(tileY) * kTileSize; +} + +struct MmapTileHeader { + uint32_t mmapMagic; + uint32_t dtVersion; + uint32_t mmapSize; + unsigned char usesLiquids; + unsigned char padding[3]; +}; + +void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t mapId, + uint32_t tileX, uint32_t tileY, + std::string const& outputPath) { + dtMeshTile const* tile = navMesh->getTile(0); + if (!tile || !tile->data || tile->dataSize == 0) + return; + + MmapTileHeader header{}; + header.mmapMagic = 'M' | ('M' << 8) | ('A' << 16) | ('P' << 24); + header.dtVersion = DT_NAVMESH_VERSION; + header.mmapSize = static_cast(tile->dataSize); + header.usesLiquids = 0; + + std::string fileName = outputPath + "/" + + std::to_string(mapId) + "_" + + std::to_string(tileX) + "_" + + std::to_string(tileY) + ".mmtile"; + + FILE* file = fopen(fileName.c_str(), "wb"); + if (!file) + return; + + fwrite(&header, sizeof(header), 1, file); + fwrite(tile->data, tile->dataSize, 1, file); + fclose(file); +} + +} // namespace + +MmapGenerator::MmapGenerator(MmapGeneratorConfig config) + : _config(std::move(config)) {} + +bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, + TileTerrainData& out) const { + std::ostringstream ss; + ss << std::setfill('0') << std::setw(3) << _config.mapId + << std::setw(2) << tileY << std::setw(2) << tileX << ".map"; + std::string const fileName = + (std::filesystem::path(_config.mapsDir) / ss.str()).string(); + + FILE* file = fopen(fileName.c_str(), "rb"); + if (!file) + return false; + + uint32_t mapMagic = 0; + if (fread(&mapMagic, 4, 1, file) != 1 || mapMagic != 0x5350414Du) { + fclose(file); + return false; + } + + uint32_t versionMagic = 0, buildMagic = 0; + uint32_t areaMapOffset = 0, areaMapSize = 0; + uint32_t heightMapOffset = 0, heightMapSize = 0; + uint32_t liquidMapOffset = 0, liquidMapSize = 0; + uint32_t holesOffset = 0, holesSize = 0; + fread(&versionMagic, 4, 1, file); + fread(&buildMagic, 4, 1, file); + fread(&areaMapOffset, 4, 1, file); + fread(&areaMapSize, 4, 1, file); + fread(&heightMapOffset, 4, 1, file); + fread(&heightMapSize, 4, 1, file); + fread(&liquidMapOffset, 4, 1, file); + fread(&liquidMapSize, 4, 1, file); + fread(&holesOffset, 4, 1, file); + fread(&holesSize, 4, 1, file); + (void)versionMagic; (void)buildMagic; + (void)areaMapOffset; (void)areaMapSize; + (void)liquidMapOffset; (void)liquidMapSize; + (void)holesOffset; (void)holesSize; + + fseek(file, static_cast(heightMapOffset), SEEK_SET); + + uint32_t heightFourcc = 0, heightFlags = 0; + float gridHeight = 0.0f, gridMaxHeight = 0.0f; + fread(&heightFourcc, 4, 1, file); + fread(&heightFlags, 4, 1, file); + fread(&gridHeight, 4, 1, file); + fread(&gridMaxHeight, 4, 1, file); + + constexpr int kGridSize = 128; + out.width = kGridSize; + out.height = kGridSize; + out.cellWidth = kTileSize / static_cast(kGridSize); + out.cellHeight = kTileSize / static_cast(kGridSize); + out.minX = TileOriginX(tileX); + out.minY = TileOriginY(tileY); + out.heights.resize(kGridSize * kGridSize); + + int const count = kGridSize * kGridSize; + if (heightFlags & 1) { + std::fill(out.heights.begin(), out.heights.end(), gridHeight); + } else if (heightFlags & 2) { + for (int i = 0; i < count; ++i) { + int16_t v = 0; fread(&v, 2, 1, file); + out.heights[i] = gridHeight + static_cast(v); + } + } else if (heightFlags & 4) { + for (int i = 0; i < count; ++i) { + int8_t v = 0; fread(&v, 1, 1, file); + out.heights[i] = gridHeight + static_cast(v); + } + } else { + for (int i = 0; i < count; ++i) { + float h = 0.0f; fread(&h, 4, 1, file); + out.heights[i] = h; + } + } + + fclose(file); + return true; +} + +bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, + uint32_t tileX, uint32_t tileY, + std::string const& outputPath) const { + float const bmin[3] = {terrain.minX, terrain.minY, -500.0f}; + float const bmax[3] = {terrain.minX + kTileSize, terrain.minY + kTileSize, 500.0f}; + + float const cellSize = std::max(1.5f, _config.cellSize); + float const cellHeight = std::max(0.3f, _config.cellHeight); + int const tileW = static_cast(kTileSize / cellSize + 0.5f); + int const tileH = static_cast(kTileSize / cellSize + 0.5f); + + rcContext ctx; + + rcHeightfield* solid = rcAllocHeightfield(); + if (!solid) { printf("A"); return false; } + if (!rcCreateHeightfield(&ctx, *solid, tileW, tileH, bmin, bmax, cellSize, cellHeight)) { + printf("B"); rcFreeHeightField(solid); return false; + } + + // Downsample terrain + std::vector hfVerts((tileW + 1) * (tileH + 1) * 3); + std::vector hfTris(tileW * tileH * 6); + std::vector triAreas(tileW * tileH * 2, 0); + + int vi = 0; + for (int ty = 0; ty <= tileH; ++ty) { + for (int tx = 0; tx <= tileW; ++tx) { + float wx = terrain.minX + static_cast(tx) * cellSize; + float wy = terrain.minY + static_cast(ty) * cellSize; + int sx = std::min(static_cast(tx * cellSize / terrain.cellWidth), terrain.width - 1); + int sy = std::min(static_cast(ty * cellSize / terrain.cellHeight), terrain.height - 1); + hfVerts[vi++] = wx; + hfVerts[vi++] = wy; + hfVerts[vi++] = terrain.heights[sy * terrain.width + sx]; + } + } + + int ti = 0; + for (int ty = 0; ty < tileH; ++ty) { + for (int tx = 0; tx < tileW; ++tx) { + int a = ty * (tileW + 1) + tx; + int b = a + 1; + int c = a + tileW + 1; + int d = c + 1; + hfTris[ti++] = a; hfTris[ti++] = c; hfTris[ti++] = b; + hfTris[ti++] = b; hfTris[ti++] = c; hfTris[ti++] = d; + } + } + + rcRasterizeTriangles(&ctx, hfVerts.data(), (tileW + 1) * (tileH + 1), + hfTris.data(), triAreas.data(), tileW * tileH * 2, *solid, 0); + + int const walkableClimb = std::max(1, static_cast(_config.agentMaxClimb / cellHeight)); + int const walkableHeight = std::max(1, static_cast(_config.agentHeight / cellHeight)); + + rcCompactHeightfield* chf = rcAllocCompactHeightfield(); + if (!chf) { printf("C"); rcFreeHeightField(solid); return false; } + if (!rcBuildCompactHeightfield(&ctx, walkableClimb, walkableHeight, *solid, *chf)) { + printf("D"); rcFreeCompactHeightfield(chf); rcFreeHeightField(solid); return false; + } + rcFreeHeightField(solid); + + printf(" spans=%d ", chf->spanCount); fflush(stdout); + if (chf->spanCount == 0) { printf("Q"); rcFreeCompactHeightfield(chf); return false; } + + int const erosionRadius = std::max(0, static_cast(_config.agentRadius / cellSize)); + if (!rcErodeWalkableArea(&ctx, erosionRadius, *chf)) { + printf("E"); rcFreeCompactHeightfield(chf); return false; + } + + if (!rcBuildRegionsMonotone(&ctx, *chf, 0, _config.minRegionArea, _config.mergeRegionArea)) { + printf("F"); rcFreeCompactHeightfield(chf); return false; + } + + rcContourSet* cset = rcAllocContourSet(); + if (!cset) { printf("G"); rcFreeCompactHeightfield(chf); return false; } + if (!rcBuildContours(&ctx, *chf, _config.maxSimplificationError, _config.maxEdgeLen, *cset)) { + printf("H"); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; + } + + rcPolyMesh* pmesh = rcAllocPolyMesh(); + if (!pmesh) { printf("I"); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } + if (!rcBuildPolyMesh(&ctx, *cset, _config.maxVertsPerPoly, *pmesh)) { + printf("J"); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; + } + + rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail(); + if (!dmesh) { printf("K"); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } + if (!rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, _config.detailSampleDist, + _config.detailSampleMaxError, *dmesh)) { + printf("L"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; + } + rcFreeCompactHeightfield(chf); + rcFreeContourSet(cset); + + for (int i = 0; i < pmesh->npolys; ++i) { + if (pmesh->areas[i] == RC_WALKABLE_AREA) + pmesh->flags[i] = 0x01; + } + + if (pmesh->npolys == 0) { + printf("Z"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; + } + + dtNavMeshCreateParams params{}; + std::memset(¶ms, 0, sizeof(params)); + params.verts = pmesh->verts; + params.vertCount = pmesh->nverts; + params.polys = pmesh->polys; + params.polyAreas = pmesh->areas; + params.polyFlags = pmesh->flags; + params.polyCount = pmesh->npolys; + params.nvp = pmesh->nvp; + params.detailMeshes = dmesh->meshes; + params.detailVerts = dmesh->verts; + params.detailVertsCount = dmesh->nverts; + params.detailTris = dmesh->tris; + params.detailTriCount = dmesh->ntris; + params.walkableHeight = _config.agentHeight; + params.walkableRadius = _config.agentRadius; + params.walkableClimb = _config.agentMaxClimb; + rcVcopy(params.bmin, pmesh->bmin); + rcVcopy(params.bmax, pmesh->bmax); + params.cs = cellSize; + params.ch = cellHeight; + params.buildBvTree = true; + + unsigned char* navData = nullptr; + int navDataSize = 0; + if (!dtCreateNavMeshData(¶ms, &navData, &navDataSize)) { + printf("M"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; + } + + dtNavMesh* tileNavMesh = dtAllocNavMesh(); + if (!tileNavMesh) { + printf("N"); dtFree(navData); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; + } + + dtNavMeshParams navParams{}; + rcVcopy(navParams.orig, bmin); + navParams.tileWidth = kTileSize; + navParams.tileHeight = kTileSize; + navParams.maxTiles = 1; + navParams.maxPolys = 1 << 16; + + dtStatus dtStatusFlags = tileNavMesh->init(&navParams); + if (dtStatusFailed(dtStatusFlags)) { + printf("O"); dtFreeNavMesh(tileNavMesh); dtFree(navData); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; + } + + dtStatusFlags = tileNavMesh->addTile(navData, navDataSize, DT_TILE_FREE_DATA, 0, nullptr); + if (dtStatusFailed(dtStatusFlags)) { + printf("P"); dtFreeNavMesh(tileNavMesh); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; + } + + SaveNavMeshTile(tileNavMesh, _config.mapId, tileX, tileY, outputPath); + + dtFreeNavMesh(tileNavMesh); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return true; +} + +bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { + TileTerrainData terrain; + if (!LoadTerrainData(tileX, tileY, terrain)) { + // Fallback: use flat terrain for testing + terrain.width = 64; + terrain.height = 64; + terrain.cellWidth = kTileSize / 64.0f; + terrain.cellHeight = kTileSize / 64.0f; + terrain.minX = TileOriginX(tileX); + terrain.minY = TileOriginY(tileY); + terrain.heights.assign(64 * 64, 0.0f); + } + + std::filesystem::create_directories(_config.mmapsDir); + return BuildTileNavMesh(terrain, tileX, tileY, _config.mmapsDir); +} + +bool MmapGenerator::GenerateAllTiles() { + bool anySuccess = false; + for (uint32_t tileY = 0; tileY < 64; ++tileY) { + for (uint32_t tileX = 0; tileX < 64; ++tileX) { + if (Generate(tileX, tileY)) { + anySuccess = true; + printf("."); fflush(stdout); + } + } + } + if (anySuccess) printf("\n"); + return anySuccess; +} + +} // namespace Firelands diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index bfe0353..869f3f9 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -5,7 +5,9 @@ #include #include +#include #include +#include #include #include #include @@ -63,6 +65,17 @@ void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t mapId, fclose(file); } +void PrintTileProgress(uint32_t tileX, uint32_t tileY, int percent, + char const* stage) { + printf("\r tile (%02u,%02u) [%3d%%] %-28s", tileX, tileY, percent, stage); + fflush(stdout); +} + +void PrintTileFailure(uint32_t tileX, uint32_t tileY, char const* stage) { + printf("\r tile (%02u,%02u) [FAIL] %s\n", tileX, tileY, stage); + fflush(stdout); +} + } // namespace MmapGenerator::MmapGenerator(MmapGeneratorConfig config) @@ -80,29 +93,67 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, if (!file) return false; - char magic[5] = {}; - if (fread(magic, 1, 4, file) != 4 || std::memcmp(magic, "MAPS", 4) != 0) { + uint32_t mapMagic = 0; + if (fread(&mapMagic, 4, 1, file) != 1 || mapMagic != 0x5350414Du) { fclose(file); return false; } - uint32_t header[3] = {}; - fread(header, sizeof(uint32_t), 3, file); - - out.cellWidth = kTileSize / static_cast(header[1]); - out.cellHeight = kTileSize / static_cast(header[2]); - out.width = static_cast(header[1]); - out.height = static_cast(header[2]); + uint32_t versionMagic = 0, buildMagic = 0; + uint32_t areaMapOffset = 0, areaMapSize = 0; + uint32_t heightMapOffset = 0, heightMapSize = 0; + uint32_t liquidMapOffset = 0, liquidMapSize = 0; + uint32_t holesOffset = 0, holesSize = 0; + fread(&versionMagic, 4, 1, file); + fread(&buildMagic, 4, 1, file); + fread(&areaMapOffset, 4, 1, file); + fread(&areaMapSize, 4, 1, file); + fread(&heightMapOffset, 4, 1, file); + fread(&heightMapSize, 4, 1, file); + fread(&liquidMapOffset, 4, 1, file); + fread(&liquidMapSize, 4, 1, file); + fread(&holesOffset, 4, 1, file); + fread(&holesSize, 4, 1, file); + (void)versionMagic; (void)buildMagic; + (void)areaMapOffset; (void)areaMapSize; + (void)liquidMapOffset; (void)liquidMapSize; + (void)holesOffset; (void)holesSize; + + fseek(file, static_cast(heightMapOffset), SEEK_SET); + + uint32_t heightFourcc = 0, heightFlags = 0; + float gridHeight = 0.0f, gridMaxHeight = 0.0f; + fread(&heightFourcc, 4, 1, file); + fread(&heightFlags, 4, 1, file); + fread(&gridHeight, 4, 1, file); + fread(&gridMaxHeight, 4, 1, file); + + constexpr int kGridSize = 128; + out.width = kGridSize; + out.height = kGridSize; + out.cellWidth = kTileSize / static_cast(kGridSize); + out.cellHeight = kTileSize / static_cast(kGridSize); out.minX = TileOriginX(tileX); out.minY = TileOriginY(tileY); - - out.heights.resize(out.width * out.height); - - for (int y = 0; y < out.height; ++y) { - for (int x = 0; x < out.width; ++x) { - float h = 0.0f; - fread(&h, sizeof(float), 1, file); - out.heights[y * out.width + x] = h; + out.heights.resize(kGridSize * kGridSize); + + int const count = kGridSize * kGridSize; + if (heightFlags & 1) { + std::fill(out.heights.begin(), out.heights.end(), gridHeight); + } else if (heightFlags & 2) { + for (int i = 0; i < count; ++i) { + int16_t v = 0; fread(&v, 2, 1, file); + out.heights[i] = gridHeight + static_cast(v); + } + } else if (heightFlags & 4) { + for (int i = 0; i < count; ++i) { + int8_t v = 0; fread(&v, 1, 1, file); + out.heights[i] = gridHeight + static_cast(v); + } + } else { + for (int i = 0; i < count; ++i) { + float h = 0.0f; fread(&h, 4, 1, file); + out.heights[i] = h; } } @@ -113,115 +164,111 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, uint32_t tileX, uint32_t tileY, std::string const& outputPath) const { - float const bmin[3] = {terrain.minX, terrain.minY, - -500.0f}; - float const bmax[3] = {terrain.minX + kTileSize, - terrain.minY + kTileSize, 500.0f}; + float const bmin[3] = {terrain.minX, terrain.minY, -500.0f}; + float const bmax[3] = {terrain.minX + kTileSize, terrain.minY + kTileSize, 500.0f}; - int const tileWidth = - static_cast(kTileSize / _config.cellSize + 0.5f); - int const tileHeight = - static_cast(kTileSize / _config.cellSize + 0.5f); + float const cellSize = std::max(1.5f, _config.cellSize); + float const cellHeight = std::max(0.3f, _config.cellHeight); + int const tileW = static_cast(kTileSize / cellSize + 0.5f); + int const tileH = static_cast(kTileSize / cellSize + 0.5f); rcContext ctx; rcHeightfield* solid = rcAllocHeightfield(); - if (!solid) + if (!solid) { + PrintTileFailure(tileX, tileY, "could not allocate heightfield"); return false; - if (!rcCreateHeightfield(&ctx, *solid, tileWidth, tileHeight, bmin, bmax, - _config.cellSize, _config.cellHeight)) { + } + if (!rcCreateHeightfield(&ctx, *solid, tileW, tileH, bmin, bmax, cellSize, cellHeight)) { + PrintTileFailure(tileX, tileY, "could not create heightfield"); rcFreeHeightField(solid); return false; } - - unsigned char* triAreas = new unsigned char[terrain.width * terrain.height * 2]; - std::memset(triAreas, 0, terrain.width * terrain.height * 2); - - std::vector verts; - verts.reserve(terrain.width * terrain.height * 3); - for (int y = 0; y < terrain.height; ++y) { - for (int x = 0; x < terrain.width; ++x) { - float wx = terrain.minX + static_cast(x) * terrain.cellWidth; - float wy = terrain.minY + static_cast(y) * terrain.cellHeight; - float h = terrain.heights[y * terrain.width + x]; - verts.push_back(wx); - verts.push_back(wy); - verts.push_back(h); + PrintTileProgress(tileX, tileY, 15, "heightfield ready"); + + int const walkableClimb = std::max(1, static_cast(_config.agentMaxClimb / cellHeight)); + int const walkableHeight = std::max(1, static_cast(_config.agentHeight / cellHeight)); + + // Seed a walkable plane so Recast has spans to compact into polygons. + int spanCount = 0; + for (int y = 0; y < solid->height; ++y) { + for (int x = 0; x < solid->width; ++x) { + if (rcAddSpan(&ctx, *solid, x, y, 0, static_cast(walkableHeight), + RC_WALKABLE_AREA, 1)) + spanCount++; } } - - std::vector tris; - for (int y = 0; y < terrain.height - 1; ++y) { - for (int x = 0; x < terrain.width - 1; ++x) { - int const a = y * terrain.width + x; - int const b = y * terrain.width + x + 1; - int const c = (y + 1) * terrain.width + x; - int const d = (y + 1) * terrain.width + x + 1; - tris.push_back(a); tris.push_back(c); tris.push_back(b); - tris.push_back(b); tris.push_back(c); tris.push_back(d); - } - } - - rcRasterizeTriangles(&ctx, verts.data(), static_cast(verts.size()) / 3, - tris.data(), triAreas, - static_cast(tris.size()) / 3, *solid, 0); - delete[] triAreas; - - rcFilterLowHangingWalkableObstacles(&ctx, static_cast(_config.agentMaxClimb / _config.cellHeight), *solid); - rcFilterLedgeSpans(&ctx, static_cast(_config.agentHeight / _config.cellHeight), - static_cast(_config.agentMaxClimb / _config.cellHeight), *solid); - rcFilterWalkableLowHeightSpans(&ctx, static_cast(_config.agentHeight / _config.cellHeight), *solid); + PrintTileProgress(tileX, tileY, 30, "walkable spans added"); rcCompactHeightfield* chf = rcAllocCompactHeightfield(); if (!chf) { + PrintTileFailure(tileX, tileY, "could not allocate compact heightfield"); rcFreeHeightField(solid); return false; } - if (!rcBuildCompactHeightfield(&ctx, static_cast(_config.agentMaxClimb / _config.cellHeight), - static_cast(_config.agentHeight / _config.cellHeight), *solid, *chf)) { + if (!rcBuildCompactHeightfield(&ctx, walkableClimb, walkableHeight, *solid, *chf)) { + PrintTileFailure(tileX, tileY, "could not compact heightfield"); rcFreeCompactHeightfield(chf); rcFreeHeightField(solid); return false; } rcFreeHeightField(solid); - if (!rcErodeWalkableArea(&ctx, static_cast(_config.agentRadius / _config.cellSize), *chf)) { + if (chf->spanCount == 0) { + PrintTileFailure(tileX, tileY, "no compact spans generated"); + rcFreeCompactHeightfield(chf); + return false; + } + PrintTileProgress(tileX, tileY, 45, "compact heightfield ready"); + + int const erosionRadius = std::max(0, static_cast(_config.agentRadius / cellSize)); + if (!rcErodeWalkableArea(&ctx, erosionRadius, *chf)) { + PrintTileFailure(tileX, tileY, "could not erode walkable area"); rcFreeCompactHeightfield(chf); return false; } + PrintTileProgress(tileX, tileY, 55, "agent radius applied"); if (!rcBuildRegionsMonotone(&ctx, *chf, 0, _config.minRegionArea, _config.mergeRegionArea)) { + PrintTileFailure(tileX, tileY, "could not build regions"); rcFreeCompactHeightfield(chf); return false; } + PrintTileProgress(tileX, tileY, 65, "regions built"); rcContourSet* cset = rcAllocContourSet(); if (!cset) { + PrintTileFailure(tileX, tileY, "could not allocate contours"); rcFreeCompactHeightfield(chf); return false; } - if (!rcBuildContours(&ctx, *chf, _config.maxSimplificationError, - _config.maxEdgeLen, *cset)) { + if (!rcBuildContours(&ctx, *chf, _config.maxSimplificationError, _config.maxEdgeLen, *cset)) { + PrintTileFailure(tileX, tileY, "could not build contours"); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } + PrintTileProgress(tileX, tileY, 75, "contours built"); rcPolyMesh* pmesh = rcAllocPolyMesh(); if (!pmesh) { + PrintTileFailure(tileX, tileY, "could not allocate poly mesh"); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } if (!rcBuildPolyMesh(&ctx, *cset, _config.maxVertsPerPoly, *pmesh)) { + PrintTileFailure(tileX, tileY, "could not build poly mesh"); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } + PrintTileProgress(tileX, tileY, 85, "poly mesh built"); rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail(); if (!dmesh) { + PrintTileFailure(tileX, tileY, "could not allocate detail mesh"); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); @@ -229,21 +276,29 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, } if (!rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, _config.detailSampleDist, _config.detailSampleMaxError, *dmesh)) { + PrintTileFailure(tileX, tileY, "could not build detail mesh"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } - rcFreeCompactHeightfield(chf); rcFreeContourSet(cset); + PrintTileProgress(tileX, tileY, 92, "detail mesh built"); for (int i = 0; i < pmesh->npolys; ++i) { if (pmesh->areas[i] == RC_WALKABLE_AREA) pmesh->flags[i] = 0x01; } + if (pmesh->npolys == 0) { + PrintTileFailure(tileX, tileY, "no polygons generated"); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return false; + } + dtNavMeshCreateParams params{}; std::memset(¶ms, 0, sizeof(params)); params.verts = pmesh->verts; @@ -263,13 +318,14 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, params.walkableClimb = _config.agentMaxClimb; rcVcopy(params.bmin, pmesh->bmin); rcVcopy(params.bmax, pmesh->bmax); - params.cs = _config.cellSize; - params.ch = _config.cellHeight; + params.cs = cellSize; + params.ch = cellHeight; params.buildBvTree = true; unsigned char* navData = nullptr; int navDataSize = 0; if (!dtCreateNavMeshData(¶ms, &navData, &navDataSize)) { + PrintTileFailure(tileX, tileY, "could not create Detour nav data"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; @@ -277,6 +333,7 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, dtNavMesh* tileNavMesh = dtAllocNavMesh(); if (!tileNavMesh) { + PrintTileFailure(tileX, tileY, "could not allocate Detour nav mesh"); dtFree(navData); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); @@ -290,8 +347,9 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, navParams.maxTiles = 1; navParams.maxPolys = 1 << 16; - dtStatus status = tileNavMesh->init(&navParams); - if (dtStatusFailed(status)) { + dtStatus dtStatusFlags = tileNavMesh->init(&navParams); + if (dtStatusFailed(dtStatusFlags)) { + PrintTileFailure(tileX, tileY, "could not initialize Detour nav mesh"); dtFreeNavMesh(tileNavMesh); dtFree(navData); rcFreePolyMeshDetail(dmesh); @@ -299,16 +357,19 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, return false; } - status = tileNavMesh->addTile(navData, navDataSize, DT_TILE_FREE_DATA, 0, nullptr); - if (dtStatusFailed(status)) { + dtStatusFlags = tileNavMesh->addTile(navData, navDataSize, DT_TILE_FREE_DATA, 0, nullptr); + if (dtStatusFailed(dtStatusFlags)) { + PrintTileFailure(tileX, tileY, "could not add Detour tile"); dtFreeNavMesh(tileNavMesh); - dtFree(navData); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; } + PrintTileProgress(tileX, tileY, 98, "Detour tile ready"); SaveNavMeshTile(tileNavMesh, _config.mapId, tileX, tileY, outputPath); + PrintTileProgress(tileX, tileY, 100, "saved"); + printf("\n"); dtFreeNavMesh(tileNavMesh); rcFreePolyMeshDetail(dmesh); @@ -318,8 +379,16 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { TileTerrainData terrain; - if (!LoadTerrainData(tileX, tileY, terrain)) - return false; + if (!LoadTerrainData(tileX, tileY, terrain)) { + // Fallback: use flat terrain for testing + terrain.width = 64; + terrain.height = 64; + terrain.cellWidth = kTileSize / 64.0f; + terrain.cellHeight = kTileSize / 64.0f; + terrain.minX = TileOriginX(tileX); + terrain.minY = TileOriginY(tileY); + terrain.heights.assign(64 * 64, 0.0f); + } std::filesystem::create_directories(_config.mmapsDir); return BuildTileNavMesh(terrain, tileX, tileY, _config.mmapsDir); @@ -327,12 +396,23 @@ bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { bool MmapGenerator::GenerateAllTiles() { bool anySuccess = false; + uint32_t processed = 0; + uint32_t succeeded = 0; + constexpr uint32_t kTotalTiles = 64u * 64u; + for (uint32_t tileY = 0; tileY < 64; ++tileY) { for (uint32_t tileX = 0; tileX < 64; ++tileX) { - if (Generate(tileX, tileY)) + ++processed; + int const percent = static_cast((processed * 100u) / kTotalTiles); + printf("\n[%3d%%] tile %4u/%u map %u (%02u,%02u)\n", + percent, processed, kTotalTiles, _config.mapId, tileX, tileY); + if (Generate(tileX, tileY)) { anySuccess = true; + ++succeeded; + } } } + printf("\nDone: %u/%u tiles generated.\n", succeeded, kTotalTiles); return anySuccess; } diff --git a/tools/vmap/mmap_generator/main.cpp b/tools/vmap/mmap_generator/main.cpp index 6f962b9..bf485bd 100644 --- a/tools/vmap/mmap_generator/main.cpp +++ b/tools/vmap/mmap_generator/main.cpp @@ -6,7 +6,7 @@ #include void PrintUsage() { - printf("firelands-mmap-generator — Build navmesh tiles from server .map data\n"); + printf("firelands-mmap-generator - Build navmesh tiles from server .map data\n"); printf("Usage: firelands-mmap-generator -m -i -o [-v ]\n"); printf("\n"); printf(" -m Map ID to generate navmesh for (e.g. 0 for Eastern Kingdoms)\n"); @@ -59,24 +59,30 @@ int main(int argc, char* argv[]) { return 1; } + uint32_t const mapId = config.mapId; Firelands::MmapGenerator generator(std::move(config)); + printf("\nFirelands mmap generator\n"); + printf("========================\n"); + printf("Map: %u\n", mapId); + if (singleTile) { - printf("Generating navmesh for map %u tile (%u,%u)...\n", - config.mapId, tileX, tileY); + printf("Tile: (%u,%u)\n\n", tileX, tileY); + printf("Generating navmesh...\n"); if (!generator.Generate(tileX, tileY)) { fprintf(stderr, "Failed to generate tile (%u,%u).\n", tileX, tileY); return 1; } - printf("Tile (%u,%u) generated successfully.\n", tileX, tileY); + printf("\nTile (%u,%u) generated successfully.\n", tileX, tileY); } else { - printf("Generating navmesh for map %u (all tiles)...\n", config.mapId); + printf("Tiles: all 64x64\n\n"); + printf("Generating navmesh...\n"); if (!generator.GenerateAllTiles()) { fprintf(stderr, "No tiles were generated. Check that .map files exist " "in the input directory.\n"); return 1; } - printf("Navmesh generation complete.\n"); + printf("\nNavmesh generation complete.\n"); } return 0; From 39d9451ac4b30c647020f153361eafa96a2a68ce Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 00:15:10 -0500 Subject: [PATCH 07/35] feat(mmap_generator): enhance terrain data handling and improve tile generation logic --- .../collision/DetourNavMeshManager.cpp | 75 +++++--- .../collision/DetourNavMeshManager.h | 2 +- tools/vmap/mmap_generator/MmapGenerator.cpp | 161 +++++++++++++----- tools/vmap/mmap_generator/MmapGenerator.h | 5 +- 4 files changed, 173 insertions(+), 70 deletions(-) diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index c271ed6..f9ae5e5 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -3,8 +3,11 @@ #include #include #include +#include +#include #include #include +#include #include #include @@ -12,22 +15,26 @@ namespace Firelands { namespace { constexpr float kTileSize = 533.33333f; +constexpr float kMapOrigin = -17066.66656f; constexpr uint32_t kTileCountPerAxis = 64; - -uint32_t WorldToTileX(float x) { - return static_cast(std::floor(32.0f - (x / kTileSize))); -} - -uint32_t WorldToTileY(float y) { - return static_cast(std::floor(32.0f - (y / kTileSize))); -} - -float TileToWorldX(uint32_t tx) { - return (32.0f - static_cast(tx) - 0.5f) * kTileSize; +constexpr uint32_t kMmapMagic = 'M' | ('M' << 8) | ('A' << 16) | ('P' << 24); + +struct MmapTileHeader { + uint32_t mmapMagic; + uint32_t dtVersion; + uint32_t mmapSize; + unsigned char usesLiquids; + unsigned char padding[3]; +}; + +void WowToDetour(float x, float y, float z, float out[3]) { + out[0] = x; + out[1] = z; + out[2] = y; } -float TileToWorldY(uint32_t ty) { - return (32.0f - static_cast(ty) - 0.5f) * kTileSize; +Vec3 DetourToWow(float const* pos) { + return Vec3{pos[0], pos[2], pos[1]}; } } // namespace @@ -66,7 +73,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, long fileSize = ftell(file); fseek(file, 0, SEEK_SET); - if (fileSize < 20) { + if (fileSize < static_cast(sizeof(MmapTileHeader) + sizeof(dtMeshHeader))) { fclose(file); return false; } @@ -78,13 +85,31 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, if (readSize != static_cast(fileSize)) return false; + MmapTileHeader const* mmapHeader = + reinterpret_cast(data.data()); + if (mmapHeader->mmapMagic != kMmapMagic || + mmapHeader->dtVersion != DT_NAVMESH_VERSION || + mmapHeader->mmapSize == 0 || + sizeof(MmapTileHeader) + mmapHeader->mmapSize > data.size()) { + return false; + } + + unsigned char const* tilePayload = data.data() + sizeof(MmapTileHeader); dtMeshHeader const* header = - reinterpret_cast(data.data()); + reinterpret_cast(tilePayload); if (header->magic != DT_NAVMESH_MAGIC || header->version != DT_NAVMESH_VERSION) return false; - dtStatus status = navMesh->addTile(data.data(), static_cast(fileSize), + auto* tileData = + static_cast(dtAlloc(mmapHeader->mmapSize, DT_ALLOC_PERM)); + if (!tileData) + return false; + std::memcpy(tileData, tilePayload, mmapHeader->mmapSize); + + dtStatus status = navMesh->addTile(tileData, static_cast(mmapHeader->mmapSize), DT_TILE_FREE_DATA, 0, nullptr); + if (dtStatusFailed(status)) + dtFree(tileData); return dtStatusSucceed(status); } @@ -93,8 +118,7 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { return true; dtNavMeshParams params{}; - float const navOrigin[3] = {-kTileSize * kTileCountPerAxis / 2.0f, - -kTileSize * kTileCountPerAxis / 2.0f, 0.0f}; + float const navOrigin[3] = {kMapOrigin, 0.0f, kMapOrigin}; dtVcopy(params.orig, navOrigin); params.tileWidth = kTileSize; params.tileHeight = kTileSize; @@ -229,8 +253,10 @@ FindPathResult DetourNavMeshManager::FindPath( dtPolyRef endRef = 0; float startNearest[3]{}; float endNearest[3]{}; - float const startPos[3] = {req.startX, req.startY, req.startZ}; - float const endPos[3] = {req.endX, req.endY, req.endZ}; + float startPos[3]{}; + float endPos[3]{}; + WowToDetour(req.startX, req.startY, req.startZ, startPos); + WowToDetour(req.endX, req.endY, req.endZ, endPos); dtQueryFilter filter; filter.setIncludeFlags(0xFFFF); @@ -257,9 +283,10 @@ FindPathResult DetourNavMeshManager::FindPath( dtPolyRef straightPathPolys[256]; int straightPathCount = 0; + int const maxPathPolys = std::clamp(_config.maxPathPolys, 1, 256); status = navQuery->findPath(startRef, endRef, startNearest, endNearest, &filter, pathPolys, &pathCount, - _config.maxPathPolys); + maxPathPolys); if (dtStatusFailed(status) || pathCount == 0) { result.status = FindPathStatus::NoPath; return result; @@ -268,7 +295,7 @@ FindPathResult DetourNavMeshManager::FindPath( status = navQuery->findStraightPath( startNearest, endNearest, pathPolys, pathCount, straightPath, straightPathFlags, straightPathPolys, &straightPathCount, - _config.maxPathPolys, 0); + maxPathPolys, 0); if (dtStatusFailed(status) || straightPathCount == 0) { result.status = FindPathStatus::NoPath; return result; @@ -276,9 +303,7 @@ FindPathResult DetourNavMeshManager::FindPath( result.waypoints.reserve(straightPathCount); for (int i = 0; i < straightPathCount; ++i) { - result.waypoints.push_back( - Vec3{straightPath[i * 3], straightPath[i * 3 + 1], - straightPath[i * 3 + 2]}); + result.waypoints.push_back(DetourToWow(&straightPath[i * 3])); } RemoveDuplicateWaypoints(result.waypoints); diff --git a/src/infrastructure/collision/DetourNavMeshManager.h b/src/infrastructure/collision/DetourNavMeshManager.h index 3e9c0b4..026f364 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.h +++ b/src/infrastructure/collision/DetourNavMeshManager.h @@ -14,7 +14,7 @@ namespace Firelands { struct DetourNavMeshConfig { float maxSearchRadius = 100.0f; - float maxPathPolys = 256; + int maxPathPolys = 256; int maxNavMeshNodes = 4096; }; diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index 869f3f9..c1171df 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -21,6 +21,13 @@ namespace { constexpr float kTileSize = 533.33333f; constexpr float kMapOrigin = -17066.66656f; +constexpr uint32_t kMapMagic = 0x5350414Du; // 'MAPS' +constexpr uint32_t kMapHeightMagic = 0x54474D48u; // 'MHGT' +constexpr uint32_t kMapHeightNoHeight = 0x0001; +constexpr uint32_t kMapHeightAsInt16 = 0x0002; +constexpr uint32_t kMapHeightAsInt8 = 0x0004; +constexpr int kTerrainGridSize = 128; +constexpr int kTerrainVertexCount = kTerrainGridSize + 1; float TileOriginX(uint32_t tileX) { return kMapOrigin + static_cast(tileX) * kTileSize; @@ -30,6 +37,14 @@ float TileOriginY(uint32_t tileY) { return kMapOrigin + static_cast(tileY) * kTileSize; } +std::filesystem::path MapTilePath(std::string const& mapsDir, uint32_t mapId, + uint32_t tileX, uint32_t tileY) { + std::ostringstream ss; + ss << std::setfill('0') << std::setw(3) << mapId + << std::setw(2) << tileY << std::setw(2) << tileX << ".map"; + return std::filesystem::path(mapsDir) / ss.str(); +} + struct MmapTileHeader { uint32_t mmapMagic; uint32_t dtVersion; @@ -83,18 +98,15 @@ MmapGenerator::MmapGenerator(MmapGeneratorConfig config) bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, TileTerrainData& out) const { - std::ostringstream ss; - ss << std::setfill('0') << std::setw(3) << _config.mapId - << std::setw(2) << tileY << std::setw(2) << tileX << ".map"; std::string const fileName = - (std::filesystem::path(_config.mapsDir) / ss.str()).string(); + MapTilePath(_config.mapsDir, _config.mapId, tileX, tileY).string(); FILE* file = fopen(fileName.c_str(), "rb"); if (!file) return false; uint32_t mapMagic = 0; - if (fread(&mapMagic, 4, 1, file) != 1 || mapMagic != 0x5350414Du) { + if (fread(&mapMagic, 4, 1, file) != 1 || mapMagic != kMapMagic) { fclose(file); return false; } @@ -128,31 +140,51 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, fread(&gridHeight, 4, 1, file); fread(&gridMaxHeight, 4, 1, file); - constexpr int kGridSize = 128; - out.width = kGridSize; - out.height = kGridSize; - out.cellWidth = kTileSize / static_cast(kGridSize); - out.cellHeight = kTileSize / static_cast(kGridSize); + if (heightFourcc != kMapHeightMagic) { + fclose(file); + return false; + } + + out.width = kTerrainVertexCount; + out.height = kTerrainVertexCount; + out.cellSize = kTileSize / static_cast(kTerrainGridSize); out.minX = TileOriginX(tileX); out.minY = TileOriginY(tileY); - out.heights.resize(kGridSize * kGridSize); + out.minZ = gridHeight; + out.maxZ = gridMaxHeight; + out.heights.resize(kTerrainVertexCount * kTerrainVertexCount); - int const count = kGridSize * kGridSize; - if (heightFlags & 1) { + int const count = kTerrainVertexCount * kTerrainVertexCount; + float const heightRange = std::max(0.0f, gridMaxHeight - gridHeight); + if (heightFlags & kMapHeightNoHeight) { std::fill(out.heights.begin(), out.heights.end(), gridHeight); - } else if (heightFlags & 2) { + } else if (heightFlags & kMapHeightAsInt16) { + float const invStep = heightRange > 0.0f ? heightRange / 65535.0f : 0.0f; for (int i = 0; i < count; ++i) { - int16_t v = 0; fread(&v, 2, 1, file); - out.heights[i] = gridHeight + static_cast(v); + uint16_t v = 0; + if (fread(&v, sizeof(v), 1, file) != 1) { + fclose(file); + return false; + } + out.heights[i] = gridHeight + static_cast(v) * invStep; } - } else if (heightFlags & 4) { + } else if (heightFlags & kMapHeightAsInt8) { + float const invStep = heightRange > 0.0f ? heightRange / 255.0f : 0.0f; for (int i = 0; i < count; ++i) { - int8_t v = 0; fread(&v, 1, 1, file); - out.heights[i] = gridHeight + static_cast(v); + uint8_t v = 0; + if (fread(&v, sizeof(v), 1, file) != 1) { + fclose(file); + return false; + } + out.heights[i] = gridHeight + static_cast(v) * invStep; } } else { for (int i = 0; i < count; ++i) { - float h = 0.0f; fread(&h, 4, 1, file); + float h = 0.0f; + if (fread(&h, sizeof(h), 1, file) != 1) { + fclose(file); + return false; + } out.heights[i] = h; } } @@ -164,8 +196,10 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, uint32_t tileX, uint32_t tileY, std::string const& outputPath) const { - float const bmin[3] = {terrain.minX, terrain.minY, -500.0f}; - float const bmax[3] = {terrain.minX + kTileSize, terrain.minY + kTileSize, 500.0f}; + float const minZ = terrain.minZ - _config.agentHeight - 5.0f; + float const maxZ = terrain.maxZ + _config.agentHeight + 5.0f; + float const bmin[3] = {terrain.minX, minZ, terrain.minY}; + float const bmax[3] = {terrain.minX + kTileSize, maxZ, terrain.minY + kTileSize}; float const cellSize = std::max(1.5f, _config.cellSize); float const cellHeight = std::max(0.3f, _config.cellHeight); @@ -189,16 +223,41 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, int const walkableClimb = std::max(1, static_cast(_config.agentMaxClimb / cellHeight)); int const walkableHeight = std::max(1, static_cast(_config.agentHeight / cellHeight)); - // Seed a walkable plane so Recast has spans to compact into polygons. - int spanCount = 0; - for (int y = 0; y < solid->height; ++y) { - for (int x = 0; x < solid->width; ++x) { - if (rcAddSpan(&ctx, *solid, x, y, 0, static_cast(walkableHeight), - RC_WALKABLE_AREA, 1)) - spanCount++; + std::vector verts; + verts.reserve(static_cast(terrain.width * terrain.height * 3)); + for (int y = 0; y < terrain.height; ++y) { + for (int x = 0; x < terrain.width; ++x) { + float const wx = terrain.minX + static_cast(x) * terrain.cellSize; + float const wy = terrain.minY + static_cast(y) * terrain.cellSize; + float const wz = terrain.heights[static_cast(y * terrain.width + x)]; + verts.push_back(wx); + verts.push_back(wz); + verts.push_back(wy); } } - PrintTileProgress(tileX, tileY, 30, "walkable spans added"); + + std::vector tris; + tris.reserve(static_cast(kTerrainGridSize * kTerrainGridSize * 6)); + for (int y = 0; y < terrain.height - 1; ++y) { + for (int x = 0; x < terrain.width - 1; ++x) { + int const a = y * terrain.width + x; + int const b = y * terrain.width + x + 1; + int const c = (y + 1) * terrain.width + x; + int const d = (y + 1) * terrain.width + x + 1; + tris.push_back(a); tris.push_back(c); tris.push_back(b); + tris.push_back(b); tris.push_back(c); tris.push_back(d); + } + } + + int const triCount = static_cast(tris.size() / 3); + std::vector triAreas(static_cast(triCount), RC_WALKABLE_AREA); + rcRasterizeTriangles(&ctx, verts.data(), static_cast(verts.size() / 3), + tris.data(), triAreas.data(), triCount, *solid, + walkableClimb); + rcFilterLowHangingWalkableObstacles(&ctx, walkableClimb, *solid); + rcFilterLedgeSpans(&ctx, walkableHeight, walkableClimb, *solid); + rcFilterWalkableLowHeightSpans(&ctx, walkableHeight, *solid); + PrintTileProgress(tileX, tileY, 30, "terrain rasterized"); rcCompactHeightfield* chf = rcAllocCompactHeightfield(); if (!chf) { @@ -316,6 +375,9 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, params.walkableHeight = _config.agentHeight; params.walkableRadius = _config.agentRadius; params.walkableClimb = _config.agentMaxClimb; + params.tileX = static_cast(tileX); + params.tileY = static_cast(tileY); + params.tileLayer = 0; rcVcopy(params.bmin, pmesh->bmin); rcVcopy(params.bmax, pmesh->bmax); params.cs = cellSize; @@ -380,14 +442,8 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { TileTerrainData terrain; if (!LoadTerrainData(tileX, tileY, terrain)) { - // Fallback: use flat terrain for testing - terrain.width = 64; - terrain.height = 64; - terrain.cellWidth = kTileSize / 64.0f; - terrain.cellHeight = kTileSize / 64.0f; - terrain.minX = TileOriginX(tileX); - terrain.minY = TileOriginY(tileY); - terrain.heights.assign(64 * 64, 0.0f); + PrintTileFailure(tileX, tileY, "terrain .map missing or invalid"); + return false; } std::filesystem::create_directories(_config.mmapsDir); @@ -398,21 +454,42 @@ bool MmapGenerator::GenerateAllTiles() { bool anySuccess = false; uint32_t processed = 0; uint32_t succeeded = 0; - constexpr uint32_t kTotalTiles = 64u * 64u; + uint32_t totalTiles = 0; + + for (uint32_t tileY = 0; tileY < 64; ++tileY) { + for (uint32_t tileX = 0; tileX < 64; ++tileX) { + if (std::filesystem::exists( + MapTilePath(_config.mapsDir, _config.mapId, tileX, tileY))) { + ++totalTiles; + } + } + } + + if (totalTiles == 0) { + printf("No terrain .map tiles found for map %u in %s\n", _config.mapId, + _config.mapsDir.c_str()); + return false; + } for (uint32_t tileY = 0; tileY < 64; ++tileY) { for (uint32_t tileX = 0; tileX < 64; ++tileX) { + if (!std::filesystem::exists( + MapTilePath(_config.mapsDir, _config.mapId, tileX, tileY))) { + continue; + } + ++processed; - int const percent = static_cast((processed * 100u) / kTotalTiles); + int const percent = static_cast((processed * 100u) / totalTiles); printf("\n[%3d%%] tile %4u/%u map %u (%02u,%02u)\n", - percent, processed, kTotalTiles, _config.mapId, tileX, tileY); + percent, processed, totalTiles, _config.mapId, tileX, tileY); if (Generate(tileX, tileY)) { anySuccess = true; ++succeeded; } } } - printf("\nDone: %u/%u tiles generated.\n", succeeded, kTotalTiles); + printf("\nDone: %u/%u existing terrain tiles generated.\n", succeeded, + totalTiles); return anySuccess; } diff --git a/tools/vmap/mmap_generator/MmapGenerator.h b/tools/vmap/mmap_generator/MmapGenerator.h index c5e749e..f252181 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.h +++ b/tools/vmap/mmap_generator/MmapGenerator.h @@ -44,8 +44,9 @@ class MmapGenerator { int height = 0; float minX = 0.0f; float minY = 0.0f; - float cellWidth = 0.0f; - float cellHeight = 0.0f; + float minZ = 0.0f; + float maxZ = 0.0f; + float cellSize = 0.0f; }; bool LoadTerrainData(uint32_t tileX, uint32_t tileY, TileTerrainData& out) const; From 5c2762d70f6b1e21666690bf3a7a1aa578bd1bb0 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 00:19:06 -0500 Subject: [PATCH 08/35] feat(mmap_generator): add logging for existing terrain tiles in GenerateAllTiles function --- tools/vmap/mmap_generator/MmapGenerator.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index c1171df..ebf281c 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -471,6 +471,9 @@ bool MmapGenerator::GenerateAllTiles() { return false; } + printf("Found %u existing terrain tiles for map %u; skipping %u empty tiles.\n", + totalTiles, _config.mapId, (64u * 64u) - totalTiles); + for (uint32_t tileY = 0; tileY < 64; ++tileY) { for (uint32_t tileX = 0; tileX < 64; ++tileX) { if (!std::filesystem::exists( From 15384e04bbdf2a0b57b1bfd45c49088c894cb92c Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 00:23:45 -0500 Subject: [PATCH 09/35] refactor(mmap_generator): remove MmapGenerator.cpp file and associated logic --- MmapGenerator.cpp | 346 ---------------------------------------------- 1 file changed, 346 deletions(-) delete mode 100644 MmapGenerator.cpp diff --git a/MmapGenerator.cpp b/MmapGenerator.cpp deleted file mode 100644 index 49bed7f..0000000 --- a/MmapGenerator.cpp +++ /dev/null @@ -1,346 +0,0 @@ -#include "MmapGenerator.h" - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace Firelands { - -namespace { - -constexpr float kTileSize = 533.33333f; -constexpr float kMapOrigin = -17066.66656f; - -float TileOriginX(uint32_t tileX) { - return kMapOrigin + static_cast(tileX) * kTileSize; -} - -float TileOriginY(uint32_t tileY) { - return kMapOrigin + static_cast(tileY) * kTileSize; -} - -struct MmapTileHeader { - uint32_t mmapMagic; - uint32_t dtVersion; - uint32_t mmapSize; - unsigned char usesLiquids; - unsigned char padding[3]; -}; - -void SaveNavMeshTile(dtNavMesh const* navMesh, uint32_t mapId, - uint32_t tileX, uint32_t tileY, - std::string const& outputPath) { - dtMeshTile const* tile = navMesh->getTile(0); - if (!tile || !tile->data || tile->dataSize == 0) - return; - - MmapTileHeader header{}; - header.mmapMagic = 'M' | ('M' << 8) | ('A' << 16) | ('P' << 24); - header.dtVersion = DT_NAVMESH_VERSION; - header.mmapSize = static_cast(tile->dataSize); - header.usesLiquids = 0; - - std::string fileName = outputPath + "/" + - std::to_string(mapId) + "_" + - std::to_string(tileX) + "_" + - std::to_string(tileY) + ".mmtile"; - - FILE* file = fopen(fileName.c_str(), "wb"); - if (!file) - return; - - fwrite(&header, sizeof(header), 1, file); - fwrite(tile->data, tile->dataSize, 1, file); - fclose(file); -} - -} // namespace - -MmapGenerator::MmapGenerator(MmapGeneratorConfig config) - : _config(std::move(config)) {} - -bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, - TileTerrainData& out) const { - std::ostringstream ss; - ss << std::setfill('0') << std::setw(3) << _config.mapId - << std::setw(2) << tileY << std::setw(2) << tileX << ".map"; - std::string const fileName = - (std::filesystem::path(_config.mapsDir) / ss.str()).string(); - - FILE* file = fopen(fileName.c_str(), "rb"); - if (!file) - return false; - - uint32_t mapMagic = 0; - if (fread(&mapMagic, 4, 1, file) != 1 || mapMagic != 0x5350414Du) { - fclose(file); - return false; - } - - uint32_t versionMagic = 0, buildMagic = 0; - uint32_t areaMapOffset = 0, areaMapSize = 0; - uint32_t heightMapOffset = 0, heightMapSize = 0; - uint32_t liquidMapOffset = 0, liquidMapSize = 0; - uint32_t holesOffset = 0, holesSize = 0; - fread(&versionMagic, 4, 1, file); - fread(&buildMagic, 4, 1, file); - fread(&areaMapOffset, 4, 1, file); - fread(&areaMapSize, 4, 1, file); - fread(&heightMapOffset, 4, 1, file); - fread(&heightMapSize, 4, 1, file); - fread(&liquidMapOffset, 4, 1, file); - fread(&liquidMapSize, 4, 1, file); - fread(&holesOffset, 4, 1, file); - fread(&holesSize, 4, 1, file); - (void)versionMagic; (void)buildMagic; - (void)areaMapOffset; (void)areaMapSize; - (void)liquidMapOffset; (void)liquidMapSize; - (void)holesOffset; (void)holesSize; - - fseek(file, static_cast(heightMapOffset), SEEK_SET); - - uint32_t heightFourcc = 0, heightFlags = 0; - float gridHeight = 0.0f, gridMaxHeight = 0.0f; - fread(&heightFourcc, 4, 1, file); - fread(&heightFlags, 4, 1, file); - fread(&gridHeight, 4, 1, file); - fread(&gridMaxHeight, 4, 1, file); - - constexpr int kGridSize = 128; - out.width = kGridSize; - out.height = kGridSize; - out.cellWidth = kTileSize / static_cast(kGridSize); - out.cellHeight = kTileSize / static_cast(kGridSize); - out.minX = TileOriginX(tileX); - out.minY = TileOriginY(tileY); - out.heights.resize(kGridSize * kGridSize); - - int const count = kGridSize * kGridSize; - if (heightFlags & 1) { - std::fill(out.heights.begin(), out.heights.end(), gridHeight); - } else if (heightFlags & 2) { - for (int i = 0; i < count; ++i) { - int16_t v = 0; fread(&v, 2, 1, file); - out.heights[i] = gridHeight + static_cast(v); - } - } else if (heightFlags & 4) { - for (int i = 0; i < count; ++i) { - int8_t v = 0; fread(&v, 1, 1, file); - out.heights[i] = gridHeight + static_cast(v); - } - } else { - for (int i = 0; i < count; ++i) { - float h = 0.0f; fread(&h, 4, 1, file); - out.heights[i] = h; - } - } - - fclose(file); - return true; -} - -bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, - uint32_t tileX, uint32_t tileY, - std::string const& outputPath) const { - float const bmin[3] = {terrain.minX, terrain.minY, -500.0f}; - float const bmax[3] = {terrain.minX + kTileSize, terrain.minY + kTileSize, 500.0f}; - - float const cellSize = std::max(1.5f, _config.cellSize); - float const cellHeight = std::max(0.3f, _config.cellHeight); - int const tileW = static_cast(kTileSize / cellSize + 0.5f); - int const tileH = static_cast(kTileSize / cellSize + 0.5f); - - rcContext ctx; - - rcHeightfield* solid = rcAllocHeightfield(); - if (!solid) { printf("A"); return false; } - if (!rcCreateHeightfield(&ctx, *solid, tileW, tileH, bmin, bmax, cellSize, cellHeight)) { - printf("B"); rcFreeHeightField(solid); return false; - } - - // Downsample terrain - std::vector hfVerts((tileW + 1) * (tileH + 1) * 3); - std::vector hfTris(tileW * tileH * 6); - std::vector triAreas(tileW * tileH * 2, 0); - - int vi = 0; - for (int ty = 0; ty <= tileH; ++ty) { - for (int tx = 0; tx <= tileW; ++tx) { - float wx = terrain.minX + static_cast(tx) * cellSize; - float wy = terrain.minY + static_cast(ty) * cellSize; - int sx = std::min(static_cast(tx * cellSize / terrain.cellWidth), terrain.width - 1); - int sy = std::min(static_cast(ty * cellSize / terrain.cellHeight), terrain.height - 1); - hfVerts[vi++] = wx; - hfVerts[vi++] = wy; - hfVerts[vi++] = terrain.heights[sy * terrain.width + sx]; - } - } - - int ti = 0; - for (int ty = 0; ty < tileH; ++ty) { - for (int tx = 0; tx < tileW; ++tx) { - int a = ty * (tileW + 1) + tx; - int b = a + 1; - int c = a + tileW + 1; - int d = c + 1; - hfTris[ti++] = a; hfTris[ti++] = c; hfTris[ti++] = b; - hfTris[ti++] = b; hfTris[ti++] = c; hfTris[ti++] = d; - } - } - - rcRasterizeTriangles(&ctx, hfVerts.data(), (tileW + 1) * (tileH + 1), - hfTris.data(), triAreas.data(), tileW * tileH * 2, *solid, 0); - - int const walkableClimb = std::max(1, static_cast(_config.agentMaxClimb / cellHeight)); - int const walkableHeight = std::max(1, static_cast(_config.agentHeight / cellHeight)); - - rcCompactHeightfield* chf = rcAllocCompactHeightfield(); - if (!chf) { printf("C"); rcFreeHeightField(solid); return false; } - if (!rcBuildCompactHeightfield(&ctx, walkableClimb, walkableHeight, *solid, *chf)) { - printf("D"); rcFreeCompactHeightfield(chf); rcFreeHeightField(solid); return false; - } - rcFreeHeightField(solid); - - printf(" spans=%d ", chf->spanCount); fflush(stdout); - if (chf->spanCount == 0) { printf("Q"); rcFreeCompactHeightfield(chf); return false; } - - int const erosionRadius = std::max(0, static_cast(_config.agentRadius / cellSize)); - if (!rcErodeWalkableArea(&ctx, erosionRadius, *chf)) { - printf("E"); rcFreeCompactHeightfield(chf); return false; - } - - if (!rcBuildRegionsMonotone(&ctx, *chf, 0, _config.minRegionArea, _config.mergeRegionArea)) { - printf("F"); rcFreeCompactHeightfield(chf); return false; - } - - rcContourSet* cset = rcAllocContourSet(); - if (!cset) { printf("G"); rcFreeCompactHeightfield(chf); return false; } - if (!rcBuildContours(&ctx, *chf, _config.maxSimplificationError, _config.maxEdgeLen, *cset)) { - printf("H"); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; - } - - rcPolyMesh* pmesh = rcAllocPolyMesh(); - if (!pmesh) { printf("I"); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } - if (!rcBuildPolyMesh(&ctx, *cset, _config.maxVertsPerPoly, *pmesh)) { - printf("J"); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; - } - - rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail(); - if (!dmesh) { printf("K"); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; } - if (!rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, _config.detailSampleDist, - _config.detailSampleMaxError, *dmesh)) { - printf("L"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); rcFreeContourSet(cset); rcFreeCompactHeightfield(chf); return false; - } - rcFreeCompactHeightfield(chf); - rcFreeContourSet(cset); - - for (int i = 0; i < pmesh->npolys; ++i) { - if (pmesh->areas[i] == RC_WALKABLE_AREA) - pmesh->flags[i] = 0x01; - } - - if (pmesh->npolys == 0) { - printf("Z"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; - } - - dtNavMeshCreateParams params{}; - std::memset(¶ms, 0, sizeof(params)); - params.verts = pmesh->verts; - params.vertCount = pmesh->nverts; - params.polys = pmesh->polys; - params.polyAreas = pmesh->areas; - params.polyFlags = pmesh->flags; - params.polyCount = pmesh->npolys; - params.nvp = pmesh->nvp; - params.detailMeshes = dmesh->meshes; - params.detailVerts = dmesh->verts; - params.detailVertsCount = dmesh->nverts; - params.detailTris = dmesh->tris; - params.detailTriCount = dmesh->ntris; - params.walkableHeight = _config.agentHeight; - params.walkableRadius = _config.agentRadius; - params.walkableClimb = _config.agentMaxClimb; - rcVcopy(params.bmin, pmesh->bmin); - rcVcopy(params.bmax, pmesh->bmax); - params.cs = cellSize; - params.ch = cellHeight; - params.buildBvTree = true; - - unsigned char* navData = nullptr; - int navDataSize = 0; - if (!dtCreateNavMeshData(¶ms, &navData, &navDataSize)) { - printf("M"); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; - } - - dtNavMesh* tileNavMesh = dtAllocNavMesh(); - if (!tileNavMesh) { - printf("N"); dtFree(navData); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; - } - - dtNavMeshParams navParams{}; - rcVcopy(navParams.orig, bmin); - navParams.tileWidth = kTileSize; - navParams.tileHeight = kTileSize; - navParams.maxTiles = 1; - navParams.maxPolys = 1 << 16; - - dtStatus dtStatusFlags = tileNavMesh->init(&navParams); - if (dtStatusFailed(dtStatusFlags)) { - printf("O"); dtFreeNavMesh(tileNavMesh); dtFree(navData); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; - } - - dtStatusFlags = tileNavMesh->addTile(navData, navDataSize, DT_TILE_FREE_DATA, 0, nullptr); - if (dtStatusFailed(dtStatusFlags)) { - printf("P"); dtFreeNavMesh(tileNavMesh); rcFreePolyMeshDetail(dmesh); rcFreePolyMesh(pmesh); return false; - } - - SaveNavMeshTile(tileNavMesh, _config.mapId, tileX, tileY, outputPath); - - dtFreeNavMesh(tileNavMesh); - rcFreePolyMeshDetail(dmesh); - rcFreePolyMesh(pmesh); - return true; -} - -bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { - TileTerrainData terrain; - if (!LoadTerrainData(tileX, tileY, terrain)) { - // Fallback: use flat terrain for testing - terrain.width = 64; - terrain.height = 64; - terrain.cellWidth = kTileSize / 64.0f; - terrain.cellHeight = kTileSize / 64.0f; - terrain.minX = TileOriginX(tileX); - terrain.minY = TileOriginY(tileY); - terrain.heights.assign(64 * 64, 0.0f); - } - - std::filesystem::create_directories(_config.mmapsDir); - return BuildTileNavMesh(terrain, tileX, tileY, _config.mmapsDir); -} - -bool MmapGenerator::GenerateAllTiles() { - bool anySuccess = false; - for (uint32_t tileY = 0; tileY < 64; ++tileY) { - for (uint32_t tileX = 0; tileX < 64; ++tileX) { - if (Generate(tileX, tileY)) { - anySuccess = true; - printf("."); fflush(stdout); - } - } - } - if (anySuccess) printf("\n"); - return anySuccess; -} - -} // namespace Firelands From b50972f388a29f7dd51234fa12725c0c40ca0ccf Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 01:44:17 -0500 Subject: [PATCH 10/35] feat(extractors): add locale support for DBC and map extraction tasks --- tools/extractors/ArchivePath.h | 10 + tools/extractors/ExtractorTasks.cpp | 83 ++++-- tools/extractors/ExtractorTasks.h | 7 +- tools/extractors/KnownDbcFiles.h | 345 ++++++++++++++++++++++++ tools/extractors/MpqPatchChain.cpp | 70 +++++ tools/extractors/MpqPatchChain.h | 11 + tools/extractors/WowDataMpqList.cpp | 145 ++++++++-- tools/extractors/WowDataMpqList.h | 3 +- tools/extractors/dbc_extractor_main.cpp | 11 +- tools/extractors/map_extractor_main.cpp | 11 +- 10 files changed, 640 insertions(+), 56 deletions(-) create mode 100644 tools/extractors/KnownDbcFiles.h diff --git a/tools/extractors/ArchivePath.h b/tools/extractors/ArchivePath.h index 799acaf..b2f545a 100644 --- a/tools/extractors/ArchivePath.h +++ b/tools/extractors/ArchivePath.h @@ -63,4 +63,14 @@ inline std::filesystem::path DbcStoreOutputRelativePath(const std::string &archi return dbcRoot / ArchivedPathToRelative(archived.substr(tailStart)); } +inline std::filesystem::path DbcOutputPath(const std::filesystem::path &outDir, + const std::string &archived) { + std::filesystem::path relative = DbcStoreOutputRelativePath(archived); + if (outDir.filename() == "dbc" && !relative.empty() && + *relative.begin() == "dbc") { + relative = relative.lexically_relative("dbc"); + } + return outDir / relative; +} + } // namespace firelands::extract diff --git a/tools/extractors/ExtractorTasks.cpp b/tools/extractors/ExtractorTasks.cpp index fd27229..31300cd 100644 --- a/tools/extractors/ExtractorTasks.cpp +++ b/tools/extractors/ExtractorTasks.cpp @@ -1,6 +1,7 @@ #include "ExtractorTasks.h" #include "ArchivePath.h" +#include "KnownDbcFiles.h" #include "MpqPatchChain.h" #include "WowDataMpqList.h" @@ -63,8 +64,8 @@ bool IsMapAsset(const std::string &archivedNorm) { } // namespace int RunListMpqsTask(const std::filesystem::path &dataDir, std::ostream &out, - std::ostream &err) { - const auto mpqs = BuildCataclysmMpqOpenOrder(dataDir); + std::ostream &err, const std::string &locale) { + const auto mpqs = BuildCataclysmMpqOpenOrder(dataDir, locale); if (mpqs.empty()) { err << "No .mpq archives found in: " << dataDir.string() << "\n"; return 1; @@ -77,22 +78,19 @@ int RunListMpqsTask(const std::filesystem::path &dataDir, std::ostream &out, int RunDbcExtractTask(const std::filesystem::path &dataDir, const std::filesystem::path &outDir, std::ostream &out, - std::ostream &err) { - const auto mpqs = BuildCataclysmMpqOpenOrder(dataDir); + std::ostream &err, const std::string &locale) { + const auto mpqs = BuildCataclysmMpqOpenOrder(dataDir, locale); if (mpqs.empty()) { err << "No .mpq archives found in: " << dataDir.string() << "\n"; return 1; } MpqPatchChain chain; - if (!chain.Open(mpqs)) { - err << "Failed to open MPQ patch chain (first archive: " - << mpqs.front().string() << ")\n"; - return 1; - } + bool const chainOpen = chain.Open(mpqs); // Discover *.dbc and *.db2 under DBFilesClient: enumerate each MPQ, then the - // open chain as a fallback (robust against chain gaps on macOS/Linux). + // open chain as a fallback when StormLib can build one. Some launcher-streamed + // 4.3.4 clients do not form a valid patch chain, so chain failure is not fatal. std::vector storePaths; std::unordered_set seenNorm; @@ -107,26 +105,55 @@ int RunDbcExtractTask(const std::filesystem::path &dataDir, storePaths.push_back(name); } }); - chain.ForEachFile(wildcard, [&](const std::string &name) { - if (!IsDbFilesClientDataStorePath(name)) { - return; - } - const std::string k = NormalizeSlashesLower(name); - if (seenNorm.insert(k).second) { - storePaths.push_back(name); - } - }); + if (chainOpen) { + chain.ForEachFile(wildcard, [&](const std::string &name) { + if (!IsDbFilesClientDataStorePath(name)) { + return; + } + const std::string k = NormalizeSlashesLower(name); + if (seenNorm.insert(k).second) { + storePaths.push_back(name); + } + }); + } }; - collectWildcard("*.dbc"); - collectWildcard("*.db2"); + collectWildcard("DBFilesClient/*.dbc"); + collectWildcard("DBFilesClient/*.db2"); + + const size_t wildcardDiscovered = storePaths.size(); + for (const char *fileName : known_dbc::kCataclysmDbcFiles) { + const std::string archived = std::string("DBFilesClient/") + fileName; + const std::string k = NormalizeSlashesLower(archived); + if (seenNorm.find(k) != seenNorm.end()) { + continue; + } + if (MpqPatchChain::FileExistsInAnyArchive(mpqs, archived.c_str())) { + printf("FOUND: %s\n", archived.c_str()); fflush(stdout); + seenNorm.insert(k); + storePaths.push_back(archived); + } else if (storePaths.empty() && fileName == known_dbc::kCataclysmDbcFiles[0]) { + printf("FIRST CHECK: %s NOT FOUND\n", archived.c_str()); fflush(stdout); + } + } + + if (wildcardDiscovered == 0 && !storePaths.empty()) { + out << "MPQ listfile unavailable; using known Cataclysm 4.3.4 DBC/DB2 names.\n"; + } size_t extracted = 0; size_t failed = 0; for (const auto &archived : storePaths) { - const std::filesystem::path dest = outDir / DbcStoreOutputRelativePath(archived); - if (chain.ExtractFile(archived.c_str(), dest)) { + const std::filesystem::path dest = DbcOutputPath(outDir, archived); + bool ok = false; + if (chainOpen) { + ok = chain.ExtractFile(archived.c_str(), dest); + } + if (!ok) { + ok = MpqPatchChain::ExtractFromBestArchive(mpqs, archived.c_str(), dest); + } + if (ok) { ++extracted; } else { err << "Extract failed: " << archived << "\n"; @@ -141,15 +168,19 @@ int RunDbcExtractTask(const std::filesystem::path &dataDir, " - Use menu option 3 (or --list-mpqs) to see which archives were detected.\n"; } + std::filesystem::path shownOut = outDir; + if (outDir.filename() != "dbc") { + shownOut /= "dbc"; + } out << "Extracted " << extracted << " DBFilesClient file(s) (.dbc / .db2) under " - << (outDir / "dbc").string() << "\n"; + << shownOut.string() << "\n"; return failed != 0 ? 1 : 0; } int RunMapExtractTask(const std::filesystem::path &dataDir, const std::filesystem::path &outDir, std::ostream &out, - std::ostream &err) { - const auto mpqs = BuildCataclysmMpqOpenOrder(dataDir); + std::ostream &err, const std::string &locale) { + const auto mpqs = BuildCataclysmMpqOpenOrder(dataDir, locale); if (mpqs.empty()) { err << "No .mpq archives found in: " << dataDir.string() << "\n"; return 1; diff --git a/tools/extractors/ExtractorTasks.h b/tools/extractors/ExtractorTasks.h index 8b90b88..955fb01 100644 --- a/tools/extractors/ExtractorTasks.h +++ b/tools/extractors/ExtractorTasks.h @@ -2,20 +2,21 @@ #include #include +#include namespace firelands::extract { // Returns 0 on success, non-zero on failure (see stderr-style messages via err). int RunListMpqsTask(const std::filesystem::path &dataDir, std::ostream &out, - std::ostream &err); + std::ostream &err, const std::string &locale = {}); int RunDbcExtractTask(const std::filesystem::path &dataDir, const std::filesystem::path &outDir, std::ostream &out, - std::ostream &err); + std::ostream &err, const std::string &locale = {}); int RunMapExtractTask(const std::filesystem::path &dataDir, const std::filesystem::path &outDir, std::ostream &out, - std::ostream &err); + std::ostream &err, const std::string &locale = {}); // Server collision maps: `maps/*.map`, tilelists, `Cameras/` (MPQ via map_extractor task). int RunServerMapVmapExtractTask(const std::filesystem::path &dataDir, diff --git a/tools/extractors/KnownDbcFiles.h b/tools/extractors/KnownDbcFiles.h new file mode 100644 index 0000000..45535b8 --- /dev/null +++ b/tools/extractors/KnownDbcFiles.h @@ -0,0 +1,345 @@ +#pragma once + +namespace firelands::extract { +namespace known_dbc { + +// Cataclysm 4.3.4 DBFilesClient tables. Used when MPQs lack a usable listfile. +inline constexpr const char* kCataclysmDbcFiles[] = { + "Achievement.dbc", + "Achievement_Category.dbc", + "Achievement_Criteria.dbc", + "AnimationData.dbc", + "AnimKit.dbc", + "AnimKitBoneSet.dbc", + "AnimKitBoneSetAlias.dbc", + "AnimKitConfig.dbc", + "AnimKitConfigBoneSet.dbc", + "AnimKitPriority.dbc", + "AnimKitSegment.dbc", + "AnimReplacement.dbc", + "AnimReplacementSet.dbc", + "AreaAssignment.dbc", + "AreaGroup.dbc", + "AreaPOI.dbc", + "AreaPOISortedWorldState.dbc", + "AreaTable.dbc", + "AreaTrigger.dbc", + "ArmorLocation.dbc", + "AttackAnimKits.dbc", + "AttackAnimTypes.dbc", + "AuctionHouse.dbc", + "BankBagSlotPrices.dbc", + "BannedAddOns.dbc", + "BarberShopStyle.dbc", + "BattlemasterList.dbc", + "CameraMode.dbc", + "CameraShakes.dbc", + "CastableRaidBuffs.dbc", + "Cfg_Categories.dbc", + "Cfg_Configs.dbc", + "CharacterFacialHairStyles.dbc", + "CharBaseInfo.dbc", + "CharHairGeosets.dbc", + "CharSections.dbc", + "CharStartOutfit.dbc", + "CharTitles.dbc", + "ChatChannels.dbc", + "ChatProfanity.dbc", + "ChrClasses.dbc", + "ChrClassesXPowerTypes.dbc", + "ChrRaces.dbc", + "CinematicCamera.dbc", + "CinematicSequences.dbc", + "CreatureDisplayInfo.dbc", + "CreatureDisplayInfoExtra.dbc", + "CreatureFamily.dbc", + "CreatureImmunities.dbc", + "CreatureModelData.dbc", + "CreatureMovementInfo.dbc", + "CreatureSoundData.dbc", + "CreatureSpellData.dbc", + "CreatureType.dbc", + "CurrencyCategory.dbc", + "CurrencyTypes.dbc", + "DanceMoves.dbc", + "DeathThudLookups.dbc", + "DeclinedWord.dbc", + "DeclinedWordCases.dbc", + "DestructibleModelData.dbc", + "DungeonEncounter.dbc", + "DungeonMap.dbc", + "DungeonMapChunk.dbc", + "DurabilityCosts.dbc", + "DurabilityQuality.dbc", + "Emotes.dbc", + "EmotesText.dbc", + "EmotesTextData.dbc", + "EmotesTextSound.dbc", + "EnvironmentalDamage.dbc", + "Exhaustion.dbc", + "Faction.dbc", + "FactionGroup.dbc", + "FactionTemplate.dbc", + "FileData.dbc", + "FootprintTextures.dbc", + "FootstepTerrainLookup.dbc", + "GameObjectArtKit.dbc", + "GameObjectDisplayInfo.dbc", + "GameTables.dbc", + "GameTips.dbc", + "GemProperties.dbc", + "GlueScreenEmote.dbc", + "GlyphProperties.dbc", + "GlyphSlot.dbc", + "GMSurveyAnswers.dbc", + "GMSurveyCurrentSurvey.dbc", + "GMSurveyQuestions.dbc", + "GMSurveySurveys.dbc", + "GMTicketCategory.dbc", + "GroundEffectDoodad.dbc", + "GroundEffectTexture.dbc", + "gtBarberShopCostBase.dbc", + "gtChanceToMeleeCrit.dbc", + "gtChanceToMeleeCritBase.dbc", + "gtChanceToSpellCrit.dbc", + "gtChanceToSpellCritBase.dbc", + "gtCombatRatings.dbc", + "gtNPCManaCostScaler.dbc", + "gtOCTBaseHPByClass.dbc", + "gtOCTBaseMPByClass.dbc", + "gtOCTClassCombatRatingScalar.dbc", + "gtOCTHpPerStamina.dbc", + "gtOCTRegenMP.dbc", + "gtRegenMPPerSpt.dbc", + "gtSpellScaling.dbc", + "GuildColorBackground.dbc", + "GuildColorBorder.dbc", + "GuildColorEmblem.dbc", + "GuildPerkSpells.dbc", + "HelmetGeosetVisData.dbc", + "HolidayDescriptions.dbc", + "HolidayNames.dbc", + "Holidays.dbc", + "ImportPriceArmor.dbc", + "ImportPriceQuality.dbc", + "ImportPriceShield.dbc", + "ImportPriceWeapon.dbc", + "Item.db2", + "ItemArmorQuality.dbc", + "ItemArmorShield.dbc", + "ItemArmorTotal.dbc", + "ItemBagFamily.dbc", + "ItemClass.dbc", + "ItemCurrencyCost.db2", + "ItemDamageAmmo.dbc", + "ItemDamageOneHand.dbc", + "ItemDamageOneHandCaster.dbc", + "ItemDamageRanged.dbc", + "ItemDamageThrown.dbc", + "ItemDamageTwoHand.dbc", + "ItemDamageTwoHandCaster.dbc", + "ItemDamageWand.dbc", + "ItemDisenchantLoot.dbc", + "ItemDisplayInfo.dbc", + "ItemExtendedCost.db2", + "ItemGroupSounds.dbc", + "ItemLimitCategory.dbc", + "ItemNameDescription.dbc", + "ItemPetFood.dbc", + "ItemPriceBase.dbc", + "ItemPurchaseGroup.dbc", + "ItemRandomProperties.dbc", + "ItemRandomSuffix.dbc", + "ItemReforge.dbc", + "ItemSet.dbc", + "ItemSparse.db2", + "Item-sparse.db2", + "ItemSubClass.dbc", + "ItemSubClassMask.dbc", + "ItemVisualEffects.dbc", + "ItemVisuals.dbc", + "JournalEncounter.dbc", + "JournalEncounterCreature.dbc", + "JournalEncounterItem.dbc", + "JournalEncounterSection.dbc", + "JournalInstance.dbc", + "KeyChain.db2", + "Languages.dbc", + "LanguageWords.dbc", + "LFGDungeonExpansion.dbc", + "LFGDungeonGroup.dbc", + "LFGDungeons.dbc", + "LFGDungeonsGroupingmap.dbc", + "Light.dbc", + "LightFloatBand.dbc", + "LightIntBand.dbc", + "LightParams.dbc", + "LightSkybox.dbc", + "LiquidMaterial.dbc", + "LiquidObject.dbc", + "LiquidType.dbc", + "LoadingScreens.dbc", + "LoadingScreenTaxiSplines.dbc", + "Lock.dbc", + "LockType.dbc", + "MailTemplate.dbc", + "Map.dbc", + "MapDifficulty.dbc", + "Material.dbc", + "MountCapability.dbc", + "MountType.dbc", + "Movie.dbc", + "MovieFileData.dbc", + "MovieVariation.dbc", + "NameGen.dbc", + "NamesProfanity.dbc", + "NamesReserved.dbc", + "NPCSounds.dbc", + "NumTalentsAtLevel.dbc", + "ObjectEffect.dbc", + "ObjectEffectGroup.dbc", + "ObjectEffectModifier.dbc", + "ObjectEffectPackage.dbc", + "ObjectEffectPackageElem.dbc", + "OverrideSpellData.dbc", + "Package.dbc", + "PageTextMaterial.dbc", + "PaperDollItemFrame.dbc", + "ParticleColor.dbc", + "PetitionType.dbc", + "Phase.dbc", + "PhaseShiftZoneSounds.dbc", + "PhaseXPhaseGroup.dbc", + "PlayerCondition.dbc", + "PowerDisplay.dbc", + "PvpDifficulty.dbc", + "QuestFactionReward.dbc", + "QuestInfo.dbc", + "QuestPOIBlob.dbc", + "QuestPOIPoint.dbc", + "QuestSort.dbc", + "QuestXP.dbc", + "RandPropPoints.dbc", + "ResearchBranch.dbc", + "ResearchField.dbc", + "ResearchProject.dbc", + "ResearchSite.dbc", + "Resistances.dbc", + "ScalingStatDistribution.dbc", + "ScalingStatValues.dbc", + "ScreenEffect.dbc", + "ScreenLocation.dbc", + "ServerMessages.dbc", + "SkillLine.dbc", + "SkillLineAbility.dbc", + "SkillLineAbilitySortedSpell.dbc", + "SkillLineCategory.dbc", + "SkillRaceClassInfo.dbc", + "SkillTiers.dbc", + "SoundAmbience.dbc", + "SoundAmbienceFlavor.dbc", + "SoundEmitterPillPoints.dbc", + "SoundEmitters.dbc", + "SoundEntries.dbc", + "SoundEntriesAdvanced.dbc", + "SoundEntriesFallbacks.dbc", + "SoundFilter.dbc", + "SoundFilterElem.dbc", + "SoundProviderPreferences.dbc", + "SpamMessages.dbc", + "Spell.dbc", + "SpellActivationOverlay.dbc", + "SpellAuraOptions.dbc", + "SpellAuraRestrictions.dbc", + "SpellAuraVisibility.dbc", + "SpellAuraVisXTalentTab.dbc", + "SpellCastingRequirements.dbc", + "SpellCastTimes.dbc", + "SpellCategories.dbc", + "SpellCategory.dbc", + "SpellChainEffects.dbc", + "SpellClassOptions.dbc", + "SpellCooldowns.dbc", + "SpellDescriptionVariables.dbc", + "SpellDifficulty.dbc", + "SpellDispelType.dbc", + "SpellDuration.dbc", + "SpellEffect.dbc", + "SpellEffectCameraShakes.dbc", + "SpellEquippedItems.dbc", + "SpellFlyout.dbc", + "SpellFlyoutItem.dbc", + "SpellFocusObject.dbc", + "SpellIcon.dbc", + "SpellInterrupts.dbc", + "SpellItemEnchantment.dbc", + "SpellItemEnchantmentCondition.dbc", + "SpellLevels.dbc", + "SpellMechanic.dbc", + "SpellMissile.dbc", + "SpellMissileMotion.dbc", + "SpellPower.dbc", + "SpellRadius.dbc", + "SpellRange.dbc", + "SpellReagents.dbc", + "SpellRuneCost.dbc", + "SpellScaling.dbc", + "SpellShapeshift.dbc", + "SpellShapeshiftForm.dbc", + "SpellSpecialUnitEffect.dbc", + "SpellTargetRestrictions.dbc", + "SpellTotems.dbc", + "SpellVisual.dbc", + "SpellVisualEffectName.dbc", + "SpellVisualKit.dbc", + "SpellVisualKitAreaModel.dbc", + "SpellVisualKitModelAttach.dbc", + "SpellVisualPrecastTransitions.dbc", + "Startup_Strings.dbc", + "Stationery.dbc", + "StringLookups.dbc", + "SummonProperties.dbc", + "Talent.dbc", + "TalentTab.dbc", + "TalentTreePrimarySpells.dbc", + "TaxiNodes.dbc", + "TaxiPath.dbc", + "TaxiPathNode.dbc", + "TerrainMaterial.dbc", + "TerrainType.dbc", + "TerrainTypeSounds.dbc", + "TotemCategory.dbc", + "TransportAnimation.dbc", + "TransportPhysics.dbc", + "TransportRotation.dbc", + "UnitBlood.dbc", + "UnitBloodLevels.dbc", + "UnitPowerBar.dbc", + "Vehicle.dbc", + "VehicleSeat.dbc", + "VehicleUIIndicator.dbc", + "VehicleUIIndSeat.dbc", + "VideoHardware.dbc", + "VocalUISounds.dbc", + "WeaponImpactSounds.dbc", + "WeaponSwingSounds2.dbc", + "Weather.dbc", + "WMOAreaTable.dbc", + "world_PVP_Area.dbc", + "WorldChunkSounds.dbc", + "WorldMapArea.dbc", + "WorldMapContinent.dbc", + "WorldMapOverlay.dbc", + "WorldMapTransforms.dbc", + "WorldSafeLocs.dbc", + "WorldStateUI.dbc", + "WorldStateZoneSounds.dbc", + "WowError_Strings.dbc", + "ZoneIntroMusicTable.dbc", + "ZoneLight.dbc", + "ZoneLightPoint.dbc", + "ZoneMusic.dbc", +}; + +} // namespace known_dbc +} // namespace firelands::extract diff --git a/tools/extractors/MpqPatchChain.cpp b/tools/extractors/MpqPatchChain.cpp index a4b7239..db8e39f 100644 --- a/tools/extractors/MpqPatchChain.cpp +++ b/tools/extractors/MpqPatchChain.cpp @@ -119,4 +119,74 @@ bool MpqPatchChain::EnumerateAcrossArchives( return true; } +// static +bool MpqPatchChain::ExtractFromBestArchive( + const std::vector &orderedArchives, + const char *archivedPath, + const std::filesystem::path &destPath) { + std::filesystem::create_directories(destPath.parent_path()); + + for (auto it = orderedArchives.rbegin(); it != orderedArchives.rend(); ++it) { + HANDLE h = nullptr; + if (!SFileOpenArchive(it->c_str(), 0, MPQ_FLAG_READ_ONLY, &h) || h == nullptr) { + continue; + } + + HANDLE file = nullptr; + bool const exists = + SFileOpenFileEx(h, archivedPath, SFILE_OPEN_FROM_MPQ, &file); + if (exists && file) { + SFileCloseFile(file); +#if defined(_WIN32) + const std::wstring wdest = destPath.wstring(); + bool const ok = SFileExtractFile(h, archivedPath, wdest.c_str(), + SFILE_OPEN_FROM_MPQ); +#else + const std::string dest = destPath.string(); + bool const ok = SFileExtractFile(h, archivedPath, dest.c_str(), + SFILE_OPEN_FROM_MPQ); +#endif + SFileCloseArchive(h); + return ok; + } + + if (file) { + SFileCloseFile(file); + } + SFileCloseArchive(h); + } + + return false; +} + +// static +bool MpqPatchChain::FileExistsInAnyArchive( + const std::vector &orderedArchives, + const char *archivedPath) { + static int callCount = 0; + callCount++; + for (auto it = orderedArchives.rbegin(); it != orderedArchives.rend(); ++it) { + HANDLE h = nullptr; + if (!SFileOpenArchive(it->c_str(), 0, MPQ_FLAG_READ_ONLY, &h) || h == nullptr) { + if (callCount == 1) printf("OPEN_FAIL: %s\n", it->string().c_str()); + continue; + } + + HANDLE file = nullptr; + bool const exists = + SFileOpenFileEx(h, archivedPath, SFILE_OPEN_FROM_MPQ, &file); + if (callCount == 1) printf(" %s -> %s = %d\n", it->filename().string().c_str(), archivedPath, (int)exists); + if (file) { + SFileCloseFile(file); + } + SFileCloseArchive(h); + + if (exists) { + return true; + } + } + + return false; +} + } // namespace firelands::extract diff --git a/tools/extractors/MpqPatchChain.h b/tools/extractors/MpqPatchChain.h index 43f20a3..fe0a8d8 100644 --- a/tools/extractors/MpqPatchChain.h +++ b/tools/extractors/MpqPatchChain.h @@ -38,6 +38,17 @@ class MpqPatchChain { const char *wildcard, const std::function &visitor); + // Extracts from the highest-priority archive containing `archivedPath`. + // This is useful for clients whose MPQs do not form a StormLib patch chain. + static bool ExtractFromBestArchive( + const std::vector &orderedArchives, + const char *archivedPath, + const std::filesystem::path &destPath); + + static bool FileExistsInAnyArchive( + const std::vector &orderedArchives, + const char *archivedPath); + bool IsOpen() const { return handle_ != nullptr; } private: diff --git a/tools/extractors/WowDataMpqList.cpp b/tools/extractors/WowDataMpqList.cpp index 09f6293..1c04005 100644 --- a/tools/extractors/WowDataMpqList.cpp +++ b/tools/extractors/WowDataMpqList.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -20,6 +22,19 @@ bool EndsWithMpqExtension(const std::string &lowerName) { lowerName.compare(lowerName.size() - 4, 4, ".mpq") == 0; } +bool LooksLikeLocaleName(const std::string &name) { + return name.size() == 4 && + std::isalpha(static_cast(name[0])) && + std::isalpha(static_cast(name[1])) && + std::isalpha(static_cast(name[2])) && + std::isalpha(static_cast(name[3])); +} + +bool HasLocaleArchive(const std::filesystem::path &dir, const std::string &locale) { + return std::filesystem::exists(dir / ("locale-" + locale + ".MPQ")) || + std::filesystem::exists(dir / ("locale-" + locale + ".mpq")); +} + // Cataclysm 4.3.4 MPQ priority tiers (lower rank = opened first / lower overlay). // Reference: reference implementation 4.3.4 extractor load order. @@ -53,6 +68,13 @@ constexpr const char *kTier2[] = { "world2.mpq", }; +// Locale subdirectory names used by Blizzard (cover all retail languages). +constexpr const char *kKnownLocaleSubdirs[] = { + "enUS", "enGB", "esES", "esMX", "frFR", "deDE", + "koKR", "ptBR", "ptPT", "ruRU", "zhCN", "zhTW", + "itIT", "plPL", +}; + int KnownFileRank(const std::string &lowerBase) { static const std::unordered_map kExact = [] { std::unordered_map m; @@ -82,6 +104,12 @@ int KnownFileRank(const std::string &lowerBase) { return 100000 + std::stoi(sm[1].str()); } + static const std::regex kPatchBase( + R"(^patch-base-(\d+)\.mpq$)", std::regex::icase); + if (std::regex_match(lowerBase, sm, kPatchBase)) { + return 110000 + std::stoi(sm[1].str()); + } + // patch-N.MPQ (patch.MPQ = patch-1). static const std::regex kPatchNum(R"(^patch(?:-(\d+))?\.mpq$)", std::regex::icase); if (std::regex_match(lowerBase, sm, kPatchNum)) { @@ -108,6 +136,12 @@ int KnownFileRank(const std::string &lowerBase) { return 900000 + std::stoi(sm[1].str()); } + static const std::regex kCacheLocalePatch( + R"(^patch-[^-]+-(\d+)\.mpq$)", std::regex::icase); + if (std::regex_match(lowerBase, sm, kCacheLocalePatch)) { + return 910000 + std::stoi(sm[1].str()); + } + // All other unrecognised names (mid priority, stable lexical tie-break). return 400000; } @@ -147,37 +181,108 @@ void CollectMpqsFromDir(const std::filesystem::path &dir, } } -// Locale subdirectory names used by Blizzard (cover all retail languages). -constexpr const char *kKnownLocaleSubdirs[] = { - "enUS", "enGB", "esES", "esMX", "frFR", "deDE", - "koKR", "ptBR", "ptPT", "ruRU", "zhCN", "zhTW", - "itIT", "plPL", -}; +std::optional ReadLocaleFromConfig(const std::filesystem::path &dataDir) { + const std::filesystem::path config = dataDir.parent_path() / "WTF" / "Config.wtf"; + std::ifstream in(config); + if (!in) { + return std::nullopt; + } + + static const std::regex kLocaleLine( + "^\\s*SET\\s+(?:locale|installLocale)\\s+\"([^\"]+)\"", + std::regex::icase); + std::string line; + std::smatch sm; + while (std::getline(in, line)) { + if (std::regex_search(line, sm, kLocaleLine)) { + return sm[1].str(); + } + } + return std::nullopt; +} + +std::optional DetectLocaleFromDataDir(const std::filesystem::path &dataDir) { + // Prefer real locale roots directly under Data/. Cache folders can contain + // stale or partial languages and should not decide the client locale. + for (const char *locale : kKnownLocaleSubdirs) { + if (HasLocaleArchive(dataDir / locale, locale)) { + return locale; + } + } + + for (const std::filesystem::directory_entry &de : + std::filesystem::directory_iterator(dataDir)) { + if (!de.is_directory()) { + continue; + } + + const std::string dirName = de.path().filename().string(); + if (!LooksLikeLocaleName(dirName)) { + continue; + } + + if (HasLocaleArchive(de.path(), dirName)) { + return dirName; + } + } + + return std::nullopt; +} + +std::optional DetectClientLocale(const std::filesystem::path &dataDir, + const std::string &preferredLocale) { + if (!preferredLocale.empty()) { + return preferredLocale; + } + if (auto fromConfig = ReadLocaleFromConfig(dataDir)) { + return fromConfig; + } + return DetectLocaleFromDataDir(dataDir); +} + +std::filesystem::path ResolveDataDir(const std::filesystem::path &inputDir) { + if (std::filesystem::is_directory(inputDir / "Data")) { + return inputDir / "Data"; + } + return inputDir; +} } // namespace std::vector -BuildCataclysmMpqOpenOrder(const std::filesystem::path &dataDir) { +BuildCataclysmMpqOpenOrder(const std::filesystem::path &inputDir, + const std::string &preferredLocale) { + const std::filesystem::path dataDir = ResolveDataDir(inputDir); std::vector entries; // Root Data/ archives. CollectMpqsFromDir(dataDir, entries); + CollectMpqsFromDir(dataDir / "Cache", entries); - // Locale subdirectories (Data/enUS/, Data/frFR/, etc.). - for (const char *sub : kKnownLocaleSubdirs) { - CollectMpqsFromDir(dataDir / sub, entries); - } - // Also pick up any non-standard locale subfolders (2-4 char names). - for (const std::filesystem::directory_entry &de : - std::filesystem::directory_iterator(dataDir)) { - if (!de.is_directory()) { - continue; + const std::optional detectedLocale = + DetectClientLocale(dataDir, preferredLocale); + if (detectedLocale) { + CollectMpqsFromDir(dataDir / *detectedLocale, entries); + CollectMpqsFromDir(dataDir / "Cache" / *detectedLocale, entries); + } else { + // Locale subdirectories (Data/enUS/, Data/frFR/, etc.). + for (const char *sub : kKnownLocaleSubdirs) { + CollectMpqsFromDir(dataDir / sub, entries); + CollectMpqsFromDir(dataDir / "Cache" / sub, entries); } - const std::string name = de.path().filename().string(); - if (name.size() < 2 || name.size() > 5) { - continue; + // Also pick up any non-standard locale subfolders (2-4 char names). + for (const std::filesystem::directory_entry &de : + std::filesystem::directory_iterator(dataDir)) { + if (!de.is_directory()) { + continue; + } + const std::string name = de.path().filename().string(); + if (name.size() < 2 || name.size() > 5) { + continue; + } + CollectMpqsFromDir(de.path(), entries); + CollectMpqsFromDir(dataDir / "Cache" / name, entries); } - CollectMpqsFromDir(de.path(), entries); } std::sort(entries.begin(), entries.end(), CompareMpqEntries); diff --git a/tools/extractors/WowDataMpqList.h b/tools/extractors/WowDataMpqList.h index bc3c37f..5994e1d 100644 --- a/tools/extractors/WowDataMpqList.h +++ b/tools/extractors/WowDataMpqList.h @@ -9,6 +9,7 @@ namespace firelands::extract { // Enumerates *.mpq / *.MPQ in `dataDir` and returns paths sorted for Cataclysm-era // patch application (first entry = SFileOpenArchive, following = SFileOpenPatchArchive). std::vector -BuildCataclysmMpqOpenOrder(const std::filesystem::path &dataDir); +BuildCataclysmMpqOpenOrder(const std::filesystem::path &dataDir, + const std::string &preferredLocale = {}); } // namespace firelands::extract diff --git a/tools/extractors/dbc_extractor_main.cpp b/tools/extractors/dbc_extractor_main.cpp index 1a14462..2b14a6f 100644 --- a/tools/extractors/dbc_extractor_main.cpp +++ b/tools/extractors/dbc_extractor_main.cpp @@ -12,7 +12,8 @@ void PrintUsage(const char *prog) { << "Extracts DBFilesClient\\*.dbc and DBFilesClient\\*.db2 into /dbc/.\n" << "Interactive launcher: firelands-extractors\n" << "Options:\n" - << " --list-mpqs Print resolved MPQ open order and exit\n"; + << " --list-mpqs Print resolved MPQ open order and exit\n" + << " --locale Force locale archive set (example: esMX)\n"; } bool ArgMatch(int argc, char **argv, int i, const char *flag) { @@ -29,6 +30,7 @@ int main(int argc, char **argv) { std::filesystem::path dataDir; std::filesystem::path outDir; + std::string locale; bool listOnly = false; for (int i = 1; i < argc; ++i) { @@ -36,6 +38,8 @@ int main(int argc, char **argv) { dataDir = argv[++i]; } else if (ArgMatch(argc, argv, i, "--out") && i + 1 < argc) { outDir = argv[++i]; + } else if (ArgMatch(argc, argv, i, "--locale") && i + 1 < argc) { + locale = argv[++i]; } else if (ArgMatch(argc, argv, i, "--list-mpqs")) { listOnly = true; } else if (ArgMatch(argc, argv, i, "-h") || @@ -55,8 +59,9 @@ int main(int argc, char **argv) { } if (listOnly) { - return firelands::extract::RunListMpqsTask(dataDir, std::cout, std::cerr); + return firelands::extract::RunListMpqsTask(dataDir, std::cout, std::cerr, + locale); } return firelands::extract::RunDbcExtractTask(dataDir, outDir, std::cout, - std::cerr); + std::cerr, locale); } diff --git a/tools/extractors/map_extractor_main.cpp b/tools/extractors/map_extractor_main.cpp index a197403..dd54e21 100644 --- a/tools/extractors/map_extractor_main.cpp +++ b/tools/extractors/map_extractor_main.cpp @@ -11,7 +11,8 @@ void PrintUsage(const char *prog) { << " " << prog << " --data --out [options]\n" << "Interactive launcher: firelands-extractors\n" << "Options:\n" - << " --list-mpqs Print resolved MPQ open order and exit\n"; + << " --list-mpqs Print resolved MPQ open order and exit\n" + << " --locale Force locale archive set (example: esMX)\n"; } bool ArgMatch(int argc, char **argv, int i, const char *flag) { @@ -28,6 +29,7 @@ int main(int argc, char **argv) { std::filesystem::path dataDir; std::filesystem::path outDir; + std::string locale; bool listOnly = false; for (int i = 1; i < argc; ++i) { @@ -35,6 +37,8 @@ int main(int argc, char **argv) { dataDir = argv[++i]; } else if (ArgMatch(argc, argv, i, "--out") && i + 1 < argc) { outDir = argv[++i]; + } else if (ArgMatch(argc, argv, i, "--locale") && i + 1 < argc) { + locale = argv[++i]; } else if (ArgMatch(argc, argv, i, "--list-mpqs")) { listOnly = true; } else if (ArgMatch(argc, argv, i, "-h") || @@ -54,8 +58,9 @@ int main(int argc, char **argv) { } if (listOnly) { - return firelands::extract::RunListMpqsTask(dataDir, std::cout, std::cerr); + return firelands::extract::RunListMpqsTask(dataDir, std::cout, std::cerr, + locale); } return firelands::extract::RunMapExtractTask(dataDir, outDir, std::cout, - std::cerr); + std::cerr, locale); } From 20d3018039247b55107664696f4d8c6457f59981 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 02:04:31 -0500 Subject: [PATCH 11/35] feat(CommandService): add .mmap command for navmesh pathfinding and visual markers --- src/application/services/CommandService.cpp | 222 +++++++++++++++++++- src/application/services/CommandService.h | 5 + 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 613e89d..57eb991 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -3,7 +3,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -238,6 +241,26 @@ static std::string StripWowChatColorTokens(std::string const &in) { static constexpr uint64_t kMaxRestartDelaySeconds = 7ULL * 24 * 3600; +static char const *FindPathStatusName(FindPathStatus status) { + switch (status) { + case FindPathStatus::Complete: + return "Complete"; + case FindPathStatus::Partial: + return "Partial"; + case FindPathStatus::NoPath: + return "NoPath"; + case FindPathStatus::NavMeshMissing: + return "NavMeshMissing"; + } + return "Unknown"; +} + +static std::string FormatVec3(Vec3 const &v) { + std::ostringstream ss; + ss << "(" << v.x << ", " << v.y << ", " << v.z << ")"; + return ss.str(); +} + static bool ParseRestartDelayToken(std::string const &token, std::chrono::seconds &out) { if (token.size() < 2) @@ -311,6 +334,9 @@ CommandService::CommandService( RegisterCommand("gps", {[this](auto s, auto a, auto o) { return HandleGps(s, a, o); }, ToMask(Permission::CommandGps), CommandAvailability::Both, ConsoleArgLayout::TargetOnlineCharacterFirst}); + RegisterCommand("mmap", {[this](auto s, auto a, auto o) { return HandleMmap(s, a, o); }, + ToMask(Permission::CommandGps), CommandAvailability::Both, + ConsoleArgLayout::TargetOnlineCharacterFirst}); RegisterCommand("tele", {[this](auto s, auto a, auto o) { return HandleTele(s, a, o); }, ToMask(Permission::CommandTeleport), CommandAvailability::Both, ConsoleArgLayout::TargetOnlineCharacterFirst}); @@ -560,6 +586,193 @@ bool CommandService::HandleGps(std::shared_ptr session, return true; } +bool CommandService::HandleMmap(std::shared_ptr session, + const std::vector &args, + PrivilegeOrigin origin) { + (void)origin; + auto collision = WorldService::Instance().GetCollisionQueries(); + if (!collision) { + session->SendNotification("MMAP: collision service is not configured."); + return true; + } + + MovementInfo const &playerPos = session->GetPosition(); + uint32_t mapId = session->GetMapId(); + uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); + + // Auto-remove expired markers (older than 9s) + { + auto mit = _mmapMarkers.find(playerGuid); + if (mit != _mmapMarkers.end()) { + auto const now = std::chrono::steady_clock::now(); + auto map = WorldService::Instance().GetMap(mapId); + auto &markers = mit->second; + markers.erase( + std::remove_if(markers.begin(), markers.end(), + [&](auto const &p) { + if (now - p.second > std::chrono::seconds(9)) { + if (map) map->RemoveObject(p.first); + return true; + } + return false; + }), + markers.end()); + if (markers.empty()) + _mmapMarkers.erase(mit); + } + } + + // .mmap clear — remove visual markers + if (!args.empty() && args[0] == "clear") { + ClearMmapMarkers(playerGuid, mapId); + session->SendNotification("MMAP: visual markers removed."); + return true; + } + + // Determine start/end positions for pathfinding + Vec3 startPos{playerPos.x, playerPos.y, playerPos.z}; + Vec3 endPos{playerPos.x, playerPos.y, playerPos.z}; + bool checkPath = false; + std::string pathLabel; + + try { + if (!args.empty()) { + if (args.size() < 3) { + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap (with target) | .mmap clear"); + return false; + } + endPos.x = std::stof(args[0]); + endPos.y = std::stof(args[1]); + endPos.z = std::stof(args[2]); + if (args.size() > 3) + mapId = static_cast(std::stoul(args[3])); + pathLabel = "you -> " + FormatVec3(endPos); + checkPath = true; + } else { + // Check if a creature is targeted: path from creature to player + uint64_t const targetGuid = session->GetClientSelectionGuid(); + if (targetGuid != 0) { + auto map = WorldService::Instance().GetMap(mapId); + if (map) { + auto creature = map->TryGetCreature(targetGuid); + if (creature) { + MovementInfo const &crPos = creature->GetPosition(); + startPos = Vec3{crPos.x, crPos.y, crPos.z}; + endPos = Vec3{playerPos.x, playerPos.y, playerPos.z}; + pathLabel = std::string("creature(") + std::to_string(creature->GetEntry()) + + ") -> you"; + checkPath = true; + } + } + if (!checkPath) + session->SendNotification("MMAP: targeted object is not a creature."); + } + } + } catch (std::exception const &) { + session->SendNotification("MMAP: invalid coordinates."); + return false; + } + + bool const available = collision->IsNavMeshDataAvailable(mapId); + float const height = collision->GetHeight(mapId, playerPos.x, playerPos.y, playerPos.z); + std::ostringstream head; + head << "MMAP map=" << mapId << " navmesh=" << (available ? "loaded" : "missing") + << " player=" << FormatVec3(Vec3{playerPos.x, playerPos.y, playerPos.z}) + << " height=" << height; + session->SendNotification(head.str()); + + if (!checkPath) + return true; + + FindPathRequest req; + req.mapId = mapId; + req.startX = startPos.x; + req.startY = startPos.y; + req.startZ = startPos.z; + req.endX = endPos.x; + req.endY = endPos.y; + req.endZ = endPos.z; + req.smoothPath = true; + req.allowPartialPath = true; + + FindPathResult result = collision->FindPath(req); + std::ostringstream path; + path << "MMAP path " << FormatVec3(startPos) << " -> " + << FormatVec3(endPos) << " (" << pathLabel << ")" + << " status=" << FindPathStatusName(result.status) + << " waypoints=" << result.waypoints.size(); + session->SendNotification(path.str()); + + constexpr size_t kMaxPrintedWaypoints = 8; + for (size_t i = 0; i < result.waypoints.size() && i < kMaxPrintedWaypoints; ++i) { + session->SendNotification(" wp[" + std::to_string(i) + "] " + + FormatVec3(result.waypoints[i])); + } + if (result.waypoints.size() > kMaxPrintedWaypoints) { + session->SendNotification(" ... " + + std::to_string(result.waypoints.size() - + kMaxPrintedWaypoints) + + " more waypoint(s)"); + } + + // Spawn visual markers at each waypoint (auto-despawn after 9s) + ClearMmapMarkers(playerGuid, mapId); + + if (!result.waypoints.empty()) { + auto map = WorldService::Instance().GetMap(mapId); + if (map) { + constexpr uint32_t kMarkerDisplayId = 11686u; + constexpr uint32_t kMarkerEntry = 99999u; + uint64_t baseGuid = static_cast(0xF100000000000000ull) + + (static_cast(playerGuid & 0xFFFFu) << 32); + auto const now = std::chrono::steady_clock::now(); + std::vector> markers; + markers.reserve(result.waypoints.size()); + + for (size_t i = 0; i < result.waypoints.size(); ++i) { + auto const &wp = result.waypoints[i]; + float const z = wp.z + 1.0f; + + auto marker = std::make_shared( + baseGuid + i, kMarkerEntry, kMarkerDisplayId, 1u, 1u, + Creature::kDefaultFactionTemplate, 0u, 0x01000000u, 0u, 0u, 0.0f); + MovementInfo pos{}; + pos.x = wp.x; + pos.y = wp.y; + pos.z = z; + pos.orientation = 0.0f; + marker->SetPosition(pos); + WorldService::Instance().AddCreatureToMap(mapId, marker); + markers.emplace_back(marker->GetGuid(), now); + } + _mmapMarkers[playerGuid] = std::move(markers); + session->SendNotification("MMAP: " + + std::to_string(result.waypoints.size()) + + " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); + } + } + return true; +} + +void CommandService::ClearMmapMarkers(uint64_t playerGuid, uint32_t mapId) { + auto it = _mmapMarkers.find(playerGuid); + if (it == _mmapMarkers.end()) + return; + + auto map = WorldService::Instance().GetMap(mapId); + auto const now = std::chrono::steady_clock::now(); + + // Remove expired + all markers + auto &markers = it->second; + auto end = markers.end(); + for (auto &[guid, spawnTime] : markers) { + if (map) + map->RemoveObject(guid); + } + markers.clear(); + _mmapMarkers.erase(it); +} + bool CommandService::HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin) { @@ -673,13 +886,20 @@ Mailbox (Moderator+ in-game) "|cffFFD200· Position|r\n" "|cffCCCCCC.gps|r |cff888888—|r Print X, Y, Z, facing. |cff666666e.g.|r " "|cffffffff.gps|r\n" - "|cff666666Console (online character first):|r |cffffffff.gps Annabell|r", + "|cff666666Console (online character first):|r |cffffffff.gps Annabell|r\n" + "|cffCCCCCC.mmap|r |cff888888—|r Check navmesh and path waypoints. " + "|cff666666e.g.|r |cffffffff.mmap -8759 544 97|r", R"H3(-------------------------------------------------------------------------------- Position -------------------------------------------------------------------------------- .gps [.gps ] Print X, Y, Z, and facing. From console, prefix an online character name. + + .mmap [x y z [mapId]] + .mmap [x y z [mapId]] (console) + Check whether navmesh data is loaded for the current map. With coordinates, + calculate a path and print the status plus the first waypoints. )H3"}, {HelpChunkAudience::Both, ToMask(Permission::CommandTeleport), "|cffFFD200· Teleport|r\n" diff --git a/src/application/services/CommandService.h b/src/application/services/CommandService.h index c89ba4d..1947fa2 100644 --- a/src/application/services/CommandService.h +++ b/src/application/services/CommandService.h @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace Firelands { @@ -67,6 +68,9 @@ class CommandService : public ICommandService { bool HandleGps(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); + bool HandleMmap(std::shared_ptr session, + const std::vector &args, PrivilegeOrigin origin); + void ClearMmapMarkers(uint64_t playerGuid, uint32_t mapId); bool HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); bool HandleHelp(std::shared_ptr session, @@ -133,6 +137,7 @@ class CommandService : public ICommandService { std::shared_ptr _characterService; std::shared_ptr _gmTicketService; std::map _commands; + std::unordered_map>> _mmapMarkers; std::function _shutdownRequestHandler; std::optional _restartDeadline; From 12b22b93b304c200a57bf525a7bbc510419bb2c3 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 02:22:39 -0500 Subject: [PATCH 12/35] feat(combat): implement GM mode for creature aggro immunity and enhance command session packet handling --- src/application/combat/CombatHostility.cpp | 3 +- src/application/ports/ICommandSessionCore.h | 2 + src/application/services/CommandService.cpp | 108 +++++++++++------- src/application/services/CommandService.h | 3 +- src/domain/world/Player.h | 4 + .../worldsession/WorldSessionGmState.cpp | 7 ++ .../worldsession/WorldSessionLoginFlow.cpp | 1 + src/world/WorldInteractiveConsole.cpp | 2 + 8 files changed, 86 insertions(+), 44 deletions(-) diff --git a/src/application/combat/CombatHostility.cpp b/src/application/combat/CombatHostility.cpp index 15f56ed..96bba2e 100644 --- a/src/application/combat/CombatHostility.cpp +++ b/src/application/combat/CombatHostility.cpp @@ -20,7 +20,8 @@ bool CanMeleeAttack(Firelands::Player const &attacker, Firelands::Player const & bool CanMeleeAttack(Firelands::Player const &attacker, Firelands::Creature const &target, Firelands::FactionTemplateDbc const *factionTemplates) { - (void)attacker; + if (attacker.IsGmModeEnabled()) + return false; if (target.IsEvading()) return false; if (!factionTemplates || !factionTemplates->IsLoaded()) diff --git a/src/application/ports/ICommandSessionCore.h b/src/application/ports/ICommandSessionCore.h index 7b880a1..4dae37a 100644 --- a/src/application/ports/ICommandSessionCore.h +++ b/src/application/ports/ICommandSessionCore.h @@ -8,6 +8,7 @@ namespace Firelands { struct MovementInfo; +class WorldPacket; /// Session surface shared by console and world client (non-GM). class ICommandSessionCore { @@ -31,6 +32,7 @@ class ICommandSessionCore { float orientation = 0.0f) = 0; virtual AccessLevel GetAccountAccessLevel() const = 0; virtual void RequestDisconnect(std::string const &reason) { (void)reason; } + virtual void SendPacket(WorldPacket &packet) { (void)packet; } virtual uint64_t GetClientSelectionGuid() const { return 0; } virtual uint64_t GetActiveCharacterObjectGuid() const { return 0; } }; diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 57eb991..64f7fa8 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -15,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -46,6 +49,11 @@ class DelegatingCommandSession final : public ICommandSession { _operatorSession->SendNotification(message); } + void SendPacket(WorldPacket &packet) override { + if (_operatorSession) + _operatorSession->SendPacket(packet); + } + const MovementInfo &GetPosition() const override { return _subject->GetPosition(); } @@ -261,6 +269,36 @@ static std::string FormatVec3(Vec3 const &v) { return ss.str(); } +static void SendMmapMarkerCreate(std::shared_ptr const &session, + uint32_t mapId, uint64_t markerGuid, + Vec3 const &pos, uint32_t entry, + uint32_t displayId, + uint32_t factionTemplate) { + MovementInfo move{}; + move.x = pos.x; + move.y = pos.y; + move.z = pos.z; + move.orientation = 0.0f; + + UpdateData update(static_cast(mapId)); + update.AddCreateObject( + markerGuid, TYPEID_UNIT, move, + WorldSessionObjectUpdate::BuildMinimalNpcUnitCreateFields( + markerGuid, entry, displayId, 1u, 1u, 1u, 0u, factionTemplate)); + WorldPacket pkt(SMSG_UPDATE_OBJECT); + update.Build(pkt); + session->SendPacket(pkt); +} + +static void SendMmapMarkerDespawn(std::shared_ptr const &session, + uint32_t mapId, uint64_t markerGuid) { + UpdateData update(static_cast(mapId)); + update.AddOutOfRangeObjects({markerGuid}); + WorldPacket pkt(SMSG_UPDATE_OBJECT); + update.Build(pkt); + session->SendPacket(pkt); +} + static bool ParseRestartDelayToken(std::string const &token, std::chrono::seconds &out) { if (token.size() < 2) @@ -605,13 +643,12 @@ bool CommandService::HandleMmap(std::shared_ptr session, auto mit = _mmapMarkers.find(playerGuid); if (mit != _mmapMarkers.end()) { auto const now = std::chrono::steady_clock::now(); - auto map = WorldService::Instance().GetMap(mapId); auto &markers = mit->second; markers.erase( std::remove_if(markers.begin(), markers.end(), [&](auto const &p) { if (now - p.second > std::chrono::seconds(9)) { - if (map) map->RemoveObject(p.first); + SendMmapMarkerDespawn(session, mapId, p.first); return true; } return false; @@ -624,7 +661,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, // .mmap clear — remove visual markers if (!args.empty() && args[0] == "clear") { - ClearMmapMarkers(playerGuid, mapId); + ClearMmapMarkers(session, playerGuid, mapId); session->SendNotification("MMAP: visual markers removed."); return true; } @@ -716,58 +753,45 @@ bool CommandService::HandleMmap(std::shared_ptr session, } // Spawn visual markers at each waypoint (auto-despawn after 9s) - ClearMmapMarkers(playerGuid, mapId); + ClearMmapMarkers(session, playerGuid, mapId); if (!result.waypoints.empty()) { - auto map = WorldService::Instance().GetMap(mapId); - if (map) { - constexpr uint32_t kMarkerDisplayId = 11686u; - constexpr uint32_t kMarkerEntry = 99999u; - uint64_t baseGuid = static_cast(0xF100000000000000ull) + - (static_cast(playerGuid & 0xFFFFu) << 32); - auto const now = std::chrono::steady_clock::now(); - std::vector> markers; - markers.reserve(result.waypoints.size()); - - for (size_t i = 0; i < result.waypoints.size(); ++i) { - auto const &wp = result.waypoints[i]; - float const z = wp.z + 1.0f; - - auto marker = std::make_shared( - baseGuid + i, kMarkerEntry, kMarkerDisplayId, 1u, 1u, - Creature::kDefaultFactionTemplate, 0u, 0x01000000u, 0u, 0u, 0.0f); - MovementInfo pos{}; - pos.x = wp.x; - pos.y = wp.y; - pos.z = z; - pos.orientation = 0.0f; - marker->SetPosition(pos); - WorldService::Instance().AddCreatureToMap(mapId, marker); - markers.emplace_back(marker->GetGuid(), now); - } - _mmapMarkers[playerGuid] = std::move(markers); - session->SendNotification("MMAP: " + - std::to_string(result.waypoints.size()) + - " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); - } + constexpr uint32_t kMarkerDisplayId = 11686u; + constexpr uint32_t kMarkerEntry = 99999u; + uint64_t baseGuid = static_cast(0xF100000000000000ull) + + (static_cast(playerGuid & 0xFFFFu) << 32); + auto const now = std::chrono::steady_clock::now(); + std::vector> markers; + markers.reserve(result.waypoints.size()); + + for (size_t i = 0; i < result.waypoints.size(); ++i) { + auto const &wp = result.waypoints[i]; + float const z = wp.z + 1.0f; + uint64_t const markerGuid = baseGuid + i; + SendMmapMarkerCreate(session, mapId, markerGuid, + Vec3{wp.x, wp.y, z}, kMarkerEntry, + kMarkerDisplayId, Creature::kDefaultFactionTemplate); + markers.emplace_back(markerGuid, now); + } + _mmapMarkers[playerGuid] = std::move(markers); + session->SendNotification("MMAP: " + + std::to_string(result.waypoints.size()) + + " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); } return true; } -void CommandService::ClearMmapMarkers(uint64_t playerGuid, uint32_t mapId) { +void CommandService::ClearMmapMarkers(std::shared_ptr session, + uint64_t playerGuid, uint32_t mapId) { auto it = _mmapMarkers.find(playerGuid); if (it == _mmapMarkers.end()) return; - auto map = WorldService::Instance().GetMap(mapId); - auto const now = std::chrono::steady_clock::now(); - // Remove expired + all markers auto &markers = it->second; - auto end = markers.end(); for (auto &[guid, spawnTime] : markers) { - if (map) - map->RemoveObject(guid); + (void)spawnTime; + SendMmapMarkerDespawn(session, mapId, guid); } markers.clear(); _mmapMarkers.erase(it); diff --git a/src/application/services/CommandService.h b/src/application/services/CommandService.h index 1947fa2..23a9875 100644 --- a/src/application/services/CommandService.h +++ b/src/application/services/CommandService.h @@ -70,7 +70,8 @@ class CommandService : public ICommandService { const std::vector &args, PrivilegeOrigin origin); bool HandleMmap(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); - void ClearMmapMarkers(uint64_t playerGuid, uint32_t mapId); + void ClearMmapMarkers(std::shared_ptr session, + uint64_t playerGuid, uint32_t mapId); bool HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); bool HandleHelp(std::shared_ptr session, diff --git a/src/domain/world/Player.h b/src/domain/world/Player.h index 9b19b61..60cbc3b 100644 --- a/src/domain/world/Player.h +++ b/src/domain/world/Player.h @@ -93,6 +93,9 @@ class Player : public WorldObject { void SetPhaseShift(PhaseShift phaseShift) { m_phaseShift = std::move(phaseShift); } PhaseShift const &GetPhaseShift() const { return m_phaseShift; } + void SetGmModeEnabled(bool enabled) { m_gmModeEnabled = enabled; } + bool IsGmModeEnabled() const { return m_gmModeEnabled; } + private: std::shared_ptr m_notifier; uint8 m_race = 0; @@ -116,6 +119,7 @@ class Player : public WorldObject { PhaseShift m_phaseShift; UnitAuraState m_auraState; + bool m_gmModeEnabled = false; }; } // namespace Firelands diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp index 17a31d2..9b7bb23 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp @@ -34,6 +34,13 @@ void WorldSession::SetGmTagEnabled(bool on) { PublishGmVisualPatchIfInWorld(); if (_playerGuid == 0 || wasOn == on) return; + + // Sync GM mode flag on the Player for creature aggro immunity + if (auto map = runtime().GetMap(_mapId)) { + if (auto player = map->TryGetPlayer(_playerGuid)) + player->SetGmModeEnabled(on); + } + RefreshNearbyCreaturePhaseVisibility(_position.x, _position.y); RefreshNearbyCreatureGmWireFlags(); } diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionLoginFlow.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionLoginFlow.cpp index 9269214..e099765 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionLoginFlow.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionLoginFlow.cpp @@ -352,6 +352,7 @@ void WorldSession::LoginSpawnInWorld(uint64 guid, Character const &character, static_cast(GetDefaultPlayerPowerType(character.GetClass())), character.GetPrimaryStat(4), character.GetLevel()); runtime().AddPlayerToMap(_mapId, player); + player->SetGmModeEnabled(_gmAppearance.gmTagOn); if (auto map = runtime().GetMap(_mapId)) { auto const now = std::chrono::steady_clock::now(); diff --git a/src/world/WorldInteractiveConsole.cpp b/src/world/WorldInteractiveConsole.cpp index 058e7f9..751d93e 100644 --- a/src/world/WorldInteractiveConsole.cpp +++ b/src/world/WorldInteractiveConsole.cpp @@ -45,6 +45,8 @@ class ServerConsoleCommandSession final : public ICommandSession { LOG_DEBUG("[console] RequestDisconnect (no world character): {}", reason); } + void SendPacket(WorldPacket &packet) override { (void)packet; } + bool GmLearnSpell(uint32 /*spellId*/) override { return false; } bool GmUnlearnSpell(uint32 /*spellId*/) override { return false; } From c0a98b0a3c4ed83d22602026baaca02d31dd3e2f Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 02:32:44 -0500 Subject: [PATCH 13/35] feat(logging): enhance MMAP logging for collision service and navmesh operations --- src/application/services/CommandService.cpp | 22 ++++++++++- .../collision/DetourNavMeshManager.cpp | 38 ++++++++++++++++++- .../collision/MapCollisionQueriesReal.cpp | 5 +++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 64f7fa8..6c904eb 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -630,6 +630,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, (void)origin; auto collision = WorldService::Instance().GetCollisionQueries(); if (!collision) { + LOG_ERROR("MMAP: collision service is not configured."); session->SendNotification("MMAP: collision service is not configured."); return true; } @@ -638,6 +639,9 @@ bool CommandService::HandleMmap(std::shared_ptr session, uint32_t mapId = session->GetMapId(); uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); + LOG_DEBUG("MMAP request: playerGuid={} mapId={} args={}", playerGuid, mapId, + args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); + // Auto-remove expired markers (older than 9s) { auto mit = _mmapMarkers.find(playerGuid); @@ -661,6 +665,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, // .mmap clear — remove visual markers if (!args.empty() && args[0] == "clear") { + LOG_DEBUG("MMAP clear: playerGuid={} mapId={}", playerGuid, mapId); ClearMmapMarkers(session, playerGuid, mapId); session->SendNotification("MMAP: visual markers removed."); return true; @@ -675,6 +680,8 @@ bool CommandService::HandleMmap(std::shared_ptr session, try { if (!args.empty()) { if (args.size() < 3) { + LOG_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + playerGuid, mapId, args.size()); session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap (with target) | .mmap clear"); return false; } @@ -699,18 +706,27 @@ bool CommandService::HandleMmap(std::shared_ptr session, pathLabel = std::string("creature(") + std::to_string(creature->GetEntry()) + ") -> you"; checkPath = true; + LOG_DEBUG("MMAP target creature resolved: playerGuid={} targetGuid={} entry={} mapId={}", + playerGuid, targetGuid, creature->GetEntry(), mapId); } } - if (!checkPath) + if (!checkPath) { + LOG_WARN("MMAP target is not a creature or not on current map: playerGuid={} targetGuid={} mapId={}", + playerGuid, targetGuid, mapId); session->SendNotification("MMAP: targeted object is not a creature."); + } } } } catch (std::exception const &) { + LOG_ERROR("MMAP invalid coordinates parse error: playerGuid={} mapId={} args={}", + playerGuid, mapId, + args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); session->SendNotification("MMAP: invalid coordinates."); return false; } bool const available = collision->IsNavMeshDataAvailable(mapId); + LOG_DEBUG("MMAP navmesh availability: mapId={} available={}", mapId, available); float const height = collision->GetHeight(mapId, playerPos.x, playerPos.y, playerPos.z); std::ostringstream head; head << "MMAP map=" << mapId << " navmesh=" << (available ? "loaded" : "missing") @@ -733,6 +749,8 @@ bool CommandService::HandleMmap(std::shared_ptr session, req.allowPartialPath = true; FindPathResult result = collision->FindPath(req); + LOG_DEBUG("MMAP path result: mapId={} status={} waypoints={}", mapId, + FindPathStatusName(result.status), result.waypoints.size()); std::ostringstream path; path << "MMAP path " << FormatVec3(startPos) << " -> " << FormatVec3(endPos) << " (" << pathLabel << ")" @@ -771,6 +789,8 @@ bool CommandService::HandleMmap(std::shared_ptr session, SendMmapMarkerCreate(session, mapId, markerGuid, Vec3{wp.x, wp.y, z}, kMarkerEntry, kMarkerDisplayId, Creature::kDefaultFactionTemplate); + LOG_TRACE("MMAP marker spawn: mapId={} guid={} wpIndex={} pos=({}, {}, {})", + mapId, markerGuid, i, wp.x, wp.y, z); markers.emplace_back(markerGuid, now); } _mmapMarkers[playerGuid] = std::move(markers); diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index f9ae5e5..449666a 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -11,6 +11,8 @@ #include #include +#include + namespace Firelands { namespace { @@ -67,6 +69,8 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, FILE* file = fopen(fileName.c_str(), "rb"); if (!file) + LOG_DEBUG("MMAP tile missing: mapId={} tileX={} tileY={} path={}", mapId, + tileX, tileY, fileName); return false; fseek(file, 0, SEEK_END); @@ -74,6 +78,8 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, fseek(file, 0, SEEK_SET); if (fileSize < static_cast(sizeof(MmapTileHeader) + sizeof(dtMeshHeader))) { + LOG_ERROR("MMAP tile too small: mapId={} tileX={} tileY={} path={} size={}", + mapId, tileX, tileY, fileName, fileSize); fclose(file); return false; } @@ -83,6 +89,8 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, fclose(file); if (readSize != static_cast(fileSize)) + LOG_ERROR("MMAP tile short read: mapId={} tileX={} tileY={} path={} read={} size={}", + mapId, tileX, tileY, fileName, readSize, fileSize); return false; MmapTileHeader const* mmapHeader = @@ -91,6 +99,9 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, mmapHeader->dtVersion != DT_NAVMESH_VERSION || mmapHeader->mmapSize == 0 || sizeof(MmapTileHeader) + mmapHeader->mmapSize > data.size()) { + LOG_ERROR("MMAP tile header invalid: mapId={} tileX={} tileY={} path={} magic={} version={} mmapSize={} dataSize={}", + mapId, tileX, tileY, fileName, mmapHeader->mmapMagic, + mmapHeader->dtVersion, mmapHeader->mmapSize, data.size()); return false; } @@ -98,18 +109,26 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, dtMeshHeader const* header = reinterpret_cast(tilePayload); if (header->magic != DT_NAVMESH_MAGIC || header->version != DT_NAVMESH_VERSION) + LOG_ERROR("MMAP tile nav header invalid: mapId={} tileX={} tileY={} path={} magic={} version={}", + mapId, tileX, tileY, fileName, header->magic, header->version); return false; auto* tileData = static_cast(dtAlloc(mmapHeader->mmapSize, DT_ALLOC_PERM)); - if (!tileData) + if (!tileData) { + LOG_ERROR("MMAP tile alloc failed: mapId={} tileX={} tileY={} size={}", + mapId, tileX, tileY, mmapHeader->mmapSize); return false; + } std::memcpy(tileData, tilePayload, mmapHeader->mmapSize); dtStatus status = navMesh->addTile(tileData, static_cast(mmapHeader->mmapSize), DT_TILE_FREE_DATA, 0, nullptr); - if (dtStatusFailed(status)) + if (dtStatusFailed(status)) { + LOG_ERROR("MMAP tile add failed: mapId={} tileX={} tileY={} status=0x{:x}", + mapId, tileX, tileY, static_cast(status)); dtFree(tileData); + } return dtStatusSucceed(status); } @@ -131,6 +150,8 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { dtStatus status = navMesh->init(¶ms); if (dtStatusFailed(status)) { + LOG_ERROR("MMAP navmesh init failed: mapId={} status=0x{:x}", mapId, + static_cast(status)); dtFreeNavMesh(navMesh); return false; } @@ -144,6 +165,7 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { } if (!anyTileLoaded) { + LOG_WARN("MMAP navmesh load skipped: no tiles found for mapId={}", mapId); dtFreeNavMesh(navMesh); return false; } @@ -156,6 +178,8 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { status = navQuery->init(navMesh, _config.maxNavMeshNodes); if (dtStatusFailed(status)) { + LOG_ERROR("MMAP navmesh query init failed: mapId={} status=0x{:x}", mapId, + static_cast(status)); dtFreeNavMeshQuery(navQuery); dtFreeNavMesh(navMesh); return false; @@ -265,6 +289,9 @@ FindPathResult DetourNavMeshManager::FindPath( dtStatus status = navQuery->findNearestPoly( startPos, searchExtents, &filter, &startRef, startNearest); if (dtStatusFailed(status) || startRef == 0) { + LOG_DEBUG("MMAP path start not found: mapId={} start=({}, {}, {}) status=0x{:x}", + req.mapId, req.startX, req.startY, req.startZ, + static_cast(status)); result.status = FindPathStatus::NoPath; return result; } @@ -272,6 +299,9 @@ FindPathResult DetourNavMeshManager::FindPath( status = navQuery->findNearestPoly(endPos, searchExtents, &filter, &endRef, endNearest); if (dtStatusFailed(status) || endRef == 0) { + LOG_DEBUG("MMAP path end not found: mapId={} end=({}, {}, {}) status=0x{:x}", + req.mapId, req.endX, req.endY, req.endZ, + static_cast(status)); result.status = FindPathStatus::NoPath; return result; } @@ -288,6 +318,8 @@ FindPathResult DetourNavMeshManager::FindPath( &filter, pathPolys, &pathCount, maxPathPolys); if (dtStatusFailed(status) || pathCount == 0) { + LOG_DEBUG("MMAP path failed: mapId={} pathCount={} status=0x{:x}", req.mapId, + pathCount, static_cast(status)); result.status = FindPathStatus::NoPath; return result; } @@ -297,6 +329,8 @@ FindPathResult DetourNavMeshManager::FindPath( straightPathFlags, straightPathPolys, &straightPathCount, maxPathPolys, 0); if (dtStatusFailed(status) || straightPathCount == 0) { + LOG_DEBUG("MMAP straight path failed: mapId={} straightCount={} status=0x{:x}", + req.mapId, straightPathCount, static_cast(status)); result.status = FindPathStatus::NoPath; return result; } diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.cpp b/src/infrastructure/collision/MapCollisionQueriesReal.cpp index 960cbed..abd1c3c 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.cpp +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -1,5 +1,7 @@ #include +#include + namespace Firelands { MapCollisionQueriesReal::MapCollisionQueriesReal(std::string dataRoot) @@ -25,6 +27,9 @@ FindPathResult MapCollisionQueriesReal::FindPath( FindPathRequest const& req) const { if (!_navMeshManager.IsNavMeshLoaded(req.mapId)) { if (!_navMeshManager.LoadMapNavMesh(req.mapId)) { + LOG_WARN("MMAP path request could not load navmesh: mapId={} start=({}, {}, {}) end=({}, {}, {})", + req.mapId, req.startX, req.startY, req.startZ, req.endX, req.endY, + req.endZ); FindPathResult result; result.status = FindPathStatus::NavMeshMissing; return result; From 60e3b4ffa1883c9444a2bc291deb4fa67265f6da Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 03:15:08 -0500 Subject: [PATCH 14/35] feat(collision): enhance navmesh functionality with loaded map and tile queries --- .../combat/CreatureChaseMovement.cpp | 21 +- src/application/ports/IMapCollisionQueries.h | 11 + src/application/services/CommandService.cpp | 232 ++++++++++++++++-- src/application/services/WorldService.cpp | 12 + src/application/services/WorldService.h | 2 + .../collision/DetourNavMeshManager.cpp | 51 +++- .../collision/DetourNavMeshManager.h | 6 + .../collision/MapCollisionQueriesReal.cpp | 13 + .../collision/MapCollisionQueriesReal.h | 3 + .../worldsession/WorldSessionCombat.cpp | 43 +++- .../worldsession/WorldSessionGmState.cpp | 7 + .../world/MapCollisionQueriesStub.cpp | 13 + .../world/MapCollisionQueriesStub.h | 3 + 13 files changed, 384 insertions(+), 33 deletions(-) diff --git a/src/application/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index 041a5cc..76aa387 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -2,6 +2,7 @@ #include #include +#include #include using Firelands::MOVEMENTFLAG_FORWARD; @@ -131,8 +132,10 @@ std::vector ComputeNavMeshPath(uint32_t mapId, float targetY, float targetZ, Firelands::IMapCollisionQueries const *collision) { std::vector waypoints; - if (!collision) + if (!collision) { + LOG_DEBUG("CHASE navmesh path skipped: mapId={} no collision service", mapId); return waypoints; + } Firelands::FindPathRequest req; req.mapId = mapId; @@ -149,6 +152,10 @@ std::vector ComputeNavMeshPath(uint32_t mapId, if (result.status == Firelands::FindPathStatus::Complete || result.status == Firelands::FindPathStatus::Partial) { waypoints = std::move(result.waypoints); + } else { + LOG_DEBUG("CHASE navmesh path failed: mapId={} status={} start=({}, {}, {}) end=({}, {}, {})", + mapId, static_cast(result.status), start.x, start.y, start.z, + targetX, targetY, targetZ); } return waypoints; } @@ -176,10 +183,20 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( if (!state.waypoints.empty()) { state.waypoints.push_back(Vec3{targetX, targetY, targetZ}); state.currentWaypoint = 0; + LOG_DEBUG("CHASE navmesh path ready: mapId={} waypointCount={} current=({}, {}, {}) target=({}, {}, {})", + mapId, state.waypoints.size(), current.x, current.y, current.z, + targetX, targetY, targetZ); + } else { + LOG_DEBUG("CHASE navmesh path empty: mapId={} current=({}, {}, {}) target=({}, {}, {}) fallback=straight-line", + mapId, current.x, current.y, current.z, targetX, targetY, targetZ); } } if (state.waypoints.empty() || state.currentWaypoint >= state.waypoints.size()) { + if (!collision) { + LOG_DEBUG("CHASE fallback without collision: mapId={} current=({}, {}, {}) target=({}, {}, {})", + mapId, current.x, current.y, current.z, targetX, targetY, targetZ); + } return StepCreatureTowardTarget(current, targetX, targetY, targetZ, deltaSeconds, config); } @@ -192,6 +209,8 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( if (distSq < 0.25f) { ++state.currentWaypoint; + LOG_TRACE("CHASE waypoint reached: mapId={} waypointIndex={} pos=({}, {}, {})", + mapId, state.currentWaypoint - 1, wp.x, wp.y, wp.z); continue; } diff --git a/src/application/ports/IMapCollisionQueries.h b/src/application/ports/IMapCollisionQueries.h index 1de4d5c..3ea6681 100644 --- a/src/application/ports/IMapCollisionQueries.h +++ b/src/application/ports/IMapCollisionQueries.h @@ -2,6 +2,7 @@ #define FIRELANDS_APPLICATION_PORTS_I_MAP_COLLISION_QUERIES_H #include +#include #include namespace Firelands { @@ -45,6 +46,16 @@ class IMapCollisionQueries { /// When false, callers should assume no navmesh files are loaded for `mapId`. virtual bool IsNavMeshDataAvailable(uint32_t mapId) const = 0; + /// Total loaded navmesh maps. + virtual uint32_t GetLoadedMapCount() const = 0; + + /// Total loaded navmesh tiles across all maps. + virtual uint32_t GetLoadedTileCount() const = 0; + + /// Loaded tile coordinates for one map. + virtual std::vector> GetLoadedTiles( + uint32_t mapId) const = 0; + /// Line of sight between two world points. Stub returns true (open line). virtual bool LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, float y1, float z1) const = 0; diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 6c904eb..50e7b72 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -269,11 +270,173 @@ static std::string FormatVec3(Vec3 const &v) { return ss.str(); } +static constexpr float kMmapGridSize = 533.3333f; +static constexpr float kMmapNavMeshOrigin = -17066.66656f; + +static bool IsMmapSubcommand(std::string const &token, char const *name) { + return AsciiEqualsLower(token, name); +} + +static std::pair ComputeMmapGridTile(float x, float y) { + int32_t gx = 32 - static_cast(x / kMmapGridSize); + int32_t gy = 32 - static_cast(y / kMmapGridSize); + return {gx, gy}; +} + +static std::pair ComputeMmapNavTile(float x, float y) { + int32_t tileX = static_cast((y - kMmapNavMeshOrigin) / kMmapGridSize); + int32_t tileY = static_cast((x - kMmapNavMeshOrigin) / kMmapGridSize); + return {tileX, tileY}; +} + +static bool HandleMmapLoadedTiles(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + uint32_t mapId) { + if (!collision.IsNavMeshDataAvailable(mapId)) { + session->SendNotification("NavMesh not loaded for current map."); + return true; + } + + session->SendNotification("mmap loadedtiles:"); + auto tiles = collision.GetLoadedTiles(mapId); + if (tiles.empty()) { + session->SendNotification(" (none)"); + return true; + } + + for (auto const &[tileX, tileY] : tiles) { + session->SendNotification("[" + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + ", " + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + } + return true; +} + +static bool HandleMmapLoc(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + uint32_t mapId) { + auto const &pos = session->GetPosition(); + session->SendNotification("mmap tileloc:"); + + auto const [gridX, gridY] = ComputeMmapGridTile(pos.x, pos.y); + session->SendNotification("" + std::to_string(mapId) + "_" + + (gridX < 10 ? std::string("0") : std::string()) + + std::to_string(gridX) + "_" + + (gridY < 10 ? std::string("0") : std::string()) + + std::to_string(gridY) + ".mmtile"); + session->SendNotification("tileloc [" + std::to_string(gridY) + ", " + + std::to_string(gridX) + "]"); + + if (!collision.IsNavMeshDataAvailable(mapId)) { + session->SendNotification("NavMesh not loaded for current map."); + return true; + } + + auto const [tileX, tileY] = ComputeMmapNavTile(pos.x, pos.y); + session->SendNotification("Calc [" + + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + ", " + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + + auto tiles = collision.GetLoadedTiles(mapId); + bool const loaded = std::find(tiles.begin(), tiles.end(), std::make_pair( + static_cast(tileX), + static_cast(tileY))) != + tiles.end(); + if (loaded) + session->SendNotification("Dt [" + + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + "," + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + else + session->SendNotification("Dt [??,??] (no tile loaded)"); + + return true; +} + +static bool HandleMmapStats(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + uint32_t mapId) { + session->SendNotification("mmap stats:"); + session->SendNotification(std::string(" global mmap pathfinding is ") + + (collision.IsNavMeshDataAvailable(mapId) ? "enabled" + : "disabled")); + session->SendNotification(" " + std::to_string(collision.GetLoadedMapCount()) + + " maps loaded with " + + std::to_string(collision.GetLoadedTileCount()) + + " tiles overall"); + session->SendNotification("Navmesh stats:"); + session->SendNotification(" " + std::to_string(collision.GetLoadedTiles(mapId).size()) + + " tiles loaded"); + return true; +} + +static bool HandleMmapTestArea(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + std::shared_ptr const &map, + uint32_t mapId) { + if (!map) { + session->SendNotification("MMAP: current map is not available."); + return true; + } + + float radius = 40.0f; + float const cellRadius = 1.0f; + float const playerX = session->GetPosition().x; + float const playerY = session->GetPosition().y; + float const playerZ = session->GetPosition().z; + + uint32_t creatureCount = 0; + uint32_t pathCount = 0; + auto const started = std::chrono::steady_clock::now(); + + map->ForEachCreatureNear(playerX, playerY, static_cast(cellRadius), + [&](std::shared_ptr const &creature) { + ++creatureCount; + FindPathRequest req; + req.mapId = mapId; + req.startX = creature->GetX(); + req.startY = creature->GetY(); + req.startZ = creature->GetZ(); + req.endX = playerX; + req.endY = playerY; + req.endZ = playerZ; + req.smoothPath = true; + req.allowPartialPath = true; + if (collision.FindPath(req).status != FindPathStatus::NavMeshMissing) + ++pathCount; + }); + + auto const elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count(); + + if (creatureCount != 0) { + session->SendNotification("Found " + std::to_string(creatureCount) + + " Creatures."); + session->SendNotification("Generated " + std::to_string(pathCount) + + " paths in " + std::to_string(elapsed) + " ms"); + } else { + session->SendNotification("No creatures in " + std::to_string(radius) + + " yard range."); + } + return true; +} + static void SendMmapMarkerCreate(std::shared_ptr const &session, uint32_t mapId, uint64_t markerGuid, Vec3 const &pos, uint32_t entry, uint32_t displayId, uint32_t factionTemplate) { + auto marker = std::make_shared(markerGuid, entry, displayId, 100u, 1u, + factionTemplate); + marker->SetPosition(MovementInfo{.x = pos.x, .y = pos.y, .z = pos.z, .orientation = 0.0f}); + marker->SetCombatStats(BuildCreatureCombatStats(1u, 1u)); + WorldService::Instance().AddCreatureToMap(mapId, std::move(marker)); + MovementInfo move{}; move.x = pos.x; move.y = pos.y; @@ -292,6 +455,7 @@ static void SendMmapMarkerCreate(std::shared_ptr const &session static void SendMmapMarkerDespawn(std::shared_ptr const &session, uint32_t mapId, uint64_t markerGuid) { + WorldService::Instance().RemoveCreatureFromMap(mapId, markerGuid); UpdateData update(static_cast(mapId)); update.AddOutOfRangeObjects({markerGuid}); WorldPacket pkt(SMSG_UPDATE_OBJECT); @@ -638,10 +802,22 @@ bool CommandService::HandleMmap(std::shared_ptr session, MovementInfo const &playerPos = session->GetPosition(); uint32_t mapId = session->GetMapId(); uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); + auto map = WorldService::Instance().GetMap(mapId); LOG_DEBUG("MMAP request: playerGuid={} mapId={} args={}", playerGuid, mapId, args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); + if (!args.empty()) { + if (IsMmapSubcommand(args[0], "loadedtiles")) + return HandleMmapLoadedTiles(session, *collision, mapId); + if (IsMmapSubcommand(args[0], "loc")) + return HandleMmapLoc(session, *collision, mapId); + if (IsMmapSubcommand(args[0], "stats")) + return HandleMmapStats(session, *collision, mapId); + if (IsMmapSubcommand(args[0], "testarea")) + return HandleMmapTestArea(session, *collision, map, mapId); + } + // Auto-remove expired markers (older than 9s) { auto mit = _mmapMarkers.find(playerGuid); @@ -671,6 +847,10 @@ bool CommandService::HandleMmap(std::shared_ptr session, return true; } + std::vector pathArgs = args; + if (!pathArgs.empty() && IsMmapSubcommand(pathArgs[0], "path")) + pathArgs.erase(pathArgs.begin()); + // Determine start/end positions for pathfinding Vec3 startPos{playerPos.x, playerPos.y, playerPos.z}; Vec3 endPos{playerPos.x, playerPos.y, playerPos.z}; @@ -678,26 +858,28 @@ bool CommandService::HandleMmap(std::shared_ptr session, std::string pathLabel; try { - if (!args.empty()) { - if (args.size() < 3) { - LOG_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", - playerGuid, mapId, args.size()); - session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap (with target) | .mmap clear"); - return false; + if (!pathArgs.empty()) { + bool const numericArgs = pathArgs.size() >= 3 && + IsAllDigitAscii(pathArgs[0].empty() ? std::string() : pathArgs[0]); + if (pathArgs.size() < 3) { + if (!IsMmapSubcommand(args[0], "path")) { + LOG_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + playerGuid, mapId, pathArgs.size()); + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); + return false; + } } - endPos.x = std::stof(args[0]); - endPos.y = std::stof(args[1]); - endPos.z = std::stof(args[2]); - if (args.size() > 3) - mapId = static_cast(std::stoul(args[3])); - pathLabel = "you -> " + FormatVec3(endPos); - checkPath = true; - } else { - // Check if a creature is targeted: path from creature to player - uint64_t const targetGuid = session->GetClientSelectionGuid(); - if (targetGuid != 0) { - auto map = WorldService::Instance().GetMap(mapId); - if (map) { + if (pathArgs.size() >= 3 && numericArgs) { + endPos.x = std::stof(pathArgs[0]); + endPos.y = std::stof(pathArgs[1]); + endPos.z = std::stof(pathArgs[2]); + if (pathArgs.size() > 3) + mapId = static_cast(std::stoul(pathArgs[3])); + pathLabel = "you -> " + FormatVec3(endPos); + checkPath = true; + } else if (IsMmapSubcommand(args[0], "path") || pathArgs.empty()) { + uint64_t const targetGuid = session->GetClientSelectionGuid(); + if (targetGuid != 0 && map) { auto creature = map->TryGetCreature(targetGuid); if (creature) { MovementInfo const &crPos = creature->GetPosition(); @@ -711,16 +893,20 @@ bool CommandService::HandleMmap(std::shared_ptr session, } } if (!checkPath) { - LOG_WARN("MMAP target is not a creature or not on current map: playerGuid={} targetGuid={} mapId={}", - playerGuid, targetGuid, mapId); session->SendNotification("MMAP: targeted object is not a creature."); } } + if (!checkPath && pathArgs.size() >= 3 && !numericArgs) { + LOG_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + playerGuid, mapId, pathArgs.size()); + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); + return false; + } } } catch (std::exception const &) { LOG_ERROR("MMAP invalid coordinates parse error: playerGuid={} mapId={} args={}", playerGuid, mapId, - args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); + pathArgs.empty() ? std::string("") : JoinArgs(pathArgs.begin(), pathArgs.end())); session->SendNotification("MMAP: invalid coordinates."); return false; } @@ -775,7 +961,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, if (!result.waypoints.empty()) { constexpr uint32_t kMarkerDisplayId = 11686u; - constexpr uint32_t kMarkerEntry = 99999u; + constexpr uint32_t kMarkerEntry = 1u; uint64_t baseGuid = static_cast(0xF100000000000000ull) + (static_cast(playerGuid & 0xFFFFu) << 32); auto const now = std::chrono::steady_clock::now(); diff --git a/src/application/services/WorldService.cpp b/src/application/services/WorldService.cpp index 50470a0..ae6077b 100644 --- a/src/application/services/WorldService.cpp +++ b/src/application/services/WorldService.cpp @@ -25,6 +25,18 @@ void WorldService::RemovePlayerFromMap(uint32 mapId, uint64 guid) { map->RemoveObject(guid); } +void WorldService::RemoveCreatureFromMap(uint32 mapId, uint64 guid) { + std::shared_ptr service; + { + std::lock_guard lock(m_worldMutex); + service = m_mapRegistry.TryGet(mapId); + } + if (!service) + return; + if (auto *map = service->GetMap()) + map->RemoveObject(guid); +} + void WorldService::AddCreatureToMap(uint32 mapId, std::shared_ptr creature) { const uint64 guid = creature->GetGuid(); diff --git a/src/application/services/WorldService.h b/src/application/services/WorldService.h index 1bc308e..98b58ee 100644 --- a/src/application/services/WorldService.h +++ b/src/application/services/WorldService.h @@ -42,6 +42,8 @@ class WorldService { void RemovePlayerFromMap(uint32 mapId, uint64 guid); + void RemoveCreatureFromMap(uint32 mapId, uint64 guid); + /// Adds a creature to the map grid and notifies Lua (`creature_spawn`). void AddCreatureToMap(uint32 mapId, std::shared_ptr creature); diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index 449666a..e08e60b 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -68,10 +68,11 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, .string(); FILE* file = fopen(fileName.c_str(), "rb"); - if (!file) + if (!file) { LOG_DEBUG("MMAP tile missing: mapId={} tileX={} tileY={} path={}", mapId, tileX, tileY, fileName); return false; + } fseek(file, 0, SEEK_END); long fileSize = ftell(file); @@ -89,9 +90,11 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, fclose(file); if (readSize != static_cast(fileSize)) + { LOG_ERROR("MMAP tile short read: mapId={} tileX={} tileY={} path={} read={} size={}", mapId, tileX, tileY, fileName, readSize, fileSize); return false; + } MmapTileHeader const* mmapHeader = reinterpret_cast(data.data()); @@ -108,10 +111,11 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, unsigned char const* tilePayload = data.data() + sizeof(MmapTileHeader); dtMeshHeader const* header = reinterpret_cast(tilePayload); - if (header->magic != DT_NAVMESH_MAGIC || header->version != DT_NAVMESH_VERSION) + if (header->magic != DT_NAVMESH_MAGIC || header->version != DT_NAVMESH_VERSION) { LOG_ERROR("MMAP tile nav header invalid: mapId={} tileX={} tileY={} path={} magic={} version={}", mapId, tileX, tileY, fileName, header->magic, header->version); return false; + } auto* tileData = static_cast(dtAlloc(mmapHeader->mmapSize, DT_ALLOC_PERM)); @@ -142,7 +146,14 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { params.tileWidth = kTileSize; params.tileHeight = kTileSize; params.maxTiles = static_cast(kTileCountPerAxis * kTileCountPerAxis); - params.maxPolys = 1 << 16; + // Detour requires enough salt bits for tile refs. With 4096 tiles, maxPolys + // must stay at 1024 or below; larger values make dtNavMesh::init fail with + // DT_INVALID_PARAM. + params.maxPolys = 1024; + + LOG_DEBUG("MMAP navmesh init params: mapId={} origin=({}, {}, {}) tileSize={} maxTiles={} maxPolys={}", + mapId, params.orig[0], params.orig[1], params.orig[2], params.tileWidth, + params.maxTiles, params.maxPolys); dtNavMesh* navMesh = dtAllocNavMesh(); if (!navMesh) @@ -152,15 +163,23 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { if (dtStatusFailed(status)) { LOG_ERROR("MMAP navmesh init failed: mapId={} status=0x{:x}", mapId, static_cast(status)); + if (dtStatusDetail(status, DT_INVALID_PARAM)) { + LOG_ERROR("MMAP navmesh init detail: invalid params for mapId={} maxTiles={} maxPolys={}", + mapId, params.maxTiles, params.maxPolys); + } dtFreeNavMesh(navMesh); return false; } bool anyTileLoaded = false; + MapNavMesh loadedEntry; + loadedEntry.navMesh = navMesh; for (uint32_t ty = 0; ty < kTileCountPerAxis; ++ty) { for (uint32_t tx = 0; tx < kTileCountPerAxis; ++tx) { - if (ReadMmapTile(mapId, tx, ty, navMesh)) + if (ReadMmapTile(mapId, tx, ty, navMesh)) { anyTileLoaded = true; + loadedEntry.loadedTiles.emplace_back(tx, ty); + } } } @@ -185,10 +204,32 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { return false; } - _loadedMaps[mapId] = {navMesh, navQuery}; + loadedEntry.navQuery = navQuery; + _loadedMaps[mapId] = std::move(loadedEntry); return true; } +uint32_t DetourNavMeshManager::GetLoadedMapCount() const { + return static_cast(_loadedMaps.size()); +} + +uint32_t DetourNavMeshManager::GetLoadedTileCount() const { + uint32_t total = 0; + for (auto const& [mapId, entry] : _loadedMaps) { + (void)mapId; + total += static_cast(entry.loadedTiles.size()); + } + return total; +} + +std::vector> DetourNavMeshManager::GetLoadedTiles( + uint32_t mapId) const { + auto it = _loadedMaps.find(mapId); + if (it == _loadedMaps.end()) + return {}; + return it->second.loadedTiles; +} + void DetourNavMeshManager::UnloadMapNavMesh(uint32_t mapId) { auto it = _loadedMaps.find(mapId); if (it == _loadedMaps.end()) diff --git a/src/infrastructure/collision/DetourNavMeshManager.h b/src/infrastructure/collision/DetourNavMeshManager.h index 026f364..beb343d 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.h +++ b/src/infrastructure/collision/DetourNavMeshManager.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include class dtNavMesh; class dtNavMeshQuery; @@ -30,6 +32,9 @@ class DetourNavMeshManager { bool LoadMapNavMesh(uint32_t mapId); void UnloadMapNavMesh(uint32_t mapId); bool IsNavMeshLoaded(uint32_t mapId) const; + uint32_t GetLoadedMapCount() const; + uint32_t GetLoadedTileCount() const; + std::vector> GetLoadedTiles(uint32_t mapId) const; bool HasDataRoot() const { return !_dataRoot.empty(); } std::string const& GetDataRoot() const { return _dataRoot; } @@ -39,6 +44,7 @@ class DetourNavMeshManager { struct MapNavMesh { dtNavMesh* navMesh = nullptr; dtNavMeshQuery* navQuery = nullptr; + std::vector> loadedTiles; }; bool ReadMmapTile(uint32_t mapId, uint32_t tileX, uint32_t tileY, diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.cpp b/src/infrastructure/collision/MapCollisionQueriesReal.cpp index abd1c3c..5308982 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.cpp +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -13,6 +13,19 @@ bool MapCollisionQueriesReal::IsNavMeshDataAvailable(uint32_t mapId) const { return _navMeshManager.LoadMapNavMesh(mapId); } +uint32_t MapCollisionQueriesReal::GetLoadedMapCount() const { + return _navMeshManager.GetLoadedMapCount(); +} + +uint32_t MapCollisionQueriesReal::GetLoadedTileCount() const { + return _navMeshManager.GetLoadedTileCount(); +} + +std::vector> MapCollisionQueriesReal::GetLoadedTiles( + uint32_t mapId) const { + return _navMeshManager.GetLoadedTiles(mapId); +} + bool MapCollisionQueriesReal::LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, float y1, float z1) const { diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.h b/src/infrastructure/collision/MapCollisionQueriesReal.h index 59bb6f5..c09d93a 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.h +++ b/src/infrastructure/collision/MapCollisionQueriesReal.h @@ -15,6 +15,9 @@ class MapCollisionQueriesReal final : public IMapCollisionQueries { ~MapCollisionQueriesReal() override = default; bool IsNavMeshDataAvailable(uint32_t mapId) const override; + uint32_t GetLoadedMapCount() const override; + uint32_t GetLoadedTileCount() const override; + std::vector> GetLoadedTiles(uint32_t mapId) const override; bool LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, float y1, float z1) const override; FindPathResult FindPath(FindPathRequest const& req) const override; diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 9bdfda8..11be408 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -113,10 +113,27 @@ bool SessionPlayerInMeleeRangeOf(WorldSession const &session, float targetX, flo return IsWithinMeleeRange3d(pos.x, pos.y, pos.z, targetX, targetY, targetZ); } +bool HasClearMeleeLineOfSight(uint32 mapId, float fromX, float fromY, float fromZ, + float toX, float toY, float toZ) { + auto collision = WorldService::Instance().GetCollisionQueries(); + if (!collision) + return true; + bool const clear = collision->LineOfSight(mapId, fromX, fromY, fromZ, toX, toY, toZ); + if (!clear) { + LOG_DEBUG("MELEE LoS blocked: mapId={} from=({}, {}, {}) to=({}, {}, {})", + mapId, fromX, fromY, fromZ, toX, toY, toZ); + } + return clear; +} + bool SessionPlayerInMeleeRangeOfNpc(WorldSession const &session, Creature const &creature) { MovementInfo const &pos = session.GetPosition(); - return IsWithinMeleeRangeAgainstNpc(pos.x, pos.y, pos.z, creature.GetX(), creature.GetY(), - creature.GetZ()); + if (!IsWithinMeleeRangeAgainstNpc(pos.x, pos.y, pos.z, creature.GetX(), creature.GetY(), + creature.GetZ())) { + return false; + } + return HasClearMeleeLineOfSight(session.GetMapId(), creature.GetX(), creature.GetY(), + creature.GetZ(), pos.x, pos.y, pos.z); } void BroadcastCreatureChaseMove(std::shared_ptr const &map, uint64 creatureGuid, @@ -212,8 +229,12 @@ bool IsCreatureInMeleeRangeOfPlayer(Creature const &creature, WorldSession const std::chrono::steady_clock::time_point now) { MovementInfo const &playerPos = session.GetPosition(); MovementInfo const vis = GetCreatureClientVisiblePosition(creature, runtime, now); - return IsWithinMeleeRangeAgainstNpc(playerPos.x, playerPos.y, playerPos.z, vis.x, vis.y, - vis.z); + if (!IsWithinMeleeRangeAgainstNpc(playerPos.x, playerPos.y, playerPos.z, vis.x, vis.y, + vis.z)) { + return false; + } + return HasClearMeleeLineOfSight(session.GetMapId(), vis.x, vis.y, vis.z, playerPos.x, + playerPos.y, playerPos.z); } bool SessionPlayerInMeleeRangeOfNpc(WorldSession const &session, Creature const &creature, @@ -576,6 +597,11 @@ void WorldSession::StartCreatureAggro(uint64_t creatureGuid) { victimCr->GetLiveHealth() == 0) { return; } + if (attackerPl->IsGmModeEnabled()) { + LOG_DEBUG("GM aggro blocked: playerGuid={} creatureGuid={} mapId={}", + _playerGuid, creatureGuid, _mapId); + return; + } if (!application::CanMeleeAttack(*attackerPl, *victimCr, _factionTemplateDbc.get())) return; @@ -823,6 +849,15 @@ void WorldSession::ProcessCreatureCombatMovementTick() { StopAllCreatureCombat(false); return; } + if (attackerPl->IsGmModeEnabled()) { + if (!_creatureAggroed.empty() || !_creatureReturningHome.empty()) { + LOG_DEBUG("GM combat cleanup: playerGuid={} mapId={} aggroed={} returningHome={}", + _playerGuid, _mapId, _creatureAggroed.size(), _creatureReturningHome.size()); + } + StopAllCreatureCombat(false); + StopMeleeAutoAttack(false); + return; + } auto const now = std::chrono::steady_clock::now(); for (auto returnIt = _creatureReturningHome.begin(); diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp index 9b7bb23..51a3135 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp @@ -41,6 +41,13 @@ void WorldSession::SetGmTagEnabled(bool on) { player->SetGmModeEnabled(on); } + if (on) { + LOG_DEBUG("GM mode enabled: clearing active creature combat for playerGuid={} mapId={}", + _playerGuid, _mapId); + StopAllCreatureCombat(false); + StopMeleeAutoAttack(false); + } + RefreshNearbyCreaturePhaseVisibility(_position.x, _position.y); RefreshNearbyCreatureGmWireFlags(); } diff --git a/src/infrastructure/world/MapCollisionQueriesStub.cpp b/src/infrastructure/world/MapCollisionQueriesStub.cpp index 691fff6..1c78b22 100644 --- a/src/infrastructure/world/MapCollisionQueriesStub.cpp +++ b/src/infrastructure/world/MapCollisionQueriesStub.cpp @@ -9,6 +9,19 @@ bool MapCollisionQueriesStub::IsNavMeshDataAvailable(uint32_t /*mapId*/) const { return !_dataRoot.empty(); } +uint32_t MapCollisionQueriesStub::GetLoadedMapCount() const { + return 0; +} + +uint32_t MapCollisionQueriesStub::GetLoadedTileCount() const { + return 0; +} + +std::vector> MapCollisionQueriesStub::GetLoadedTiles( + uint32_t /*mapId*/) const { + return {}; +} + bool MapCollisionQueriesStub::LineOfSight(uint32_t /*mapId*/, float /*x0*/, float /*y0*/, float /*z0*/, float /*x1*/, float /*y1*/, diff --git a/src/infrastructure/world/MapCollisionQueriesStub.h b/src/infrastructure/world/MapCollisionQueriesStub.h index aadd868..e82497b 100644 --- a/src/infrastructure/world/MapCollisionQueriesStub.h +++ b/src/infrastructure/world/MapCollisionQueriesStub.h @@ -12,6 +12,9 @@ class MapCollisionQueriesStub final : public IMapCollisionQueries { explicit MapCollisionQueriesStub(std::string dataRoot = {}); bool IsNavMeshDataAvailable(uint32_t mapId) const override; + uint32_t GetLoadedMapCount() const override; + uint32_t GetLoadedTileCount() const override; + std::vector> GetLoadedTiles(uint32_t mapId) const override; bool LineOfSight(uint32_t mapId, float x0, float y0, float z0, float x1, float y1, float z1) const override; FindPathResult FindPath(FindPathRequest const& req) const override; From e17907b54c760dab3bc24f329a692b8861144131 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 11:56:27 -0500 Subject: [PATCH 15/35] feat(logging): implement MMAP-specific logging enhancements across services --- src/application/services/CommandService.cpp | 20 +++--- .../collision/DetourNavMeshManager.cpp | 32 ++++----- .../collision/MapCollisionQueriesReal.cpp | 2 +- .../worldsession/WorldSessionCombat.cpp | 28 +++++--- src/shared/Logger.h | 70 +++++++++++++++++++ src/shared/game/MeleeRange.h | 22 +++--- 6 files changed, 126 insertions(+), 48 deletions(-) diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 50e7b72..0289a68 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -794,7 +794,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, (void)origin; auto collision = WorldService::Instance().GetCollisionQueries(); if (!collision) { - LOG_ERROR("MMAP: collision service is not configured."); + LOG_MMAP_ERROR("MMAP: collision service is not configured."); session->SendNotification("MMAP: collision service is not configured."); return true; } @@ -804,7 +804,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); auto map = WorldService::Instance().GetMap(mapId); - LOG_DEBUG("MMAP request: playerGuid={} mapId={} args={}", playerGuid, mapId, + LOG_MMAP_DEBUG("MMAP request: playerGuid={} mapId={} args={}", playerGuid, mapId, args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); if (!args.empty()) { @@ -841,7 +841,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, // .mmap clear — remove visual markers if (!args.empty() && args[0] == "clear") { - LOG_DEBUG("MMAP clear: playerGuid={} mapId={}", playerGuid, mapId); + LOG_MMAP_DEBUG("MMAP clear: playerGuid={} mapId={}", playerGuid, mapId); ClearMmapMarkers(session, playerGuid, mapId); session->SendNotification("MMAP: visual markers removed."); return true; @@ -863,7 +863,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, IsAllDigitAscii(pathArgs[0].empty() ? std::string() : pathArgs[0]); if (pathArgs.size() < 3) { if (!IsMmapSubcommand(args[0], "path")) { - LOG_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", playerGuid, mapId, pathArgs.size()); session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); return false; @@ -888,7 +888,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, pathLabel = std::string("creature(") + std::to_string(creature->GetEntry()) + ") -> you"; checkPath = true; - LOG_DEBUG("MMAP target creature resolved: playerGuid={} targetGuid={} entry={} mapId={}", + LOG_MMAP_DEBUG("MMAP target creature resolved: playerGuid={} targetGuid={} entry={} mapId={}", playerGuid, targetGuid, creature->GetEntry(), mapId); } } @@ -897,14 +897,14 @@ bool CommandService::HandleMmap(std::shared_ptr session, } } if (!checkPath && pathArgs.size() >= 3 && !numericArgs) { - LOG_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", playerGuid, mapId, pathArgs.size()); session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); return false; } } } catch (std::exception const &) { - LOG_ERROR("MMAP invalid coordinates parse error: playerGuid={} mapId={} args={}", + LOG_MMAP_ERROR("MMAP invalid coordinates parse error: playerGuid={} mapId={} args={}", playerGuid, mapId, pathArgs.empty() ? std::string("") : JoinArgs(pathArgs.begin(), pathArgs.end())); session->SendNotification("MMAP: invalid coordinates."); @@ -912,7 +912,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, } bool const available = collision->IsNavMeshDataAvailable(mapId); - LOG_DEBUG("MMAP navmesh availability: mapId={} available={}", mapId, available); + LOG_MMAP_DEBUG("MMAP navmesh availability: mapId={} available={}", mapId, available); float const height = collision->GetHeight(mapId, playerPos.x, playerPos.y, playerPos.z); std::ostringstream head; head << "MMAP map=" << mapId << " navmesh=" << (available ? "loaded" : "missing") @@ -935,7 +935,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, req.allowPartialPath = true; FindPathResult result = collision->FindPath(req); - LOG_DEBUG("MMAP path result: mapId={} status={} waypoints={}", mapId, + LOG_MMAP_DEBUG("MMAP path result: mapId={} status={} waypoints={}", mapId, FindPathStatusName(result.status), result.waypoints.size()); std::ostringstream path; path << "MMAP path " << FormatVec3(startPos) << " -> " @@ -975,7 +975,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, SendMmapMarkerCreate(session, mapId, markerGuid, Vec3{wp.x, wp.y, z}, kMarkerEntry, kMarkerDisplayId, Creature::kDefaultFactionTemplate); - LOG_TRACE("MMAP marker spawn: mapId={} guid={} wpIndex={} pos=({}, {}, {})", + LOG_MMAP_TRACE("MMAP marker spawn: mapId={} guid={} wpIndex={} pos=({}, {}, {})", mapId, markerGuid, i, wp.x, wp.y, z); markers.emplace_back(markerGuid, now); } diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index e08e60b..aaaab35 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -69,7 +69,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, FILE* file = fopen(fileName.c_str(), "rb"); if (!file) { - LOG_DEBUG("MMAP tile missing: mapId={} tileX={} tileY={} path={}", mapId, + LOG_MMAP_DEBUG("MMAP tile missing: mapId={} tileX={} tileY={} path={}", mapId, tileX, tileY, fileName); return false; } @@ -79,7 +79,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, fseek(file, 0, SEEK_SET); if (fileSize < static_cast(sizeof(MmapTileHeader) + sizeof(dtMeshHeader))) { - LOG_ERROR("MMAP tile too small: mapId={} tileX={} tileY={} path={} size={}", + LOG_MMAP_ERROR("MMAP tile too small: mapId={} tileX={} tileY={} path={} size={}", mapId, tileX, tileY, fileName, fileSize); fclose(file); return false; @@ -91,7 +91,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, if (readSize != static_cast(fileSize)) { - LOG_ERROR("MMAP tile short read: mapId={} tileX={} tileY={} path={} read={} size={}", + LOG_MMAP_ERROR("MMAP tile short read: mapId={} tileX={} tileY={} path={} read={} size={}", mapId, tileX, tileY, fileName, readSize, fileSize); return false; } @@ -102,7 +102,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, mmapHeader->dtVersion != DT_NAVMESH_VERSION || mmapHeader->mmapSize == 0 || sizeof(MmapTileHeader) + mmapHeader->mmapSize > data.size()) { - LOG_ERROR("MMAP tile header invalid: mapId={} tileX={} tileY={} path={} magic={} version={} mmapSize={} dataSize={}", + LOG_MMAP_ERROR("MMAP tile header invalid: mapId={} tileX={} tileY={} path={} magic={} version={} mmapSize={} dataSize={}", mapId, tileX, tileY, fileName, mmapHeader->mmapMagic, mmapHeader->dtVersion, mmapHeader->mmapSize, data.size()); return false; @@ -112,7 +112,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, dtMeshHeader const* header = reinterpret_cast(tilePayload); if (header->magic != DT_NAVMESH_MAGIC || header->version != DT_NAVMESH_VERSION) { - LOG_ERROR("MMAP tile nav header invalid: mapId={} tileX={} tileY={} path={} magic={} version={}", + LOG_MMAP_ERROR("MMAP tile nav header invalid: mapId={} tileX={} tileY={} path={} magic={} version={}", mapId, tileX, tileY, fileName, header->magic, header->version); return false; } @@ -120,7 +120,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, auto* tileData = static_cast(dtAlloc(mmapHeader->mmapSize, DT_ALLOC_PERM)); if (!tileData) { - LOG_ERROR("MMAP tile alloc failed: mapId={} tileX={} tileY={} size={}", + LOG_MMAP_ERROR("MMAP tile alloc failed: mapId={} tileX={} tileY={} size={}", mapId, tileX, tileY, mmapHeader->mmapSize); return false; } @@ -129,7 +129,7 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, dtStatus status = navMesh->addTile(tileData, static_cast(mmapHeader->mmapSize), DT_TILE_FREE_DATA, 0, nullptr); if (dtStatusFailed(status)) { - LOG_ERROR("MMAP tile add failed: mapId={} tileX={} tileY={} status=0x{:x}", + LOG_MMAP_ERROR("MMAP tile add failed: mapId={} tileX={} tileY={} status=0x{:x}", mapId, tileX, tileY, static_cast(status)); dtFree(tileData); } @@ -151,7 +151,7 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { // DT_INVALID_PARAM. params.maxPolys = 1024; - LOG_DEBUG("MMAP navmesh init params: mapId={} origin=({}, {}, {}) tileSize={} maxTiles={} maxPolys={}", + LOG_MMAP_DEBUG("MMAP navmesh init params: mapId={} origin=({}, {}, {}) tileSize={} maxTiles={} maxPolys={}", mapId, params.orig[0], params.orig[1], params.orig[2], params.tileWidth, params.maxTiles, params.maxPolys); @@ -161,10 +161,10 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { dtStatus status = navMesh->init(¶ms); if (dtStatusFailed(status)) { - LOG_ERROR("MMAP navmesh init failed: mapId={} status=0x{:x}", mapId, + LOG_MMAP_ERROR("MMAP navmesh init failed: mapId={} status=0x{:x}", mapId, static_cast(status)); if (dtStatusDetail(status, DT_INVALID_PARAM)) { - LOG_ERROR("MMAP navmesh init detail: invalid params for mapId={} maxTiles={} maxPolys={}", + LOG_MMAP_ERROR("MMAP navmesh init detail: invalid params for mapId={} maxTiles={} maxPolys={}", mapId, params.maxTiles, params.maxPolys); } dtFreeNavMesh(navMesh); @@ -184,7 +184,7 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { } if (!anyTileLoaded) { - LOG_WARN("MMAP navmesh load skipped: no tiles found for mapId={}", mapId); + LOG_MMAP_WARN("MMAP navmesh load skipped: no tiles found for mapId={}", mapId); dtFreeNavMesh(navMesh); return false; } @@ -197,7 +197,7 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { status = navQuery->init(navMesh, _config.maxNavMeshNodes); if (dtStatusFailed(status)) { - LOG_ERROR("MMAP navmesh query init failed: mapId={} status=0x{:x}", mapId, + LOG_MMAP_ERROR("MMAP navmesh query init failed: mapId={} status=0x{:x}", mapId, static_cast(status)); dtFreeNavMeshQuery(navQuery); dtFreeNavMesh(navMesh); @@ -330,7 +330,7 @@ FindPathResult DetourNavMeshManager::FindPath( dtStatus status = navQuery->findNearestPoly( startPos, searchExtents, &filter, &startRef, startNearest); if (dtStatusFailed(status) || startRef == 0) { - LOG_DEBUG("MMAP path start not found: mapId={} start=({}, {}, {}) status=0x{:x}", + LOG_MMAP_DEBUG("MMAP path start not found: mapId={} start=({}, {}, {}) status=0x{:x}", req.mapId, req.startX, req.startY, req.startZ, static_cast(status)); result.status = FindPathStatus::NoPath; @@ -340,7 +340,7 @@ FindPathResult DetourNavMeshManager::FindPath( status = navQuery->findNearestPoly(endPos, searchExtents, &filter, &endRef, endNearest); if (dtStatusFailed(status) || endRef == 0) { - LOG_DEBUG("MMAP path end not found: mapId={} end=({}, {}, {}) status=0x{:x}", + LOG_MMAP_DEBUG("MMAP path end not found: mapId={} end=({}, {}, {}) status=0x{:x}", req.mapId, req.endX, req.endY, req.endZ, static_cast(status)); result.status = FindPathStatus::NoPath; @@ -359,7 +359,7 @@ FindPathResult DetourNavMeshManager::FindPath( &filter, pathPolys, &pathCount, maxPathPolys); if (dtStatusFailed(status) || pathCount == 0) { - LOG_DEBUG("MMAP path failed: mapId={} pathCount={} status=0x{:x}", req.mapId, + LOG_MMAP_DEBUG("MMAP path failed: mapId={} pathCount={} status=0x{:x}", req.mapId, pathCount, static_cast(status)); result.status = FindPathStatus::NoPath; return result; @@ -370,7 +370,7 @@ FindPathResult DetourNavMeshManager::FindPath( straightPathFlags, straightPathPolys, &straightPathCount, maxPathPolys, 0); if (dtStatusFailed(status) || straightPathCount == 0) { - LOG_DEBUG("MMAP straight path failed: mapId={} straightCount={} status=0x{:x}", + LOG_MMAP_DEBUG("MMAP straight path failed: mapId={} straightCount={} status=0x{:x}", req.mapId, straightPathCount, static_cast(status)); result.status = FindPathStatus::NoPath; return result; diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.cpp b/src/infrastructure/collision/MapCollisionQueriesReal.cpp index 5308982..6c6cb09 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.cpp +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -40,7 +40,7 @@ FindPathResult MapCollisionQueriesReal::FindPath( FindPathRequest const& req) const { if (!_navMeshManager.IsNavMeshLoaded(req.mapId)) { if (!_navMeshManager.LoadMapNavMesh(req.mapId)) { - LOG_WARN("MMAP path request could not load navmesh: mapId={} start=({}, {}, {}) end=({}, {}, {})", + LOG_MMAP_WARN("MMAP path request could not load navmesh: mapId={} start=({}, {}, {}) end=({}, {}, {})", req.mapId, req.startX, req.startY, req.startZ, req.endX, req.endY, req.endZ); FindPathResult result; diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 11be408..0fc8b3b 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -120,8 +120,14 @@ bool HasClearMeleeLineOfSight(uint32 mapId, float fromX, float fromY, float from return true; bool const clear = collision->LineOfSight(mapId, fromX, fromY, fromZ, toX, toY, toZ); if (!clear) { - LOG_DEBUG("MELEE LoS blocked: mapId={} from=({}, {}, {}) to=({}, {}, {})", - mapId, fromX, fromY, fromZ, toX, toY, toZ); + static thread_local std::chrono::steady_clock::time_point lastLog{}; + auto const now = std::chrono::steady_clock::now(); + if (lastLog.time_since_epoch().count() == 0 || + now - lastLog >= std::chrono::seconds(2)) { + lastLog = now; + LOG_DEBUG("MELEE LoS blocked: mapId={} from=({}, {}, {}) to=({}, {}, {})", + mapId, fromX, fromY, fromZ, toX, toY, toZ); + } } return clear; } @@ -228,21 +234,23 @@ bool IsCreatureInMeleeRangeOfPlayer(Creature const &creature, WorldSession const WorldSession::CreatureCombatRuntime const &runtime, std::chrono::steady_clock::time_point now) { MovementInfo const &playerPos = session.GetPosition(); - MovementInfo const vis = GetCreatureClientVisiblePosition(creature, runtime, now); - if (!IsWithinMeleeRangeAgainstNpc(playerPos.x, playerPos.y, playerPos.z, vis.x, vis.y, - vis.z)) { + (void)runtime; + (void)now; + MovementInfo const &creaturePos = creature.GetPosition(); + if (!IsWithinMeleeRangeAgainstNpc(playerPos.x, playerPos.y, playerPos.z, creaturePos.x, + creaturePos.y, creaturePos.z)) { return false; } - return HasClearMeleeLineOfSight(session.GetMapId(), vis.x, vis.y, vis.z, playerPos.x, - playerPos.y, playerPos.z); + return HasClearMeleeLineOfSight(session.GetMapId(), creaturePos.x, creaturePos.y, + creaturePos.z, playerPos.x, playerPos.y, playerPos.z); } bool SessionPlayerInMeleeRangeOfNpc(WorldSession const &session, Creature const &creature, WorldSession::CreatureCombatRuntime const &runtime, std::chrono::steady_clock::time_point now) { - MovementInfo const &pos = session.GetPosition(); - MovementInfo const vis = GetCreatureClientVisiblePosition(creature, runtime, now); - return IsWithinMeleeRangeAgainstNpc(pos.x, pos.y, pos.z, vis.x, vis.y, vis.z); + (void)runtime; + (void)now; + return SessionPlayerInMeleeRangeOfNpc(session, creature); } /// Advances server position along the client-aligned spline timeline. diff --git a/src/shared/Logger.h b/src/shared/Logger.h index b6aebc0..3bc73dc 100644 --- a/src/shared/Logger.h +++ b/src/shared/Logger.h @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -74,10 +75,12 @@ struct LoggerConfig { bool enableConsole = true; bool enableFile = false; std::string filePath = "firelands.log"; + std::string mmapFilePath; std::size_t maxFileSizeBytes = 10 * 1024 * 1024; // 10 MB std::size_t maxFiles = 5; LogLevel consoleLevel = LogLevel::Info; LogLevel fileLevel = LogLevel::Debug; + LogLevel mmapFileLevel = LogLevel::Debug; // Console: compact + colored level emoji, time only (no date noise) // Example: [23:18:34] [💬] Starting Authentication Server... @@ -279,6 +282,36 @@ class Logger { spdlogger_->critical(fmt, std::forward(args)...); } + template + void MmapTrace(spdlog::format_string_t fmt, Args &&...args) { + mmapSpdlogger_->trace(fmt, std::forward(args)...); + } + + template + void MmapDebug(spdlog::format_string_t fmt, Args &&...args) { + mmapSpdlogger_->debug(fmt, std::forward(args)...); + } + + template + void MmapInfo(spdlog::format_string_t fmt, Args &&...args) { + mmapSpdlogger_->info(fmt, std::forward(args)...); + } + + template + void MmapWarn(spdlog::format_string_t fmt, Args &&...args) { + mmapSpdlogger_->warn(fmt, std::forward(args)...); + } + + template + void MmapError(spdlog::format_string_t fmt, Args &&...args) { + mmapSpdlogger_->error(fmt, std::forward(args)...); + } + + template + void MmapCritical(spdlog::format_string_t fmt, Args &&...args) { + mmapSpdlogger_->critical(fmt, std::forward(args)...); + } + // ── Runtime configuration ───────────────────────────────────────────── /** @@ -300,6 +333,11 @@ class Logger { return spdlogger_; } + [[nodiscard]] std::shared_ptr GetMmapSpdLogger() const + noexcept { + return mmapSpdlogger_; + } + /// Console sink pattern from the active `LoggerConfig` (for TUI mirroring). [[nodiscard]] const std::string &GetConsolePattern() const noexcept { return console_pattern_; @@ -343,9 +381,35 @@ class Logger { spdlogger_->flush_on(spdlog::level::err); spdlog::register_logger(spdlogger_); + + std::string mmapFilePath = config.mmapFilePath; + if (mmapFilePath.empty()) { + std::filesystem::path basePath(config.filePath); + if (basePath.has_filename()) { + std::filesystem::path mmapPath = basePath; + mmapPath.replace_filename(basePath.stem().string() + "-mmap" + + basePath.extension().string()); + mmapFilePath = mmapPath.string(); + } else { + mmapFilePath = "logs/firelands-mmap.log"; + } + } + + auto mmapSink = std::make_shared( + mmapFilePath, config.maxFileSizeBytes, config.maxFiles); + mmapSink->set_level( + static_cast(config.mmapFileLevel)); + mmapSink->set_pattern(config.filePattern); + + mmapSpdlogger_ = std::make_shared(config.name + ".mmap", + mmapSink); + mmapSpdlogger_->set_level(spdlog::level::trace); + mmapSpdlogger_->flush_on(spdlog::level::err); + spdlog::register_logger(mmapSpdlogger_); } std::shared_ptr spdlogger_; + std::shared_ptr mmapSpdlogger_; std::string console_pattern_; static std::unique_ptr instance_; }; @@ -365,5 +429,11 @@ inline std::unique_ptr Logger::instance_ = nullptr; #define LOG_WARN(...) ::Firelands::Logger::Get().Warn(__VA_ARGS__) #define LOG_ERROR(...) ::Firelands::Logger::Get().Error(__VA_ARGS__) #define LOG_CRITICAL(...) ::Firelands::Logger::Get().Critical(__VA_ARGS__) +#define LOG_MMAP_TRACE(...) ::Firelands::Logger::Get().MmapTrace(__VA_ARGS__) +#define LOG_MMAP_DEBUG(...) ::Firelands::Logger::Get().MmapDebug(__VA_ARGS__) +#define LOG_MMAP_INFO(...) ::Firelands::Logger::Get().MmapInfo(__VA_ARGS__) +#define LOG_MMAP_WARN(...) ::Firelands::Logger::Get().MmapWarn(__VA_ARGS__) +#define LOG_MMAP_ERROR(...) ::Firelands::Logger::Get().MmapError(__VA_ARGS__) +#define LOG_MMAP_CRITICAL(...) ::Firelands::Logger::Get().MmapCritical(__VA_ARGS__) #endif // FIRELANDS_SHARED_LOGGER_H diff --git a/src/shared/game/MeleeRange.h b/src/shared/game/MeleeRange.h index f588e6a..bc31944 100644 --- a/src/shared/game/MeleeRange.h +++ b/src/shared/game/MeleeRange.h @@ -46,23 +46,23 @@ inline bool IsWithinMeleeRange3d(float ax, float ay, float az, float bx, float b return std::fabs(az - bz) <= kMeleeMaxVerticalSlopYards; } -/// Melee vs a creature: same Cataclysm reach formula plus NPC position-sync tolerance. +/// Melee vs a creature: strict contact range, tuned to avoid long-distance hits. inline bool IsWithinMeleeRangeAgainstNpc(float playerX, float playerY, float playerZ, - float creatureX, float creatureY, - float creatureZ, - float attackerCombatReachYards = - kDefaultUnitCombatReachYards, - float victimCombatReachYards = - kDefaultUnitCombatReachYards) { - float const maxYards = - MeleeRangeMaxYards(attackerCombatReachYards, victimCombatReachYards) + - kNpcMeleePositionSyncToleranceYards; + float creatureX, float creatureY, + float creatureZ, + float attackerCombatReachYards = + kDefaultUnitCombatReachYards, + float victimCombatReachYards = + kDefaultUnitCombatReachYards) { + (void)attackerCombatReachYards; + (void)victimCombatReachYards; + float const maxYards = kBaseMeleeRangeYards + kMeleeRangeSlopYards; float const maxSq = maxYards * maxYards; float const dx = playerX - creatureX; float const dy = playerY - creatureY; if ((dx * dx + dy * dy) > maxSq) return false; - return std::fabs(playerZ - creatureZ) <= kNpcMeleeMaxVerticalSlopYards; + return std::fabs(playerZ - creatureZ) <= kMeleeMaxVerticalSlopYards; } } // namespace Firelands From aef96f96c9bac376f842ff3e7579351d9eeba7f3 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 11:56:39 -0500 Subject: [PATCH 16/35] feat(logging): add MMAP logging configuration options and enhance mmap generator usage --- src/shared/Logger.h | 18 +++++-- src/world/WorldApplication.cpp | 6 ++- tools/vmap/mmap_generator/main.cpp | 86 ++++++++++++++++++++++++++++-- worldserver.yaml | 2 + 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/src/shared/Logger.h b/src/shared/Logger.h index 3bc73dc..e1e08e8 100644 --- a/src/shared/Logger.h +++ b/src/shared/Logger.h @@ -147,6 +147,16 @@ class LoggerBuilder { return *this; } + LoggerBuilder &WithMmapFile(std::string path) { + config_.mmapFilePath = std::move(path); + return *this; + } + + LoggerBuilder &WithMmapFileLevel(LogLevel level) { + config_.mmapFileLevel = level; + return *this; + } + /** * @brief Sets the pattern for the console sink only. * @@ -387,16 +397,16 @@ class Logger { std::filesystem::path basePath(config.filePath); if (basePath.has_filename()) { std::filesystem::path mmapPath = basePath; - mmapPath.replace_filename(basePath.stem().string() + "-mmap" + + mmapPath.replace_filename(basePath.stem().string() + "-mmaps" + basePath.extension().string()); mmapFilePath = mmapPath.string(); } else { - mmapFilePath = "logs/firelands-mmap.log"; + mmapFilePath = "logs/firelands-mmaps.log"; } } - auto mmapSink = std::make_shared( - mmapFilePath, config.maxFileSizeBytes, config.maxFiles); + auto mmapSink = + std::make_shared(mmapFilePath); mmapSink->set_level( static_cast(config.mmapFileLevel)); mmapSink->set_pattern(config.filePattern); diff --git a/src/world/WorldApplication.cpp b/src/world/WorldApplication.cpp index 9d94717..c7894bb 100644 --- a/src/world/WorldApplication.cpp +++ b/src/world/WorldApplication.cpp @@ -471,6 +471,10 @@ int RunWorldApplication(int argc, char **argv) { .WithFile(true, config.GetNested( {"Log", "File"}, "logs/firelands-world.log")) .WithFileLevel(LogLevel::Debug) + .WithMmapFile(config.GetNested( + {"Log", "MmapFile"}, "logs/firelands-mmaps.log")) + .WithMmapFileLevel(config.GetNested( + {"Log", "MmapLevel"}, LogLevel::Debug)) .WithRotatingFile(10 * 1024 * 1024, 5) .Build()); @@ -552,4 +556,4 @@ int RunWorldApplication(int argc, char **argv) { return rc; } -} // namespace Firelands \ No newline at end of file +} // namespace Firelands diff --git a/tools/vmap/mmap_generator/main.cpp b/tools/vmap/mmap_generator/main.cpp index bf485bd..e47efb9 100644 --- a/tools/vmap/mmap_generator/main.cpp +++ b/tools/vmap/mmap_generator/main.cpp @@ -4,12 +4,16 @@ #include #include #include +#include +#include void PrintUsage() { printf("firelands-mmap-generator - Build navmesh tiles from server .map data\n"); - printf("Usage: firelands-mmap-generator -m -i -o [-v ]\n"); + printf("Usage: firelands-mmap-generator -m -i -o [-v ]\n"); printf("\n"); printf(" -m Map ID to generate navmesh for (e.g. 0 for Eastern Kingdoms)\n"); + printf(" -m all Generate navmesh for every mapId found in \n"); + printf(" --all-maps Same as -m all\n"); printf(" -i Input directory containing server .map files\n"); printf(" -o Output directory for .mmtile files\n"); printf(" -v Optional: vmaps directory for building collision\n"); @@ -17,9 +21,38 @@ void PrintUsage() { printf(" -h Show this help\n"); } +std::set DiscoverMapIds(std::string const& mapsDir) { + std::set mapIds; + if (!std::filesystem::is_directory(mapsDir)) + return mapIds; + + for (auto const& entry : std::filesystem::directory_iterator(mapsDir)) { + if (!entry.is_regular_file() || entry.path().extension() != ".map") + continue; + + std::string const stem = entry.path().stem().string(); + if (stem.size() != 7) + continue; + + bool numeric = true; + for (char c : stem) { + if (c < '0' || c > '9') { + numeric = false; + break; + } + } + if (!numeric) + continue; + + mapIds.insert(static_cast(std::stoul(stem.substr(0, 3)))); + } + return mapIds; +} + int main(int argc, char* argv[]) { Firelands::MmapGeneratorConfig config = Firelands::MmapGeneratorConfig::Default(); bool hasMapId = false; + bool allMaps = false; bool hasInput = false; bool hasOutput = false; bool singleTile = false; @@ -31,8 +64,16 @@ int main(int argc, char* argv[]) { PrintUsage(); return 0; } - if (std::strcmp(argv[i], "-m") == 0 && i + 1 < argc) { - config.mapId = static_cast(std::atoi(argv[++i])); + if (std::strcmp(argv[i], "--all-maps") == 0) { + allMaps = true; + hasMapId = true; + } else if (std::strcmp(argv[i], "-m") == 0 && i + 1 < argc) { + char const* value = argv[++i]; + if (std::strcmp(value, "all") == 0 || std::strcmp(value, "ALL") == 0) { + allMaps = true; + } else { + config.mapId = static_cast(std::atoi(value)); + } hasMapId = true; } else if (std::strcmp(argv[i], "-i") == 0 && i + 1 < argc) { config.mapsDir = argv[++i]; @@ -59,6 +100,45 @@ int main(int argc, char* argv[]) { return 1; } + if (allMaps && singleTile) { + fprintf(stderr, "Error: -t can only be used with one mapId, not -m all.\n"); + return 1; + } + + if (allMaps) { + auto const mapIds = DiscoverMapIds(config.mapsDir); + if (mapIds.empty()) { + fprintf(stderr, "Error: no *.map files found in: %s\n", config.mapsDir.c_str()); + return 1; + } + + printf("\nFirelands mmap generator\n"); + printf("========================\n"); + printf("Maps: %zu detected\n", mapIds.size()); + printf("Tiles: all existing terrain tiles per map\n\n"); + + uint32_t generatedMaps = 0; + uint32_t failedMaps = 0; + for (uint32_t mapId : mapIds) { + Firelands::MmapGeneratorConfig mapConfig = config; + mapConfig.mapId = mapId; + Firelands::MmapGenerator generator(std::move(mapConfig)); + + printf("\n============================================================\n"); + printf("Generating map %u\n", mapId); + printf("============================================================\n"); + if (generator.GenerateAllTiles()) { + ++generatedMaps; + } else { + ++failedMaps; + } + } + + printf("\nAll-map generation complete: %u map(s) generated, %u map(s) failed.\n", + generatedMaps, failedMaps); + return failedMaps == 0 ? 0 : 1; + } + uint32_t const mapId = config.mapId; Firelands::MmapGenerator generator(std::move(config)); diff --git a/worldserver.yaml b/worldserver.yaml index 8784507..4ea1055 100644 --- a/worldserver.yaml +++ b/worldserver.yaml @@ -52,6 +52,8 @@ Database: Log: Level: "Debug" File: "logs/firelands-world.log" + MmapFile: "logs/firelands-mmaps.log" + MmapLevel: "Debug" StickyBanner: true Motd: From 06afc8c4f2df0abf36b8f4fcf972e394248212e7 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 12:05:17 -0500 Subject: [PATCH 17/35] feat(mmap): enhance tile generation with batch progress tracking --- TrinityCore | 1 + tools/vmap/mmap_generator/MmapGenerator.cpp | 27 +++++++++--- tools/vmap/mmap_generator/MmapGenerator.h | 9 +++- tools/vmap/mmap_generator/main.cpp | 48 ++++++++++++++++++--- 4 files changed, 74 insertions(+), 11 deletions(-) create mode 160000 TrinityCore diff --git a/TrinityCore b/TrinityCore new file mode 160000 index 0000000..6fc1f7f --- /dev/null +++ b/TrinityCore @@ -0,0 +1 @@ +Subproject commit 6fc1f7f41236cbbb43680a07ff73b2e24148694c diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index ebf281c..8a44817 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -450,7 +450,7 @@ bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { return BuildTileNavMesh(terrain, tileX, tileY, _config.mmapsDir); } -bool MmapGenerator::GenerateAllTiles() { +bool MmapGenerator::GenerateAllTiles(BatchProgress const* batchProgress) { bool anySuccess = false; uint32_t processed = 0; uint32_t succeeded = 0; @@ -471,8 +471,14 @@ bool MmapGenerator::GenerateAllTiles() { return false; } - printf("Found %u existing terrain tiles for map %u; skipping %u empty tiles.\n", - totalTiles, _config.mapId, (64u * 64u) - totalTiles); + if (batchProgress != nullptr) { + printf("Map %u/%u: map %u has %u existing terrain tiles; skipping %u empty tiles.\n", + batchProgress->mapIndex, batchProgress->mapCount, _config.mapId, + totalTiles, (64u * 64u) - totalTiles); + } else { + printf("Found %u existing terrain tiles for map %u; skipping %u empty tiles.\n", + totalTiles, _config.mapId, (64u * 64u) - totalTiles); + } for (uint32_t tileY = 0; tileY < 64; ++tileY) { for (uint32_t tileX = 0; tileX < 64; ++tileX) { @@ -483,8 +489,19 @@ bool MmapGenerator::GenerateAllTiles() { ++processed; int const percent = static_cast((processed * 100u) / totalTiles); - printf("\n[%3d%%] tile %4u/%u map %u (%02u,%02u)\n", - percent, processed, totalTiles, _config.mapId, tileX, tileY); + if (batchProgress != nullptr && batchProgress->globalTotalTiles > 0) { + uint32_t const globalProcessed = + batchProgress->globalProcessedTiles + processed; + int const globalPercent = static_cast( + (globalProcessed * 100u) / batchProgress->globalTotalTiles); + printf("\n[%3d%% all] map %3u/%u [%3d%% map] tile %4u/%u, global %5u/%u map %u (%02u,%02u)\n", + globalPercent, batchProgress->mapIndex, batchProgress->mapCount, + percent, processed, totalTiles, globalProcessed, + batchProgress->globalTotalTiles, _config.mapId, tileX, tileY); + } else { + printf("\n[%3d%%] tile %4u/%u map %u (%02u,%02u)\n", percent, + processed, totalTiles, _config.mapId, tileX, tileY); + } if (Generate(tileX, tileY)) { anySuccess = true; ++succeeded; diff --git a/tools/vmap/mmap_generator/MmapGenerator.h b/tools/vmap/mmap_generator/MmapGenerator.h index f252181..db8d6a2 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.h +++ b/tools/vmap/mmap_generator/MmapGenerator.h @@ -32,10 +32,17 @@ struct MmapGeneratorConfig { class MmapGenerator { public: + struct BatchProgress { + uint32_t mapIndex = 1; + uint32_t mapCount = 1; + uint32_t globalProcessedTiles = 0; + uint32_t globalTotalTiles = 0; + }; + explicit MmapGenerator(MmapGeneratorConfig config); bool Generate(uint32_t tileX, uint32_t tileY); - bool GenerateAllTiles(); + bool GenerateAllTiles(BatchProgress const* batchProgress = nullptr); private: struct TileTerrainData { diff --git a/tools/vmap/mmap_generator/main.cpp b/tools/vmap/mmap_generator/main.cpp index e47efb9..375b6f6 100644 --- a/tools/vmap/mmap_generator/main.cpp +++ b/tools/vmap/mmap_generator/main.cpp @@ -49,6 +49,26 @@ std::set DiscoverMapIds(std::string const& mapsDir) { return mapIds; } +uint32_t CountExistingTiles(std::string const& mapsDir, uint32_t mapId) { + uint32_t totalTiles = 0; + char stemPrefix[4]; + std::snprintf(stemPrefix, sizeof(stemPrefix), "%03u", mapId); + + if (!std::filesystem::is_directory(mapsDir)) + return 0; + + for (auto const& entry : std::filesystem::directory_iterator(mapsDir)) { + if (!entry.is_regular_file() || entry.path().extension() != ".map") + continue; + + std::string const stem = entry.path().stem().string(); + if (stem.size() == 7 && stem.rfind(stemPrefix, 0) == 0) + ++totalTiles; + } + + return totalTiles; +} + int main(int argc, char* argv[]) { Firelands::MmapGeneratorConfig config = Firelands::MmapGeneratorConfig::Default(); bool hasMapId = false; @@ -112,30 +132,48 @@ int main(int argc, char* argv[]) { return 1; } + uint32_t totalTiles = 0; + for (uint32_t mapId : mapIds) + totalTiles += CountExistingTiles(config.mapsDir, mapId); + printf("\nFirelands mmap generator\n"); printf("========================\n"); printf("Maps: %zu detected\n", mapIds.size()); - printf("Tiles: all existing terrain tiles per map\n\n"); + printf("Tiles: %u existing terrain tiles total\n\n", totalTiles); uint32_t generatedMaps = 0; uint32_t failedMaps = 0; + uint32_t processedMaps = 0; + uint32_t processedTiles = 0; for (uint32_t mapId : mapIds) { + ++processedMaps; + uint32_t const mapTiles = CountExistingTiles(config.mapsDir, mapId); Firelands::MmapGeneratorConfig mapConfig = config; mapConfig.mapId = mapId; Firelands::MmapGenerator generator(std::move(mapConfig)); printf("\n============================================================\n"); - printf("Generating map %u\n", mapId); + printf("Generating map %u/%zu: %u (%u tile(s), %u/%u global done)\n", + processedMaps, mapIds.size(), mapId, mapTiles, processedTiles, + totalTiles); printf("============================================================\n"); - if (generator.GenerateAllTiles()) { + Firelands::MmapGenerator::BatchProgress progress; + progress.mapIndex = processedMaps; + progress.mapCount = static_cast(mapIds.size()); + progress.globalProcessedTiles = processedTiles; + progress.globalTotalTiles = totalTiles; + + if (generator.GenerateAllTiles(&progress)) { ++generatedMaps; } else { ++failedMaps; } + processedTiles += mapTiles; } - printf("\nAll-map generation complete: %u map(s) generated, %u map(s) failed.\n", - generatedMaps, failedMaps); + printf("\nAll-map generation complete: %u/%zu map(s) generated, %u map(s) failed, %u/%u tile(s) processed.\n", + generatedMaps, mapIds.size(), failedMaps, processedTiles, + totalTiles); return failedMaps == 0 ? 0 : 1; } From a411361a4e434c05a0b97f11591ae8cd401099fa Mon Sep 17 00:00:00 2001 From: HkevinH Date: Sat, 30 May 2026 13:21:15 -0500 Subject: [PATCH 18/35] feat(mmap): add mmap marker allocation and enhance navmesh tile processing --- src/application/services/CommandService.cpp | 141 +++++++++++++----- .../collision/DetourNavMeshManager.cpp | 21 +++ tools/vmap/map_extractor/AdtReader.cpp | 35 ++++- tools/vmap/mmap_generator/MmapGenerator.cpp | 31 +++- 4 files changed, 184 insertions(+), 44 deletions(-) diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 0289a68..8981b1a 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -13,12 +13,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -35,6 +37,14 @@ namespace { /// Blood Elf Female civilian — usable placeholder when `.npc add` omits displayId. constexpr uint32_t kDefaultGmNpcDisplayId = 15688u; +std::atomic g_nextMmapMarkerLow{0x71000000u}; + +uint64_t AllocateMmapMarkerGuid(uint32_t creatureEntry) { + uint32_t const low = + g_nextMmapMarkerLow.fetch_add(1u, std::memory_order_relaxed); + return MakeCreatureObjectGuid(creatureEntry, low); +} + class DelegatingCommandSession final : public ICommandSession { std::shared_ptr _subject; std::shared_ptr _operatorSession; @@ -284,8 +294,10 @@ static std::pair ComputeMmapGridTile(float x, float y) { } static std::pair ComputeMmapNavTile(float x, float y) { - int32_t tileX = static_cast((y - kMmapNavMeshOrigin) / kMmapGridSize); - int32_t tileY = static_cast((x - kMmapNavMeshOrigin) / kMmapGridSize); + int32_t tileX = static_cast(std::floor((x - kMmapNavMeshOrigin) / + kMmapGridSize)); + int32_t tileY = static_cast(std::floor((y - kMmapNavMeshOrigin) / + kMmapGridSize)); return {tileX, tileY}; } @@ -319,21 +331,25 @@ static bool HandleMmapLoc(std::shared_ptr const &session, auto const &pos = session->GetPosition(); session->SendNotification("mmap tileloc:"); - auto const [gridX, gridY] = ComputeMmapGridTile(pos.x, pos.y); - session->SendNotification("" + std::to_string(mapId) + "_" + - (gridX < 10 ? std::string("0") : std::string()) + - std::to_string(gridX) + "_" + - (gridY < 10 ? std::string("0") : std::string()) + - std::to_string(gridY) + ".mmtile"); - session->SendNotification("tileloc [" + std::to_string(gridY) + ", " + - std::to_string(gridX) + "]"); - if (!collision.IsNavMeshDataAvailable(mapId)) { session->SendNotification("NavMesh not loaded for current map."); return true; } auto const [tileX, tileY] = ComputeMmapNavTile(pos.x, pos.y); + session->SendNotification(std::to_string(mapId) + "_" + + std::to_string(tileX) + "_" + + std::to_string(tileY) + ".mmtile"); + session->SendNotification("tileloc [" + + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + ", " + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + + auto const [gridX, gridY] = ComputeMmapGridTile(pos.x, pos.y); + session->SendNotification("legacy [" + std::to_string(gridY) + ", " + + std::to_string(gridX) + "]"); + session->SendNotification("Calc [" + (tileX < 10 ? std::string("0") : std::string()) + std::to_string(tileX) + ", " + @@ -426,6 +442,39 @@ static bool HandleMmapTestArea(std::shared_ptr const &session, return true; } +static std::shared_ptr ResolveMmapChaseCreature( + std::shared_ptr const &session, + std::shared_ptr const &map) { + if (!map) + return nullptr; + + uint64_t const targetGuid = session->GetClientSelectionGuid(); + if (targetGuid != 0) { + if (auto selected = map->TryGetCreature(targetGuid)) + return selected; + } + + MovementInfo const &playerPos = session->GetPosition(); + std::shared_ptr nearest; + float nearestDistSq = std::numeric_limits::max(); + constexpr int kSearchCellRadius = 2; + + map->ForEachCreatureNear( + playerPos.x, playerPos.y, kSearchCellRadius, + [&](std::shared_ptr const &creature) { + float const dx = creature->GetX() - playerPos.x; + float const dy = creature->GetY() - playerPos.y; + float const dz = creature->GetZ() - playerPos.z; + float const distSq = dx * dx + dy * dy + dz * dz; + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearest = creature; + } + }); + + return nearest; +} + static void SendMmapMarkerCreate(std::shared_ptr const &session, uint32_t mapId, uint64_t markerGuid, Vec3 const &pos, uint32_t entry, @@ -794,7 +843,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, (void)origin; auto collision = WorldService::Instance().GetCollisionQueries(); if (!collision) { - LOG_MMAP_ERROR("MMAP: collision service is not configured."); + LOG_MMAP_ERROR("[MMAP] collision service is not configured."); session->SendNotification("MMAP: collision service is not configured."); return true; } @@ -804,7 +853,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); auto map = WorldService::Instance().GetMap(mapId); - LOG_MMAP_DEBUG("MMAP request: playerGuid={} mapId={} args={}", playerGuid, mapId, + LOG_MMAP_DEBUG("[MMAP] request: playerGuid={} mapId={} args={}", playerGuid, mapId, args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); if (!args.empty()) { @@ -841,14 +890,18 @@ bool CommandService::HandleMmap(std::shared_ptr session, // .mmap clear — remove visual markers if (!args.empty() && args[0] == "clear") { - LOG_MMAP_DEBUG("MMAP clear: playerGuid={} mapId={}", playerGuid, mapId); + LOG_MMAP_DEBUG("[MMAP] clear: playerGuid={} mapId={}", playerGuid, mapId); ClearMmapMarkers(session, playerGuid, mapId); - session->SendNotification("MMAP: visual markers removed."); + session->SendNotification("[MMAP] visual markers removed."); return true; } std::vector pathArgs = args; - if (!pathArgs.empty() && IsMmapSubcommand(pathArgs[0], "path")) + bool const chasePath = + !pathArgs.empty() && IsMmapSubcommand(pathArgs[0], "chase"); + if (!pathArgs.empty() && + (IsMmapSubcommand(pathArgs[0], "path") || + IsMmapSubcommand(pathArgs[0], "chase"))) pathArgs.erase(pathArgs.begin()); // Determine start/end positions for pathfinding @@ -858,14 +911,15 @@ bool CommandService::HandleMmap(std::shared_ptr session, std::string pathLabel; try { - if (!pathArgs.empty()) { + if (!pathArgs.empty() || chasePath || + (!args.empty() && IsMmapSubcommand(args[0], "path"))) { bool const numericArgs = pathArgs.size() >= 3 && IsAllDigitAscii(pathArgs[0].empty() ? std::string() : pathArgs[0]); if (pathArgs.size() < 3) { - if (!IsMmapSubcommand(args[0], "path")) { + if (!IsMmapSubcommand(args[0], "path") && !chasePath) { LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", playerGuid, mapId, pathArgs.size()); - session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap chase | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); return false; } } @@ -877,29 +931,36 @@ bool CommandService::HandleMmap(std::shared_ptr session, mapId = static_cast(std::stoul(pathArgs[3])); pathLabel = "you -> " + FormatVec3(endPos); checkPath = true; - } else if (IsMmapSubcommand(args[0], "path") || pathArgs.empty()) { - uint64_t const targetGuid = session->GetClientSelectionGuid(); - if (targetGuid != 0 && map) { - auto creature = map->TryGetCreature(targetGuid); - if (creature) { - MovementInfo const &crPos = creature->GetPosition(); - startPos = Vec3{crPos.x, crPos.y, crPos.z}; - endPos = Vec3{playerPos.x, playerPos.y, playerPos.z}; - pathLabel = std::string("creature(") + std::to_string(creature->GetEntry()) + - ") -> you"; - checkPath = true; - LOG_MMAP_DEBUG("MMAP target creature resolved: playerGuid={} targetGuid={} entry={} mapId={}", - playerGuid, targetGuid, creature->GetEntry(), mapId); - } + } else if (IsMmapSubcommand(args[0], "path") || chasePath || + pathArgs.empty()) { + auto creature = chasePath ? ResolveMmapChaseCreature(session, map) + : nullptr; + if (!creature) { + uint64_t const targetGuid = session->GetClientSelectionGuid(); + if (targetGuid != 0 && map) + creature = map->TryGetCreature(targetGuid); + } + if (creature) { + MovementInfo const &crPos = creature->GetPosition(); + startPos = Vec3{crPos.x, crPos.y, crPos.z}; + endPos = Vec3{playerPos.x, playerPos.y, playerPos.z}; + pathLabel = std::string("creature(") + + std::to_string(creature->GetEntry()) + ") -> you"; + checkPath = true; + LOG_MMAP_DEBUG("MMAP chase creature resolved: playerGuid={} entry={} mapId={} start=({}, {}, {})", + playerGuid, creature->GetEntry(), mapId, crPos.x, crPos.y, + crPos.z); } if (!checkPath) { - session->SendNotification("MMAP: targeted object is not a creature."); + session->SendNotification( + chasePath ? "MMAP: no nearby creature found for chase path." + : "MMAP: targeted object is not a creature."); } } if (!checkPath && pathArgs.size() >= 3 && !numericArgs) { LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", playerGuid, mapId, pathArgs.size()); - session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap chase | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); return false; } } @@ -962,8 +1023,6 @@ bool CommandService::HandleMmap(std::shared_ptr session, if (!result.waypoints.empty()) { constexpr uint32_t kMarkerDisplayId = 11686u; constexpr uint32_t kMarkerEntry = 1u; - uint64_t baseGuid = static_cast(0xF100000000000000ull) + - (static_cast(playerGuid & 0xFFFFu) << 32); auto const now = std::chrono::steady_clock::now(); std::vector> markers; markers.reserve(result.waypoints.size()); @@ -971,7 +1030,7 @@ bool CommandService::HandleMmap(std::shared_ptr session, for (size_t i = 0; i < result.waypoints.size(); ++i) { auto const &wp = result.waypoints[i]; float const z = wp.z + 1.0f; - uint64_t const markerGuid = baseGuid + i; + uint64_t const markerGuid = AllocateMmapMarkerGuid(kMarkerEntry); SendMmapMarkerCreate(session, mapId, markerGuid, Vec3{wp.x, wp.y, z}, kMarkerEntry, kMarkerDisplayId, Creature::kDefaultFactionTemplate); @@ -1127,9 +1186,13 @@ Position Print X, Y, Z, and facing. From console, prefix an online character name. .mmap [x y z [mapId]] + .mmap path + .mmap chase .mmap [x y z [mapId]] (console) Check whether navmesh data is loaded for the current map. With coordinates, - calculate a path and print the status plus the first waypoints. + calculate a path and print the status plus the first waypoints. In-game, + `.mmap path` uses the selected creature, and `.mmap chase` uses the + selected creature or nearest creature to draw the path it would take to you. )H3"}, {HelpChunkAudience::Both, ToMask(Permission::CommandTeleport), "|cffFFD200· Teleport|r\n" diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index aaaab35..63a0ffd 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -29,6 +29,22 @@ struct MmapTileHeader { unsigned char padding[3]; }; +uint32_t RepairMissingPolyFlags(unsigned char* tilePayload) { + auto* header = reinterpret_cast(tilePayload); + unsigned char* cursor = tilePayload + dtAlign4(sizeof(dtMeshHeader)); + cursor += dtAlign4(sizeof(float) * 3 * header->vertCount); + auto* polys = reinterpret_cast(cursor); + + uint32_t repaired = 0; + for (int i = 0; i < header->polyCount; ++i) { + if (polys[i].flags == 0 && polys[i].getType() == DT_POLYTYPE_GROUND) { + polys[i].flags = 0x01; + ++repaired; + } + } + return repaired; +} + void WowToDetour(float x, float y, float z, float out[3]) { out[0] = x; out[1] = z; @@ -125,6 +141,11 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, return false; } std::memcpy(tileData, tilePayload, mmapHeader->mmapSize); + uint32_t const repairedFlags = RepairMissingPolyFlags(tileData); + if (repairedFlags != 0) { + LOG_MMAP_DEBUG("MMAP tile repaired missing poly flags: mapId={} tileX={} tileY={} repaired={}", + mapId, tileX, tileY, repairedFlags); + } dtStatus status = navMesh->addTile(tileData, static_cast(mmapHeader->mmapSize), DT_TILE_FREE_DATA, 0, nullptr); diff --git a/tools/vmap/map_extractor/AdtReader.cpp b/tools/vmap/map_extractor/AdtReader.cpp index 92c3418..c67b334 100644 --- a/tools/vmap/map_extractor/AdtReader.cpp +++ b/tools/vmap/map_extractor/AdtReader.cpp @@ -31,6 +31,27 @@ static constexpr uint32_t kTagMCVT = RawTag('T','V','C','M'); static constexpr uint32_t kTagMCLQ = RawTag('Q','L','C','M'); static constexpr uint32_t kTagMH2O = RawTag('O','2','H','M'); static constexpr uint32_t kTagMFBO = RawTag('O','B','F','M'); +template +static const T* ResolveMcnkSubChunk(const uint8_t* mcnkData, + uint32_t mcnkSize, + uint32_t offset, + uint32_t expectedTag) { + if (offset == 0) + return nullptr; + + uint32_t const candidates[] = {8u + offset, offset}; + uint32_t const totalSize = 8u + mcnkSize; + for (uint32_t candidate : candidates) { + if (candidate + sizeof(T) > totalSize) + continue; + + const T* chunk = reinterpret_cast(mcnkData + candidate); + if (chunk->fcc == expectedTag) + return chunk; + } + + return nullptr; +} // ─── LiquidVertexFormat resolution ────────────────────────────────────────── @@ -117,8 +138,10 @@ void AdtReader::ProcessMcnk(const uint8_t* mcnkData, // MCVT sub-chunk (relative offsets within the MCNK payload) if (mcnk->offsMCVT) { - const adt_MCVT* mcvt = reinterpret_cast( - mcnkData + 8 + mcnk->offsMCVT); // +8 skips fcc+size + const adt_MCVT* mcvt = + ResolveMcnkSubChunk(mcnkData, mcnk->size, + mcnk->offsMCVT, kTagMCVT); + if (mcvt) { // V9: outer grid (ADT_CELL_SIZE+1 × ADT_CELL_SIZE+1), stride = 2*CELL_SIZE+1 for (int y = 0; y <= kAdtCellSize; ++y) { int cy = iy * kAdtCellSize + y; @@ -135,12 +158,16 @@ void AdtReader::ProcessMcnk(const uint8_t* mcnkData, out.V8[cy][cx] += mcvt->height_map[y * (kAdtCellSize * 2 + 1) + kAdtCellSize + 1 + x]; } } + } } // MCLQ legacy liquid if (mcnk->sizeMCLQ > 8 && mcnk->offsMCLQ) { - const adt_MCLQ* liq = reinterpret_cast( - mcnkData + 8 + mcnk->offsMCLQ); + const adt_MCLQ* liq = + ResolveMcnkSubChunk(mcnkData, mcnk->size, + mcnk->offsMCLQ, kTagMCLQ); + if (!liq) + return; int count = 0; for (int y = 0; y < kAdtCellSize; ++y) { int cy = iy * kAdtCellSize + y; diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index 8a44817..bb97fb4 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -189,6 +189,35 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, } } + float actualMinZ = gridHeight; + float actualMaxZ = gridHeight; + bool haveValidHeight = false; + uint32_t sanitizedHeights = 0; + for (float& h : out.heights) { + if (!std::isfinite(h) || std::abs(h) > 20000.0f) { + h = gridHeight; + ++sanitizedHeights; + } + + if (!haveValidHeight) { + actualMinZ = h; + actualMaxZ = h; + haveValidHeight = true; + } else { + actualMinZ = std::min(actualMinZ, h); + actualMaxZ = std::max(actualMaxZ, h); + } + } + if (sanitizedHeights != 0) { + printf(" tile (%02u,%02u) sanitized %u invalid terrain height sample(s)\n", + tileX, tileY, sanitizedHeights); + } + + if (haveValidHeight) { + out.minZ = actualMinZ; + out.maxZ = actualMaxZ; + } + fclose(file); return true; } @@ -347,7 +376,7 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, PrintTileProgress(tileX, tileY, 92, "detail mesh built"); for (int i = 0; i < pmesh->npolys; ++i) { - if (pmesh->areas[i] == RC_WALKABLE_AREA) + if (pmesh->areas[i] != RC_NULL_AREA) pmesh->flags[i] = 0x01; } From 3643104e18716d2db8ddd29fede52572991263bc Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 18:51:10 +0000 Subject: [PATCH 19/35] fix(mmap): correct tile coordinate convention and ground-clamp creature chase - MmapGenerator: TileOriginX/Y now follow the WoW ADT convention so the generated tiles land at the right navmesh slot ((31 - row)*TS for X, (31 - col)*TS for Y), with V9 sampled from its NW origin and triangle winding rebuilt to keep normals pointing up. - DetourNavMeshManager: drop maxTiles to 1024 (tileBits=10) so maxPolys can rise to 4096 (polyBits=12), eliminating DT_INVALID_PARAM addTile failures on dense terrain. - DetourNavMeshManager: new GetNavMeshHeight that queries findNearestPoly with a wide vertical extent so a caller above the floor still resolves the ground polygon below. MapCollisionQueriesReal::GetHeight prefers it over the vmap fallback. - WorldSessionCombat: project the chase targetZ to the navmesh ground before the relocation check and after the step, so creatures stay on the floor when the player flies up. - CreatureChaseMovementTest: implement the remaining IMapCollisionQueries overrides on the mock so the suite builds again. --- .../collision/DetourNavMeshManager.cpp | 40 ++++++++++++++++--- .../collision/DetourNavMeshManager.h | 7 ++++ .../collision/MapCollisionQueriesReal.cpp | 10 +++++ .../worldsession/WorldSessionCombat.cpp | 28 ++++++++++--- .../unit/combat/CreatureChaseMovementTest.cpp | 5 +++ tools/vmap/mmap_generator/MmapGenerator.cpp | 36 +++++++++++------ 6 files changed, 104 insertions(+), 22 deletions(-) diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index 63a0ffd..d255772 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -166,11 +166,13 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { dtVcopy(params.orig, navOrigin); params.tileWidth = kTileSize; params.tileHeight = kTileSize; - params.maxTiles = static_cast(kTileCountPerAxis * kTileCountPerAxis); - // Detour requires enough salt bits for tile refs. With 4096 tiles, maxPolys - // must stay at 1024 or below; larger values make dtNavMesh::init fail with - // DT_INVALID_PARAM. - params.maxPolys = 1024; + // Detour's poly ref packs tileBits + polyBits + saltBits == 32 with + // saltBits >= 10. With maxTiles=1024 (tileBits=10) we can raise maxPolys to + // 4096 (polyBits=12). A continent's worst case is ~900 ADTs, so 1024 slots + // is enough and gives addTile much more room before DT_INVALID_PARAM fires + // on dense terrain. + params.maxTiles = 1024; + params.maxPolys = 4096; LOG_MMAP_DEBUG("MMAP navmesh init params: mapId={} origin=({}, {}, {}) tileSize={} maxTiles={} maxPolys={}", mapId, params.orig[0], params.orig[1], params.orig[2], params.tileWidth, @@ -268,6 +270,34 @@ bool DetourNavMeshManager::IsNavMeshLoaded(uint32_t mapId) const { return _loadedMaps.count(mapId) > 0; } +bool DetourNavMeshManager::GetNavMeshHeight(uint32_t mapId, float x, float y, + float zHint, float& outZ) const { + auto it = _loadedMaps.find(mapId); + if (it == _loadedMaps.end() || it->second.navQuery == nullptr) + return false; + + float queryPos[3]{}; + WowToDetour(x, y, zHint, queryPos); + // Vertical extent is intentionally large so a flying caller still finds the + // ground polygon underneath them. + float const extents[3] = {_config.maxSearchRadius, 1000.0f, + _config.maxSearchRadius}; + + dtQueryFilter filter; + filter.setIncludeFlags(0xFFFF); + filter.setExcludeFlags(0); + + dtPolyRef ref = 0; + float nearest[3]{}; + dtStatus status = it->second.navQuery->findNearestPoly(queryPos, extents, + &filter, &ref, nearest); + if (dtStatusFailed(status) || ref == 0) + return false; + + outZ = nearest[1]; + return true; +} + void DetourNavMeshManager::RemoveDuplicateWaypoints( std::vector& waypoints) { if (waypoints.size() <= 1) diff --git a/src/infrastructure/collision/DetourNavMeshManager.h b/src/infrastructure/collision/DetourNavMeshManager.h index beb343d..11dd8d2 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.h +++ b/src/infrastructure/collision/DetourNavMeshManager.h @@ -40,6 +40,13 @@ class DetourNavMeshManager { FindPathResult FindPath(FindPathRequest const& req) const; + /// Sample the navmesh ground height at WoW (x, y). Searches with a wide + /// vertical extent so callers above the terrain (flying players) still + /// resolve the floor underneath. Returns true and writes the height into + /// `outZ` when a poly is found; false otherwise. + bool GetNavMeshHeight(uint32_t mapId, float x, float y, float zHint, + float& outZ) const; + private: struct MapNavMesh { dtNavMesh* navMesh = nullptr; diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.cpp b/src/infrastructure/collision/MapCollisionQueriesReal.cpp index 6c6cb09..1ad50c5 100644 --- a/src/infrastructure/collision/MapCollisionQueriesReal.cpp +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -53,6 +53,16 @@ FindPathResult MapCollisionQueriesReal::FindPath( float MapCollisionQueriesReal::GetHeight(uint32_t mapId, float x, float y, float zHint) const { + // Prefer the navmesh ground height — it reflects walkable terrain (what AI + // should track) and is available wherever creatures path. Fall back to the + // raw vmap surface, then to the caller's hint. + if (_navMeshManager.IsNavMeshLoaded(mapId) || + _navMeshManager.LoadMapNavMesh(mapId)) { + float navHeight = 0.0f; + if (_navMeshManager.GetNavMeshHeight(mapId, x, y, zHint, navHeight)) + return navHeight; + } + if (_navMeshManager.HasDataRoot() && !_vmapManager.IsMapLoaded(mapId)) _vmapManager.LoadMap(mapId, _navMeshManager.GetDataRoot()); if (_vmapManager.IsMapLoaded(mapId)) diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 0fc8b3b..105037f 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -355,6 +355,21 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, SyncCreatureToActiveSpline(map, creature, runtime, now); + MovementInfo from = creature->GetPosition(); + auto const collisionQueries = WorldService::Instance().GetCollisionQueries(); + bool const useNavMesh = + collisionQueries && collisionQueries->IsNavMeshDataAvailable(map->GetMapId()); + + // Pin the chase target to the navmesh ground so creatures don't follow a + // flying player into the air. Done before the relocation check so the + // stored grounded Z and the new grounded Z compare consistently across + // ticks (otherwise an airborne player would trigger a replan every tick). + // Home splines already track a grounded point. + if (collisionQueries && !returnHomeSpline) { + targetZ = collisionQueries->GetHeight(map->GetMapId(), targetX, targetY, + from.z); + } + bool const chaseTargetMoved = !returnHomeSpline && (!runtime.lastChaseTargetPos.has_value() || @@ -363,7 +378,6 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, runtime.lastChaseTargetPos->z, targetX, targetY, targetZ)); - MovementInfo from = creature->GetPosition(); if (chaseTargetMoved && runtime.activeSpline.has_value()) { from = InterpolateActiveSpline(*runtime.activeSpline, now, true); map->UpdateObjectPosition(creature->GetGuid(), from); @@ -372,10 +386,6 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, application::combat::CreatureChaseConfig config{}; config.stopDistanceYards = stopDistanceYards; - auto const collisionQueries = WorldService::Instance().GetCollisionQueries(); - bool const useNavMesh = - collisionQueries && collisionQueries->IsNavMeshDataAvailable(map->GetMapId()); - application::combat::CreatureChaseStepResult projected; if (useNavMesh && !returnHomeSpline) { projected = application::combat::StepCreatureAlongNavMeshPath( @@ -386,6 +396,14 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, from, targetX, targetY, targetZ, kCreatureSplineHorizonSeconds, config); } + // Belt-and-suspenders: even when the step skipped Z motion (stop-range case + // or 2D-only waypoint), keep the broadcast position on the navmesh floor. + if (collisionQueries && !returnHomeSpline) { + projected.position.z = collisionQueries->GetHeight( + map->GetMapId(), projected.position.x, projected.position.y, + projected.position.z); + } + if (!returnHomeSpline) { float const distToPlayerSq = DistanceSquared2d(from.x, from.y, targetX, targetY); diff --git a/tests/unit/combat/CreatureChaseMovementTest.cpp b/tests/unit/combat/CreatureChaseMovementTest.cpp index e3355c0..dd9acf3 100644 --- a/tests/unit/combat/CreatureChaseMovementTest.cpp +++ b/tests/unit/combat/CreatureChaseMovementTest.cpp @@ -16,6 +16,11 @@ Firelands::MovementInfo MakePos(float x, float y, float z) { class MockCollisionQueries : public Firelands::IMapCollisionQueries { public: bool IsNavMeshDataAvailable(uint32_t) const override { return navMeshAvailable; } + uint32_t GetLoadedMapCount() const override { return navMeshAvailable ? 1u : 0u; } + uint32_t GetLoadedTileCount() const override { return navMeshAvailable ? 1u : 0u; } + std::vector> GetLoadedTiles(uint32_t) const override { + return {}; + } bool LineOfSight(uint32_t, float, float, float, float, float, float) const override { return true; } diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index bb97fb4..9fe809b 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -20,7 +20,6 @@ namespace Firelands { namespace { constexpr float kTileSize = 533.33333f; -constexpr float kMapOrigin = -17066.66656f; constexpr uint32_t kMapMagic = 0x5350414Du; // 'MAPS' constexpr uint32_t kMapHeightMagic = 0x54474D48u; // 'MHGT' constexpr uint32_t kMapHeightNoHeight = 0x0001; @@ -29,12 +28,18 @@ constexpr uint32_t kMapHeightAsInt8 = 0x0004; constexpr int kTerrainGridSize = 128; constexpr int kTerrainVertexCount = kTerrainGridSize + 1; -float TileOriginX(uint32_t tileX) { - return kMapOrigin + static_cast(tileX) * kTileSize; +// WoW ADT convention (TrinityCore-compatible): +// - WoW X axis points NORTH; row index (tileY in the .map filename) decreases +// as X grows. Min WoW X for ADT row R is (31 - R) * TILE. +// - WoW Y axis points WEST; column index (tileX in the .map filename) +// decreases as Y grows. Min WoW Y for ADT col C is (31 - C) * TILE. +// - V9[0][0] is the NW corner: max WoW X, max WoW Y. +float TileMinWowX(uint32_t tileY) { + return (31.0f - static_cast(tileY)) * kTileSize; } -float TileOriginY(uint32_t tileY) { - return kMapOrigin + static_cast(tileY) * kTileSize; +float TileMinWowY(uint32_t tileX) { + return (31.0f - static_cast(tileX)) * kTileSize; } std::filesystem::path MapTilePath(std::string const& mapsDir, uint32_t mapId, @@ -148,8 +153,8 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, out.width = kTerrainVertexCount; out.height = kTerrainVertexCount; out.cellSize = kTileSize / static_cast(kTerrainGridSize); - out.minX = TileOriginX(tileX); - out.minY = TileOriginY(tileY); + out.minX = TileMinWowX(tileY); + out.minY = TileMinWowY(tileX); out.minZ = gridHeight; out.maxZ = gridMaxHeight; out.heights.resize(kTerrainVertexCount * kTerrainVertexCount); @@ -252,13 +257,19 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, int const walkableClimb = std::max(1, static_cast(_config.agentMaxClimb / cellHeight)); int const walkableHeight = std::max(1, static_cast(_config.agentHeight / cellHeight)); + // V9 layout in the .map: cell (cy, cx) holds the height at WoW position + // (maxWowX - cy*cellSize, maxWowY - cx*cellSize). + // To produce vertices in increasing detour X/Z we mirror both indices when + // sampling the heightmap, so vertex (y, x) lands at (minX + y*cs, minY + x*cs). std::vector verts; verts.reserve(static_cast(terrain.width * terrain.height * 3)); for (int y = 0; y < terrain.height; ++y) { + int const cy = kTerrainGridSize - y; for (int x = 0; x < terrain.width; ++x) { - float const wx = terrain.minX + static_cast(x) * terrain.cellSize; - float const wy = terrain.minY + static_cast(y) * terrain.cellSize; - float const wz = terrain.heights[static_cast(y * terrain.width + x)]; + int const cx = kTerrainGridSize - x; + float const wx = terrain.minX + static_cast(y) * terrain.cellSize; + float const wy = terrain.minY + static_cast(x) * terrain.cellSize; + float const wz = terrain.heights[static_cast(cy * terrain.width + cx)]; verts.push_back(wx); verts.push_back(wz); verts.push_back(wy); @@ -273,8 +284,9 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, int const b = y * terrain.width + x + 1; int const c = (y + 1) * terrain.width + x; int const d = (y + 1) * terrain.width + x + 1; - tris.push_back(a); tris.push_back(c); tris.push_back(b); - tris.push_back(b); tris.push_back(c); tris.push_back(d); + // CCW from above so triangle normals point up (+Y) — required by Recast. + tris.push_back(a); tris.push_back(b); tris.push_back(c); + tris.push_back(b); tris.push_back(d); tris.push_back(c); } } From d166c0935fe6e83e76a7eef1f4c48fb52d36696b Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 19:15:17 +0000 Subject: [PATCH 20/35] chore(mmap): add chase-Z debug logs to trace airborne creature follow Adds four MMAP_DEBUG breadcrumbs in TryBroadcastCreatureSplineStep and TryFinalizeCreatureChaseStand: - ground-project target: raw vs. grounded targetZ, plus from. - ground-clamp step: pre/post clamp on projected.position.z. - broadcast: from/to that the client actually receives. - finalize stand: from, raw target, computed stand position. Lets us see exactly which tick (and which value) is pulling the creature up when the player is airborne. --- .../worldsession/WorldSessionCombat.cpp | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 105037f..92ba53f 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -317,6 +317,13 @@ bool TryFinalizeCreatureChaseStand(std::shared_ptr const &map, float const standDistSq = DistanceSquared2d(from.x, from.y, stand.x, stand.y); + LOG_MMAP_DEBUG( + "CHASE finalize stand: mapId={} creatureGuid={} from=({}, {}, {}) " + "target=({}, {}, {}) stand=({}, {}, {}) standDist2d={}", + map ? map->GetMapId() : 0u, creature ? creature->GetGuid() : 0ULL, from.x, + from.y, from.z, targetX, targetY, targetZ, stand.x, stand.y, stand.z, + std::sqrt(standDistSq)); + if (IsCreatureSplineInFlight(runtime, now, true)) return false; @@ -365,9 +372,15 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // stored grounded Z and the new grounded Z compare consistently across // ticks (otherwise an airborne player would trigger a replan every tick). // Home splines already track a grounded point. + float const targetZRaw = targetZ; if (collisionQueries && !returnHomeSpline) { targetZ = collisionQueries->GetHeight(map->GetMapId(), targetX, targetY, from.z); + LOG_MMAP_DEBUG( + "CHASE ground-project target: mapId={} creatureGuid={} from=({}, {}, {}) " + "targetXY=({}, {}) targetZ_raw={} targetZ_grounded={} delta={}", + map->GetMapId(), creature->GetGuid(), from.x, from.y, from.z, targetX, + targetY, targetZRaw, targetZ, targetZ - targetZRaw); } bool const chaseTargetMoved = @@ -399,9 +412,17 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // Belt-and-suspenders: even when the step skipped Z motion (stop-range case // or 2D-only waypoint), keep the broadcast position on the navmesh floor. if (collisionQueries && !returnHomeSpline) { + float const preClampZ = projected.position.z; projected.position.z = collisionQueries->GetHeight( map->GetMapId(), projected.position.x, projected.position.y, projected.position.z); + LOG_MMAP_DEBUG( + "CHASE ground-clamp step: mapId={} creatureGuid={} step=({}, {}, {}) " + "preClampZ={} clampedZ={} delta={} moved={} inStopRange={}", + map->GetMapId(), creature->GetGuid(), projected.position.x, + projected.position.y, projected.position.z, preClampZ, + projected.position.z, projected.position.z - preClampZ, projected.moved, + projected.inStopRange); } if (!returnHomeSpline) { @@ -436,6 +457,11 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, int32_t const splineId = static_cast(++moveCounter); uint32_t const durationMs = monster_move_wire::MonsterMoveDurationMs( from.x, from.y, from.z, to.x, to.y, to.z, kCreatureRunSpeedYardsPerSec); + LOG_MMAP_DEBUG( + "CHASE broadcast: mapId={} creatureGuid={} returnHome={} from=({}, {}, {}) " + "to=({}, {}, {}) duration={}ms splineId={}", + map->GetMapId(), creature->GetGuid(), returnHomeSpline, from.x, from.y, + from.z, to.x, to.y, to.z, durationMs, splineId); if (returnHomeSpline) BroadcastCreatureReturnMove(map, creature->GetGuid(), from, to, splineId); else From 7589f45e91bc39609307d65e150a3e84c9877ac6 Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 19:22:43 +0000 Subject: [PATCH 21/35] fix(mmap): pick the floor under (x,y), not the 3D-nearest poly GetNavMeshHeight used findNearestPoly with a 100-yard horizontal and 1000-yard vertical extent. With a high zHint (creature drifting after chasing a flying player), the 3D-nearest poly was a nearby cliff peak, not the floor below. The chase clamp then snapped the creature to the peak, the next tick re-projected with the new (higher) zHint, and the creature climbed the cliff every tick until it sat on top. Switch to queryPolygons with a tight horizontal half-extent (1.5y) so only polys whose XY footprint covers the query column are considered, then read each candidate's true surface height with getPolyHeight. We pick the highest floor at or below the query Y (with 2y of slop). If nothing sits below, fall back to the lowest poly above so the caller still gets a sensible value. --- .../collision/DetourNavMeshManager.cpp | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index d255772..dd4c742 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -278,24 +279,64 @@ bool DetourNavMeshManager::GetNavMeshHeight(uint32_t mapId, float x, float y, float queryPos[3]{}; WowToDetour(x, y, zHint, queryPos); - // Vertical extent is intentionally large so a flying caller still finds the - // ground polygon underneath them. - float const extents[3] = {_config.maxSearchRadius, 1000.0f, - _config.maxSearchRadius}; + // We want the floor *below the same XY*, not the closest poly in 3D space. + // findNearestPoly would happily pick a nearby cliff peak instead of the + // ground straight under the query. Enumerate polys whose XY footprint + // covers the query column (tight horizontal extent), then evaluate each + // poly's actual surface height at (x, y). dtQueryFilter filter; filter.setIncludeFlags(0xFFFF); filter.setExcludeFlags(0); - dtPolyRef ref = 0; - float nearest[3]{}; - dtStatus status = it->second.navQuery->findNearestPoly(queryPos, extents, - &filter, &ref, nearest); - if (dtStatusFailed(status) || ref == 0) + constexpr int kMaxCandidates = 32; + // Slightly relaxed horizontal so we still find a poly when the query lands + // exactly on a tile/poly boundary. + float const halfExtents[3] = {1.5f, 1000.0f, 1.5f}; + dtPolyRef polys[kMaxCandidates]; + int polyCount = 0; + dtStatus status = it->second.navQuery->queryPolygons(queryPos, halfExtents, + &filter, polys, + &polyCount, + kMaxCandidates); + if (dtStatusFailed(status) || polyCount == 0) return false; - outZ = nearest[1]; - return true; + // Slop accounts for floating point error and small ledges; anything above + // the query Y by more than this is treated as a ceiling we don't want. + constexpr float kAboveSlop = 2.0f; + + float bestBelow = -std::numeric_limits::infinity(); + float bestAbove = std::numeric_limits::infinity(); + bool hasBelow = false; + bool hasAbove = false; + + for (int i = 0; i < polyCount; ++i) { + float h = 0.0f; + if (dtStatusFailed( + it->second.navQuery->getPolyHeight(polys[i], queryPos, &h))) + continue; + + if (h <= queryPos[1] + kAboveSlop) { + if (!hasBelow || h > bestBelow) { + bestBelow = h; + hasBelow = true; + } + } else if (!hasAbove || h < bestAbove) { + bestAbove = h; + hasAbove = true; + } + } + + if (hasBelow) { + outZ = bestBelow; + return true; + } + if (hasAbove) { + outZ = bestAbove; + return true; + } + return false; } void DetourNavMeshManager::RemoveDuplicateWaypoints( From ec8e2aa1922f97bf82d5eb6ead968df2f7877d71 Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 19:38:20 +0000 Subject: [PATCH 22/35] fix(chase): reject navmesh ground when it would teleport creature vertically Logs from a Razor Hill test showed targetZ_grounded jumping to ~85y on a flat area where the real terrain is ~15y. The .mmtile data is genuinely corrupted (no ground poly at the creature's actual XY, only a ghost poly ~70y above). The clamp then reinforced the bad value tick after tick. Add two guards inside TryBroadcastCreatureSplineStep: - target projection: if navmesh ground is more than 15y from creature's current Z, ignore it and keep targetZ at from.z. - post-step clamp: if navmesh ground is more than 5y from from.z, keep the creature at from.z instead of snapping onto the bad poly. Each rejection logs a WARN so the bad .mmtile coordinates are visible during regen/debug. This is a safety net while the underlying terrain data is being fixed -- once the .map extraction produces correct V9s and mmaps are regenerated, these guards should never trip. --- .../worldsession/WorldSessionCombat.cpp | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 92ba53f..f57aaac 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -374,8 +374,25 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // Home splines already track a grounded point. float const targetZRaw = targetZ; if (collisionQueries && !returnHomeSpline) { - targetZ = collisionQueries->GetHeight(map->GetMapId(), targetX, targetY, - from.z); + float const projected = collisionQueries->GetHeight(map->GetMapId(), + targetX, targetY, + from.z); + // If the navmesh ground is wildly different from the creature's current Z + // (>15y), the .mmtile data is likely corrupted at this column. Fall back + // to from.z so the creature keeps its current footing instead of being + // teleported onto a ghost poly. + constexpr float kMaxProjectionDriftYards = 15.0f; + if (std::fabs(projected - from.z) > kMaxProjectionDriftYards) { + LOG_MMAP_WARN( + "CHASE rejecting navmesh target projection: mapId={} creatureGuid={} " + "targetXY=({}, {}) navmeshZ={} fromZ={} delta={} -- using fromZ as " + "fallback (likely corrupted .mmtile)", + map->GetMapId(), creature->GetGuid(), targetX, targetY, projected, + from.z, projected - from.z); + targetZ = from.z; + } else { + targetZ = projected; + } LOG_MMAP_DEBUG( "CHASE ground-project target: mapId={} creatureGuid={} from=({}, {}, {}) " "targetXY=({}, {}) targetZ_raw={} targetZ_grounded={} delta={}", @@ -413,9 +430,26 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // or 2D-only waypoint), keep the broadcast position on the navmesh floor. if (collisionQueries && !returnHomeSpline) { float const preClampZ = projected.position.z; - projected.position.z = collisionQueries->GetHeight( + float groundZ = collisionQueries->GetHeight( map->GetMapId(), projected.position.x, projected.position.y, projected.position.z); + // Safety net for corrupted navmesh tiles: if the resolved ground would + // teleport the creature more than 5 yards vertically in a single tick, + // ignore the navmesh value and keep the creature anchored at its current + // Z. WoW terrain rarely changes more than 5y per step horizontally, so + // this only kicks in when the .mmtile data is wrong. + constexpr float kMaxVerticalStepYards = 5.0f; + if (std::fabs(groundZ - from.z) > kMaxVerticalStepYards) { + LOG_MMAP_WARN( + "CHASE rejecting navmesh ground jump: mapId={} creatureGuid={} " + "step=({}, {}, {}) navmeshZ={} fromZ={} delta={} -- using fromZ as " + "fallback (likely corrupted .mmtile)", + map->GetMapId(), creature->GetGuid(), projected.position.x, + projected.position.y, projected.position.z, groundZ, from.z, + groundZ - from.z); + groundZ = from.z; + } + projected.position.z = groundZ; LOG_MMAP_DEBUG( "CHASE ground-clamp step: mapId={} creatureGuid={} step=({}, {}, {}) " "preClampZ={} clampedZ={} delta={} moved={} inStopRange={}", From 1656be9c16d5970292286554fc3bb61c0ea469f0 Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 20:03:50 +0000 Subject: [PATCH 23/35] fix(char-enum): resolve displayId from DBC when item_template has a zero row SMSG_CHAR_ENUM showed characters naked even when they were clothed once in-world. Root cause: FetchItemProto returned immediately on any item_template hit, including rows where displayid was 0. The char enum ships the resolved displayId straight to the client, so a zero means the slot renders empty; the world stays clothed because PLAYER_VISIBLE_ ITEM_*_ENTRYID carries the raw entry and the client resolves visuals from ItemSparse.db2 locally. Treat a SQL row with displayId == 0 as incomplete and fall through to the ItemDb2 / CharStartOutfit DBC lookup, then keep the SQL row's inventoryType / buyCount but override displayId from the fallback. If both fallbacks also fail, return the (incomplete) SQL row so existing behavior is preserved for genuinely zero-display items. --- .../persistence/MySqlCharacterRepository.cpp | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/persistence/MySqlCharacterRepository.cpp b/src/infrastructure/persistence/MySqlCharacterRepository.cpp index 5341ed6..c2db541 100644 --- a/src/infrastructure/persistence/MySqlCharacterRepository.cpp +++ b/src/infrastructure/persistence/MySqlCharacterRepository.cpp @@ -447,6 +447,7 @@ std::optional FetchItemProto( std::shared_ptr conn, uint32_t itemEntry, CharStartOutfitDbc const *charStartOutfitDbc, ItemDb2Wdb2 const *itemDb2) { + std::optional sqlRow; try { std::shared_ptr ps(conn->prepareStatement( "SELECT InventoryType AS ity, BuyCount AS bct, displayid AS did " @@ -459,7 +460,17 @@ std::optional FetchItemProto( row.buyCount = std::max(1u, static_cast(rs->getInt("bct"))); row.displayId = rs->getUInt("did"); - return row; + // Only return immediately if displayId is usable. A row with + // displayId == 0 means item_template is incomplete for this entry + // (common with custom DBs that didn't import displayid columns); in + // that case fall through to the DBC/DB2 lookup and merge the visual + // from there. The world stays clothed because the client resolves + // visuals from its own ItemSparse.db2 using the item entry, but + // SMSG_CHAR_ENUM sends the resolved displayId straight to the screen, + // so a zero here shows the character naked on the select screen. + if (row.displayId != 0) + return row; + sqlRow = row; } } catch (sql::SQLException const &e) { LOG_WARN("FetchItemProto failed for entry {}: {}", itemEntry, e.what()); @@ -467,8 +478,9 @@ std::optional FetchItemProto( if (itemDb2 && itemDb2->IsLoaded()) { if (auto client = itemDb2->Lookup(itemEntry)) { ItemProtoRow row; - row.inventoryType = client->inventoryType; - row.buyCount = 1u; + row.inventoryType = + sqlRow ? sqlRow->inventoryType : client->inventoryType; + row.buyCount = sqlRow ? sqlRow->buyCount : 1u; row.displayId = client->displayId; return row; } @@ -476,13 +488,13 @@ std::optional FetchItemProto( if (charStartOutfitDbc) { if (auto visual = charStartOutfitDbc->GetItemVisualByEntry(itemEntry)) { ItemProtoRow row; - row.inventoryType = visual->invType; - row.buyCount = 1; + row.inventoryType = sqlRow ? sqlRow->inventoryType : visual->invType; + row.buyCount = sqlRow ? sqlRow->buyCount : 1u; row.displayId = visual->displayId; return row; } } - return std::nullopt; + return sqlRow; } std::optional PrimaryEquipSlotForInventoryType(uint8_t inventoryType) { From f51a82d19d04b3a1e6c202a89eb0b6f54a426664 Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 20:08:13 +0000 Subject: [PATCH 24/35] fix(chase): skip navmesh waypoints that lead away from the target After fixing the char-enum and ground-clamp, the user reported the NPC walking correctly on the ground but heading *away* from the player on the first step. Cause: Detour's findStraightPath returns a start projection on the nearest poly, which on corrupted/ghost mmtiles can land yards away from the creature's actual XY. The chase then takes its first step toward that ghost point and never reaches the player. Add a guard inside StepCreatureAlongNavMeshPath: a waypoint is skipped when the vector from current to wp points away from the target (dot < 0) *and* the wp is farther from the target than current. That catches the ghost-projection case without breaking legitimate detours (those have a positive component toward the target). Falls through to the next waypoint, ultimately reaching the appended end-target waypoint. --- .../combat/CreatureChaseMovement.cpp | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/application/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index 76aa387..e907953 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -201,6 +201,10 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( deltaSeconds, config); } + float const directDx = targetX - current.x; + float const directDy = targetY - current.y; + float const directDistSq = directDx * directDx + directDy * directDy; + while (state.currentWaypoint < state.waypoints.size()) { Vec3 const &wp = state.waypoints[state.currentWaypoint]; float const dx = current.x - wp.x; @@ -214,6 +218,28 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( continue; } + // Skip a waypoint that goes the wrong way. Detour's findStraightPath can + // emit a start-projection point far from the actual creature when + // findNearestPoly picks a stale/corrupted poly; following it sends the NPC + // *away* from the player. "Wrong way" = the vector from current to wp + // points away from the target, AND wp is farther from the target than + // current already is. + float const wpToTargetX = targetX - wp.x; + float const wpToTargetY = targetY - wp.y; + float const wpToTargetDistSq = + wpToTargetX * wpToTargetX + wpToTargetY * wpToTargetY; + float const wpFromCurrentDot = + (wp.x - current.x) * directDx + (wp.y - current.y) * directDy; + if (wpFromCurrentDot < 0.f && wpToTargetDistSq > directDistSq) { + LOG_DEBUG( + "CHASE skipping wrong-way waypoint: mapId={} waypointIndex={} " + "wp=({}, {}, {}) current=({}, {}, {}) target=({}, {})", + mapId, state.currentWaypoint, wp.x, wp.y, wp.z, current.x, current.y, + current.z, targetX, targetY); + ++state.currentWaypoint; + continue; + } + auto step = StepCreatureTowardTarget(current, wp.x, wp.y, wp.z, deltaSeconds, config); return step; } From f9347b0bf4013ba71c0a049e0514379426bdd409 Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sat, 30 May 2026 20:14:58 +0000 Subject: [PATCH 25/35] fix(trinitycore): remove TrinityCore submodule reference --- TrinityCore | 1 - 1 file changed, 1 deletion(-) delete mode 160000 TrinityCore diff --git a/TrinityCore b/TrinityCore deleted file mode 160000 index 6fc1f7f..0000000 --- a/TrinityCore +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6fc1f7f41236cbbb43680a07ff73b2e24148694c From 6eaedabeab04d9ef725207a923e1264d10c568db Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sun, 31 May 2026 07:17:07 +0000 Subject: [PATCH 26/35] fix(mmap-command): make .mmap path markers visible above the floor Reports came in that .mmap path showed no visual markers even after the path resolved with status=Complete. Three things were stacking up: - The marker spawn log was MMAP_TRACE and got filtered, so there was no way to confirm where the markers actually landed. - The visual lift was wp.z + 1.0y, which puts the marker inside any player or creature model (humans are ~2y tall). When the path collapses to a single waypoint (start ~= end), the lone marker spawned inside the player and looked like 'nothing rendered'. - The waypoint Z came straight from Detour's corridor. On tiles with the ghost-poly corruption we already guard against in the chase loop, that Z is tens of yards above the real ground, so the marker spawned in mid-air far above the camera frame. Re-ground each waypoint through GetHeight() before placing, lift the marker 3y above that, promote the spawn line to MMAP_DEBUG, and emit a client notification when the result collapsed to zero markers so a trivial path is obviously distinct from a missing marker. --- src/application/services/CommandService.cpp | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 8981b1a..0ecd6e0 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -1021,27 +1021,44 @@ bool CommandService::HandleMmap(std::shared_ptr session, ClearMmapMarkers(session, playerGuid, mapId); if (!result.waypoints.empty()) { + // Tauren female-ish display: tall, neutral, easy to spot from camera. constexpr uint32_t kMarkerDisplayId = 11686u; constexpr uint32_t kMarkerEntry = 1u; + // Float the marker well above a player model so it's visible even when + // start ≈ end and the waypoint lands inside the caster or the target. + constexpr float kMarkerVisualLiftYards = 3.0f; auto const now = std::chrono::steady_clock::now(); std::vector> markers; markers.reserve(result.waypoints.size()); for (size_t i = 0; i < result.waypoints.size(); ++i) { auto const &wp = result.waypoints[i]; - float const z = wp.z + 1.0f; + // Re-ground each waypoint Z before placing the marker. Detour's + // findStraightPath returns Z from the navmesh corridor, and on + // corrupted/ghost mmtiles that Z can be tens of yards above the real + // floor. Anchoring to the live GetHeight (which goes through the + // floor-under-(x,y) query) keeps markers visible on the ground. + float const groundZ = + collision->GetHeight(mapId, wp.x, wp.y, wp.z); + float const z = groundZ + kMarkerVisualLiftYards; uint64_t const markerGuid = AllocateMmapMarkerGuid(kMarkerEntry); SendMmapMarkerCreate(session, mapId, markerGuid, Vec3{wp.x, wp.y, z}, kMarkerEntry, kMarkerDisplayId, Creature::kDefaultFactionTemplate); - LOG_MMAP_TRACE("MMAP marker spawn: mapId={} guid={} wpIndex={} pos=({}, {}, {})", - mapId, markerGuid, i, wp.x, wp.y, z); + LOG_MMAP_DEBUG( + "MMAP marker spawn: mapId={} guid={} wpIndex={} wp=({}, {}, {}) " + "groundZ={} placedZ={}", + mapId, markerGuid, i, wp.x, wp.y, wp.z, groundZ, z); markers.emplace_back(markerGuid, now); } _mmapMarkers[playerGuid] = std::move(markers); session->SendNotification("MMAP: " + std::to_string(result.waypoints.size()) + " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); + } else { + session->SendNotification( + "MMAP: no waypoints to mark (start and end resolved to the same " + "navmesh point)."); } return true; } From 33b2eba6dc68f2439cdad2c6356afadf66d621ba Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sun, 31 May 2026 07:21:06 +0000 Subject: [PATCH 27/35] fix(chase): stabilize path so the NPC stops oscillating toward the player Reported behaviour: the creature walks toward the player, turns around, replans, then walks back. The cause was the relocation threshold of 0.5y: any normal player movement triggered a full replan, state.waypoints was cleared, and currentWaypoint reset to 0 every tick. On tiles with ghost polys, findNearestPoly alternated between two bad start projections each tick, so the corridor's wp[0] flipped direction and the NPC oscillated. Two changes: - Raise the replan threshold to 3y. The path now commits across several ticks of walking, so a transient ghost projection does not flip the corridor mid-tick. - Short-range bypass: when the target is within 8y 2D, skip the navmesh corridor entirely and chase in a straight line. This is the case least likely to need a real detour and most likely to be hurt by a ghost start projection. Clears any stale waypoints so the state stays consistent. --- .../combat/CreatureChaseMovement.cpp | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/application/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index e907953..0feb419 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -166,9 +166,15 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( ChaseNavMeshState &state, Firelands::IMapCollisionQueries const *collision, uint32_t mapId) { + // 3y threshold so the corridor persists across ticks while the player is + // walking. With the default 0.5y the path tore down and rebuilt every + // single tick, which on tiles with ghost polys made findNearestPoly + // alternate between bad start projections and the NPC oscillated. + constexpr float kChaseReplanThresholdYards = 3.0f; bool const targetRelocated = ChaseTargetRelocated(state.lastTargetX, state.lastTargetY, - state.lastTargetZ, targetX, targetY, targetZ); + state.lastTargetZ, targetX, targetY, targetZ, + kChaseReplanThresholdYards); if (targetRelocated || state.waypoints.empty()) { state.lastTargetX = targetX; @@ -178,6 +184,23 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( state.waypoints.clear(); } + // Short-range bypass: when the target is well within engagement distance, + // skip the navmesh corridor entirely. It rarely adds value and is the case + // most vulnerable to ghost-poly start projections, which is what makes the + // NPC walk toward the player, turn around, replan, and come back. + constexpr float kDirectChaseRangeYards = 8.0f; + float const directDxFast = targetX - current.x; + float const directDyFast = targetY - current.y; + float const directDistSqFast = + directDxFast * directDxFast + directDyFast * directDyFast; + if (directDistSqFast <= + kDirectChaseRangeYards * kDirectChaseRangeYards) { + state.waypoints.clear(); + state.currentWaypoint = 0; + return StepCreatureTowardTarget(current, targetX, targetY, targetZ, + deltaSeconds, config); + } + if (targetRelocated && collision) { state.waypoints = ComputeNavMeshPath(mapId, current, targetX, targetY, targetZ, collision); if (!state.waypoints.empty()) { From c06c3c548d95968c7dd5ca372a21c9e8470d2710 Mon Sep 17 00:00:00 2001 From: Kevin Alvear Date: Sun, 31 May 2026 07:26:43 +0000 Subject: [PATCH 28/35] fix(mmap): silence tile-missing noise for tiles that have no .map source The startup log shipped ~3000 "MMAP tile missing" lines because the manager probes all 64x64 tile slots on every map load. The vast majority of those slots are ocean/void in WoW: a continent has on the order of 900 valid ADTs, not 4096. The real generator failures got buried under the noise. Now the manager checks first whether maps/.map exists: - If the .map is absent, the tile is silently skipped (genuinely off-continent, never expected to have an mmtile). - If the .map is present but the .mmtile is not, log MMAP_WARN pointing at the missing file. That is a real generator regression. - After the load loop, emit one MMAP_INFO summary: loaded vs expected vs missing, so the operator can see at a glance whether a regeneration succeeded fully or partially. --- .../collision/DetourNavMeshManager.cpp | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index dd4c742..66f5996 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -74,6 +74,24 @@ DetourNavMeshManager::~DetourNavMeshManager() { _loadedMaps.clear(); } +namespace { +// Path to the extractor's .map file for (mapId, tileX, tileY). Uses the WoW +// ADT naming convention: <3-digit mapId><2-digit tileY><2-digit tileX>.map. +std::filesystem::path MapFilePath(std::string const& dataRoot, uint32_t mapId, + uint32_t tileX, uint32_t tileY) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%03u%02u%02u.map", mapId, tileY, tileX); + return std::filesystem::path(dataRoot) / "maps" / buf; +} + +bool MapTileExistsOnDisk(std::string const& dataRoot, uint32_t mapId, + uint32_t tileX, uint32_t tileY) { + std::error_code ec; + return std::filesystem::exists( + MapFilePath(dataRoot, mapId, tileX, tileY), ec); +} +} // namespace + bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, uint32_t tileY, dtNavMesh* navMesh) const { @@ -86,8 +104,15 @@ bool DetourNavMeshManager::ReadMmapTile(uint32_t mapId, uint32_t tileX, FILE* file = fopen(fileName.c_str(), "rb"); if (!file) { - LOG_MMAP_DEBUG("MMAP tile missing: mapId={} tileX={} tileY={} path={}", mapId, - tileX, tileY, fileName); + // Only complain when the source .map exists. If there's no .map either, + // this tile is genuinely off-continent (ocean / void) and the generator + // was never expected to produce an .mmtile for it. + if (MapTileExistsOnDisk(_dataRoot, mapId, tileX, tileY)) { + LOG_MMAP_WARN( + "MMAP tile missing but .map present (generator likely failed for " + "this tile): mapId={} tileX={} tileY={} path={}", + mapId, tileX, tileY, fileName); + } return false; } @@ -196,10 +221,14 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { } bool anyTileLoaded = false; + uint32_t expectedTiles = 0; MapNavMesh loadedEntry; loadedEntry.navMesh = navMesh; for (uint32_t ty = 0; ty < kTileCountPerAxis; ++ty) { for (uint32_t tx = 0; tx < kTileCountPerAxis; ++tx) { + bool const hasMap = MapTileExistsOnDisk(_dataRoot, mapId, tx, ty); + if (hasMap) + ++expectedTiles; if (ReadMmapTile(mapId, tx, ty, navMesh)) { anyTileLoaded = true; loadedEntry.loadedTiles.emplace_back(tx, ty); @@ -207,6 +236,14 @@ bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { } } + LOG_MMAP_INFO( + "MMAP navmesh tile load summary: mapId={} loaded={} expected(.map)={} " + "missing={}", + mapId, loadedEntry.loadedTiles.size(), expectedTiles, + expectedTiles > loadedEntry.loadedTiles.size() + ? expectedTiles - static_cast(loadedEntry.loadedTiles.size()) + : 0u); + if (!anyTileLoaded) { LOG_MMAP_WARN("MMAP navmesh load skipped: no tiles found for mapId={}", mapId); dtFreeNavMesh(navMesh); From 92b360625dc04760c53010c73d2babbbcfe4405c Mon Sep 17 00:00:00 2001 From: HkevinH Date: Mon, 1 Jun 2026 17:59:34 -0500 Subject: [PATCH 29/35] feat(navigation): enhance ground snapping and collision handling in creature movement --- .../combat/CreatureChaseMovement.cpp | 29 ++++- src/application/services/CommandService.cpp | 12 +- src/application/services/CommandService.h | 2 + src/application/services/WorldService.cpp | 12 ++ src/application/services/WorldService.h | 6 + .../collision/DetourNavMeshManager.cpp | 120 +++++++++++++++--- .../worldsession/WorldSessionCombat.cpp | 32 +++-- src/world/WorldApplication.cpp | 1 + tools/vmap/mmap_generator/MmapGenerator.cpp | 106 +++++++++++----- 9 files changed, 245 insertions(+), 75 deletions(-) diff --git a/src/application/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index 0feb419..e7a9c42 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -166,6 +166,21 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( ChaseNavMeshState &state, Firelands::IMapCollisionQueries const *collision, uint32_t mapId) { + // Set the Z value of the result to the navmesh floor on every tick. Blending with + // a delay (zBlendPerTick) toward the waypoint’s Z value causes the creature to + // float on slopes (when climbing slowly) or sink into/pass through the slope (the Z value doesn’t rise + // as fast as the terrain). Solving the Z value against the actual ground eliminates both + // problems, and the creature always stays attached to the terrain. + auto snapToGround = [&](CreatureChaseStepResult res) { + if (collision && res.moved) { + float const ground = collision->GetHeight(mapId, res.position.x, + res.position.y, res.position.z); + if (std::isfinite(ground)) + res.position.z = ground; + } + return res; + }; + // 3y threshold so the corridor persists across ticks while the player is // walking. With the default 0.5y the path tore down and rebuilt every // single tick, which on tiles with ghost polys made findNearestPoly @@ -197,8 +212,8 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( kDirectChaseRangeYards * kDirectChaseRangeYards) { state.waypoints.clear(); state.currentWaypoint = 0; - return StepCreatureTowardTarget(current, targetX, targetY, targetZ, - deltaSeconds, config); + return snapToGround(StepCreatureTowardTarget(current, targetX, targetY, + targetZ, deltaSeconds, config)); } if (targetRelocated && collision) { @@ -220,8 +235,8 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( LOG_DEBUG("CHASE fallback without collision: mapId={} current=({}, {}, {}) target=({}, {}, {})", mapId, current.x, current.y, current.z, targetX, targetY, targetZ); } - return StepCreatureTowardTarget(current, targetX, targetY, targetZ, - deltaSeconds, config); + return snapToGround(StepCreatureTowardTarget(current, targetX, targetY, + targetZ, deltaSeconds, config)); } float const directDx = targetX - current.x; @@ -264,11 +279,11 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( } auto step = StepCreatureTowardTarget(current, wp.x, wp.y, wp.z, deltaSeconds, config); - return step; + return snapToGround(step); } - return StepCreatureTowardTarget(current, targetX, targetY, targetZ, - deltaSeconds, config); + return snapToGround(StepCreatureTowardTarget(current, targetX, targetY, + targetZ, deltaSeconds, config)); } } // namespace application::combat diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 371cedf..023ce07 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -1027,9 +1029,15 @@ bool CommandService::HandleMmap(std::shared_ptr session, ClearMmapMarkers(session, playerGuid, mapId); if (!result.waypoints.empty()) { - // Tauren female-ish display: tall, neutral, easy to spot from camera. - constexpr uint32_t kMarkerDisplayId = 11686u; constexpr uint32_t kMarkerEntry = 1u; + uint32_t kMarkerDisplayId = 15688u; + if (auto const repo = WorldService::Instance().GetNpcTemplateSearch()) { + if (auto const tpl = repo->TryGetByEntry(kMarkerEntry)) { + kMarkerDisplayId = ResolveCreatureDisplayId( + 0, tpl->displayIds[0], tpl->displayIds[1], tpl->displayIds[2], + tpl->displayIds[3]); + } + } // Float the marker well above a player model so it's visible even when // start ≈ end and the waypoint lands inside the caster or the target. constexpr float kMarkerVisualLiftYards = 3.0f; diff --git a/src/application/services/CommandService.h b/src/application/services/CommandService.h index dd812c6..afd6fa5 100644 --- a/src/application/services/CommandService.h +++ b/src/application/services/CommandService.h @@ -63,6 +63,8 @@ class CommandService : public ICommandService { PermissionMask requiredPermissions = 0; CommandAvailability availability = CommandAvailability::Both; ConsoleArgLayout consoleLayout = ConsoleArgLayout::SameAsInGame; + std::string description; + std::string syntax; }; void RegisterCommand(const std::string &name, CommandEntry entry); diff --git a/src/application/services/WorldService.cpp b/src/application/services/WorldService.cpp index ae6077b..269e4bf 100644 --- a/src/application/services/WorldService.cpp +++ b/src/application/services/WorldService.cpp @@ -150,6 +150,18 @@ std::shared_ptr WorldService::GetAreaTableDbc() { return m_areaTableDbc; } +void WorldService::SetNpcTemplateSearch( + std::shared_ptr repo) { + std::lock_guard lock(m_auxMutex); + m_npcTemplateSearch = std::move(repo); +} + +std::shared_ptr +WorldService::GetNpcTemplateSearch() { + std::lock_guard lock(m_auxMutex); + return m_npcTemplateSearch; +} + void WorldService::SetExperienceRates(ExperienceRates rates) { std::lock_guard lock(m_auxMutex); m_experienceRates = rates; diff --git a/src/application/services/WorldService.h b/src/application/services/WorldService.h index 98b58ee..c61c7b5 100644 --- a/src/application/services/WorldService.h +++ b/src/application/services/WorldService.h @@ -24,6 +24,7 @@ class FactionTemplateDbc; class AreaTableDbc; class PhaseGroupCatalog; class PhaseAreaCatalog; +class INpcTemplateSearchRepository; /// Singleton world state (maps, shared script host, collision port). /// Populated from `world` executable after config load. @@ -74,6 +75,10 @@ class WorldService { void SetAreaTableDbc(std::shared_ptr areaTableDbc); std::shared_ptr GetAreaTableDbc(); + void SetNpcTemplateSearch( + std::shared_ptr repo); + std::shared_ptr GetNpcTemplateSearch(); + void SetExperienceRates(ExperienceRates rates); ExperienceRates GetExperienceRates(); /// Explicit teardown hook for process shutdown. Releases map-held objects @@ -104,6 +109,7 @@ class WorldService { std::shared_ptr m_phaseGroupCatalog; std::shared_ptr m_phaseAreaCatalog; std::shared_ptr m_areaTableDbc; + std::shared_ptr m_npcTemplateSearch; ExperienceRates m_experienceRates{}; }; diff --git a/src/infrastructure/collision/DetourNavMeshManager.cpp b/src/infrastructure/collision/DetourNavMeshManager.cpp index 66f5996..0b9778b 100644 --- a/src/infrastructure/collision/DetourNavMeshManager.cpp +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -56,6 +56,62 @@ Vec3 DetourToWow(float const* pos) { return Vec3{pos[0], pos[2], pos[1]}; } +bool FindGroundPoly(dtNavMeshQuery* navQuery, float const queryPos[3], + dtQueryFilter const& filter, float maxVerticalSearch, + dtPolyRef& outRef, float outNearest[3]) { + constexpr int kMaxCandidates = 64; + float const halfExtents[3] = {2.0f, maxVerticalSearch, 2.0f}; + dtPolyRef polys[kMaxCandidates]; + int polyCount = 0; + dtStatus status = navQuery->queryPolygons(queryPos, halfExtents, &filter, + polys, &polyCount, kMaxCandidates); + if (dtStatusFailed(status) || polyCount == 0) + return false; + + dtPolyRef bestRef = 0; + float bestNearest[3]{}; + float bestScore = std::numeric_limits::infinity(); + bool foundBelow = false; + + for (int i = 0; i < polyCount; ++i) { + float polyHeight = 0.0f; + if (dtStatusFailed(navQuery->getPolyHeight(polys[i], queryPos, &polyHeight))) + continue; + + float nearest[3]{}; + status = navQuery->closestPointOnPoly(polys[i], queryPos, nearest, nullptr); + if (dtStatusFailed(status)) + continue; + + float const horizontalDx = nearest[0] - queryPos[0]; + float const horizontalDz = nearest[2] - queryPos[2]; + float const horizontalScore = + horizontalDx * horizontalDx + horizontalDz * horizontalDz; + bool const below = polyHeight <= queryPos[1] + 2.0f; + float const verticalScore = std::abs(polyHeight - queryPos[1]); + float const score = horizontalScore * 25.0f + verticalScore; + + if ((below && !foundBelow) || + (below == foundBelow && score < bestScore)) { + bestRef = polys[i]; + bestNearest[0] = nearest[0]; + bestNearest[1] = polyHeight; + bestNearest[2] = nearest[2]; + bestScore = score; + foundBelow = below; + } + } + + if (bestRef == 0) + return false; + + outRef = bestRef; + outNearest[0] = bestNearest[0]; + outNearest[1] = bestNearest[1]; + outNearest[2] = bestNearest[2]; + return true; +} + } // namespace DetourNavMeshManager::DetourNavMeshManager(std::string dataRoot, @@ -75,12 +131,13 @@ DetourNavMeshManager::~DetourNavMeshManager() { } namespace { -// Path to the extractor's .map file for (mapId, tileX, tileY). Uses the WoW -// ADT naming convention: <3-digit mapId><2-digit tileY><2-digit tileX>.map. +// Path to the extractor's .map file for (mapId, tileX, tileY). El extractor nombra +// {map}{gx}{gy} con gx = 32 - worldX/533 = 63 - tileX y gy = 32 - worldY/533 = 63 - tileY +// (mismo mapeo que usa el generador en MmapGenerator::MapTilePath). Ambos ejes espejados. std::filesystem::path MapFilePath(std::string const& dataRoot, uint32_t mapId, uint32_t tileX, uint32_t tileY) { char buf[32]; - std::snprintf(buf, sizeof(buf), "%03u%02u%02u.map", mapId, tileY, tileX); + std::snprintf(buf, sizeof(buf), "%03u%02u%02u.map", mapId, 63u - tileX, 63u - tileY); return std::filesystem::path(dataRoot) / "maps" / buf; } @@ -439,10 +496,6 @@ FindPathResult DetourNavMeshManager::FindPath( if (!navQuery || !navMesh) return result; - float const searchExtents[3] = {_config.maxSearchRadius, - _config.maxSearchRadius, - _config.maxSearchRadius}; - dtPolyRef startRef = 0; dtPolyRef endRef = 0; float startNearest[3]{}; @@ -456,22 +509,22 @@ FindPathResult DetourNavMeshManager::FindPath( filter.setIncludeFlags(0xFFFF); filter.setExcludeFlags(0); - dtStatus status = navQuery->findNearestPoly( - startPos, searchExtents, &filter, &startRef, startNearest); - if (dtStatusFailed(status) || startRef == 0) { + bool const foundStart = + FindGroundPoly(navQuery, startPos, filter, _config.maxSearchRadius, + startRef, startNearest); + if (!foundStart || startRef == 0) { LOG_MMAP_DEBUG("MMAP path start not found: mapId={} start=({}, {}, {}) status=0x{:x}", - req.mapId, req.startX, req.startY, req.startZ, - static_cast(status)); + req.mapId, req.startX, req.startY, req.startZ, 0u); result.status = FindPathStatus::NoPath; return result; } - status = navQuery->findNearestPoly(endPos, searchExtents, &filter, - &endRef, endNearest); - if (dtStatusFailed(status) || endRef == 0) { + bool const foundEnd = + FindGroundPoly(navQuery, endPos, filter, _config.maxSearchRadius, + endRef, endNearest); + if (!foundEnd || endRef == 0) { LOG_MMAP_DEBUG("MMAP path end not found: mapId={} end=({}, {}, {}) status=0x{:x}", - req.mapId, req.endX, req.endY, req.endZ, - static_cast(status)); + req.mapId, req.endX, req.endY, req.endZ, 0u); result.status = FindPathStatus::NoPath; return result; } @@ -484,9 +537,9 @@ FindPathResult DetourNavMeshManager::FindPath( int straightPathCount = 0; int const maxPathPolys = std::clamp(_config.maxPathPolys, 1, 256); - status = navQuery->findPath(startRef, endRef, startNearest, endNearest, - &filter, pathPolys, &pathCount, - maxPathPolys); + dtStatus status = navQuery->findPath(startRef, endRef, startNearest, + endNearest, &filter, pathPolys, + &pathCount, maxPathPolys); if (dtStatusFailed(status) || pathCount == 0) { LOG_MMAP_DEBUG("MMAP path failed: mapId={} pathCount={} status=0x{:x}", req.mapId, pathCount, static_cast(status)); @@ -515,6 +568,33 @@ FindPathResult DetourNavMeshManager::FindPath( if (req.smoothPath) SmoothPath(result.waypoints); + // findStraightPath solo devuelve las esquinas del camino, con tramos largos en + // terreno abierto. Los partimos en pasos cortos para que la criatura camine bien + // (siguiendo de cerca el corredor) y para que los marcadores de .mmap path queden + // repartidos a lo largo del camino. La Z se resuelve aparte: la criatura la pega + // al suelo cada tick y los marcadores via GetHeight. + if (result.waypoints.size() >= 2) { + constexpr float kSegmentStep = 5.0f; // tramos cortos (yardas) + constexpr size_t kMaxWaypoints = 256; + std::vector dense; + dense.reserve(std::min(kMaxWaypoints, result.waypoints.size() * 8 + 1)); + for (size_t i = 0; i + 1 < result.waypoints.size() && + dense.size() < kMaxWaypoints; ++i) { + Vec3 const a = result.waypoints[i]; + Vec3 const b = result.waypoints[i + 1]; + float const dx = b.x - a.x, dy = b.y - a.y; + float const segLen = std::sqrt(dx * dx + dy * dy); + int const steps = std::max(1, static_cast(segLen / kSegmentStep)); + for (int s = 0; s < steps && dense.size() < kMaxWaypoints; ++s) { + float const t = static_cast(s) / static_cast(steps); + dense.push_back( + Vec3{a.x + dx * t, a.y + dy * t, a.z + (b.z - a.z) * t}); + } + } + dense.push_back(result.waypoints.back()); + result.waypoints = std::move(dense); + } + bool const reachedEnd = (pathCount > 0 && pathPolys[pathCount - 1] == endRef); result.status = diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index f57aaac..8501379 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -377,18 +377,23 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, float const projected = collisionQueries->GetHeight(map->GetMapId(), targetX, targetY, from.z); - // If the navmesh ground is wildly different from the creature's current Z - // (>15y), the .mmtile data is likely corrupted at this column. Fall back + // If the navmesh ground is very different from the creature/target Z, + // the .mmtile data likely picked a ghost/upper poly at this column. Fall back // to from.z so the creature keeps its current footing instead of being // teleported onto a ghost poly. - constexpr float kMaxProjectionDriftYards = 15.0f; - if (std::fabs(projected - from.z) > kMaxProjectionDriftYards) { + // Only distrust the navmesh ground when it sits well ABOVE the creature's + // current footing -- that signals a ghost/upper poly in the .mmtile. When + // the navmesh ground is at or below the creature (downhill target, or a + // flying player whose target we want pulled down to the floor) we trust it, + // so creatures never follow a target up into the air. + constexpr float kMaxUpwardProjectionYards = 4.0f; + if (projected - from.z > kMaxUpwardProjectionYards) { LOG_MMAP_WARN( "CHASE rejecting navmesh target projection: mapId={} creatureGuid={} " - "targetXY=({}, {}) navmeshZ={} fromZ={} delta={} -- using fromZ as " - "fallback (likely corrupted .mmtile)", + "targetXY=({}, {}) navmeshZ={} fromZ={} rawZ={} deltaFrom={} -- ground " + "resolved above footing, keeping fromZ (likely corrupted .mmtile)", map->GetMapId(), creature->GetGuid(), targetX, targetY, projected, - from.z, projected - from.z); + from.z, targetZRaw, projected - from.z); targetZ = from.z; } else { targetZ = projected; @@ -438,12 +443,17 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // ignore the navmesh value and keep the creature anchored at its current // Z. WoW terrain rarely changes more than 5y per step horizontally, so // this only kicks in when the .mmtile data is wrong. - constexpr float kMaxVerticalStepYards = 5.0f; - if (std::fabs(groundZ - from.z) > kMaxVerticalStepYards) { + // Asymmetric guard: only reject when the resolved ground is well ABOVE the + // creature in a single tick (a ghost/upper poly). Downward corrections are + // always allowed -- that is just gravity, and it lets a creature that ended + // up airborne (knockback, chasing up a slope) fall back to the floor + // instead of getting stuck flying. + constexpr float kMaxUpwardStepYards = 5.0f; + if (groundZ - from.z > kMaxUpwardStepYards) { LOG_MMAP_WARN( "CHASE rejecting navmesh ground jump: mapId={} creatureGuid={} " - "step=({}, {}, {}) navmeshZ={} fromZ={} delta={} -- using fromZ as " - "fallback (likely corrupted .mmtile)", + "step=({}, {}, {}) navmeshZ={} fromZ={} delta={} -- ground above " + "footing, keeping fromZ (likely corrupted .mmtile)", map->GetMapId(), creature->GetGuid(), projected.position.x, projected.position.y, projected.position.z, groundZ, from.z, groundZ - from.z); diff --git a/src/world/WorldApplication.cpp b/src/world/WorldApplication.cpp index ae26bdb..0cf5ed3 100644 --- a/src/world/WorldApplication.cpp +++ b/src/world/WorldApplication.cpp @@ -308,6 +308,7 @@ int RunWorldGameStack(std::shared_ptr tui_runtime, auto npcTemplateSearchRepo = std::make_shared(worldConn); + WorldService::Instance().SetNpcTemplateSearch(npcTemplateSearchRepo); auto gossipRepo = std::make_shared(worldConn); auto npcTextRepo = diff --git a/tools/vmap/mmap_generator/MmapGenerator.cpp b/tools/vmap/mmap_generator/MmapGenerator.cpp index 9fe809b..5e8d174 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.cpp +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -20,6 +20,7 @@ namespace Firelands { namespace { constexpr float kTileSize = 533.33333f; +constexpr float kMapOrigin = -17066.66656f; constexpr uint32_t kMapMagic = 0x5350414Du; // 'MAPS' constexpr uint32_t kMapHeightMagic = 0x54474D48u; // 'MHGT' constexpr uint32_t kMapHeightNoHeight = 0x0001; @@ -28,28 +29,44 @@ constexpr uint32_t kMapHeightAsInt8 = 0x0004; constexpr int kTerrainGridSize = 128; constexpr int kTerrainVertexCount = kTerrainGridSize + 1; -// WoW ADT convention (TrinityCore-compatible): -// - WoW X axis points NORTH; row index (tileY in the .map filename) decreases -// as X grows. Min WoW X for ADT row R is (31 - R) * TILE. -// - WoW Y axis points WEST; column index (tileX in the .map filename) -// decreases as Y grows. Min WoW Y for ADT col C is (31 - C) * TILE. -// - V9[0][0] is the NW corner: max WoW X, max WoW Y. -float TileMinWowX(uint32_t tileY) { - return (31.0f - static_cast(tileY)) * kTileSize; +float TileOriginX(uint32_t tileX) { + return kMapOrigin + static_cast(tileX) * kTileSize; } -float TileMinWowY(uint32_t tileX) { - return (31.0f - static_cast(tileX)) * kTileSize; +float TileOriginY(uint32_t tileY) { + return kMapOrigin + static_cast(tileY) * kTileSize; } std::filesystem::path MapTilePath(std::string const& mapsDir, uint32_t mapId, uint32_t tileX, uint32_t tileY) { + // The runtime loads the tile as tileX = floor(x/533 + 32), tileY = floor(y/533 + 32) + // and Detour places it in world space at ((tileX-32)*533, (tileY-32)*533). The extractor's .map + // is named {map}{gx}{gy} with gx = 32 - worldX/533 and gy = 32 - worldY/533, that is + // gx = 63 - tileX and gy = 63 - tileY. BOTH axes are mirrored; previously mapGridY = tileX + // left the X-axis unmirrored and the navmesh was mirrored (e.g., Stormwind tileX=15 + // took row 15 of the ADT instead of 48 = 63-15) -> it sent you to another zone. + uint32_t const mapGridY = 63u - tileX; + uint32_t const mapGridX = 63u - tileY; + std::ostringstream ss; ss << std::setfill('0') << std::setw(3) << mapId - << std::setw(2) << tileY << std::setw(2) << tileX << ".map"; + << std::setw(2) << mapGridY << std::setw(2) << mapGridX << ".map"; return std::filesystem::path(mapsDir) / ss.str(); } +size_t NavHeightIndex(int x, int y) { + return static_cast(y * kTerrainVertexCount + x); +} + +size_t MapHeightIndex(int x, int y) { + // The .map V9 already uses row 0 = iy=0 (maxWowX side), the same order as + // NavHeightIndex. The X-axis mirroring within the tile is already applied by the rasterizer + // (cy = kTerrainGridSize - y). Flipping here as well would duplicate the flip and leave + // the terrain inverted along the X-axis -> on slopes, the height would come from an offset point + // -> creatures would end up floating. No extra flip. + return static_cast(y * kTerrainVertexCount + x); +} + struct MmapTileHeader { uint32_t mmapMagic; uint32_t dtVersion; @@ -153,8 +170,8 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, out.width = kTerrainVertexCount; out.height = kTerrainVertexCount; out.cellSize = kTileSize / static_cast(kTerrainGridSize); - out.minX = TileMinWowX(tileY); - out.minY = TileMinWowY(tileX); + out.minX = TileOriginX(tileX); + out.minY = TileOriginY(tileY); out.minZ = gridHeight; out.maxZ = gridMaxHeight; out.heights.resize(kTerrainVertexCount * kTerrainVertexCount); @@ -165,32 +182,45 @@ bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, std::fill(out.heights.begin(), out.heights.end(), gridHeight); } else if (heightFlags & kMapHeightAsInt16) { float const invStep = heightRange > 0.0f ? heightRange / 65535.0f : 0.0f; - for (int i = 0; i < count; ++i) { - uint16_t v = 0; - if (fread(&v, sizeof(v), 1, file) != 1) { - fclose(file); - return false; + std::vector rawHeights(static_cast(count)); + if (fread(rawHeights.data(), sizeof(uint16_t), rawHeights.size(), file) != + rawHeights.size()) { + fclose(file); + return false; + } + for (int y = 0; y < kTerrainVertexCount; ++y) { + for (int x = 0; x < kTerrainVertexCount; ++x) { + uint16_t const v = rawHeights[MapHeightIndex(x, y)]; + out.heights[NavHeightIndex(x, y)] = + gridHeight + static_cast(v) * invStep; } - out.heights[i] = gridHeight + static_cast(v) * invStep; } } else if (heightFlags & kMapHeightAsInt8) { float const invStep = heightRange > 0.0f ? heightRange / 255.0f : 0.0f; - for (int i = 0; i < count; ++i) { - uint8_t v = 0; - if (fread(&v, sizeof(v), 1, file) != 1) { - fclose(file); - return false; + std::vector rawHeights(static_cast(count)); + if (fread(rawHeights.data(), sizeof(uint8_t), rawHeights.size(), file) != + rawHeights.size()) { + fclose(file); + return false; + } + for (int y = 0; y < kTerrainVertexCount; ++y) { + for (int x = 0; x < kTerrainVertexCount; ++x) { + uint8_t const v = rawHeights[MapHeightIndex(x, y)]; + out.heights[NavHeightIndex(x, y)] = + gridHeight + static_cast(v) * invStep; } - out.heights[i] = gridHeight + static_cast(v) * invStep; } } else { - for (int i = 0; i < count; ++i) { - float h = 0.0f; - if (fread(&h, sizeof(h), 1, file) != 1) { - fclose(file); - return false; + std::vector rawHeights(static_cast(count)); + if (fread(rawHeights.data(), sizeof(float), rawHeights.size(), file) != + rawHeights.size()) { + fclose(file); + return false; + } + for (int y = 0; y < kTerrainVertexCount; ++y) { + for (int x = 0; x < kTerrainVertexCount; ++x) { + out.heights[NavHeightIndex(x, y)] = rawHeights[MapHeightIndex(x, y)]; } - out.heights[i] = h; } } @@ -291,10 +321,16 @@ bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, } int const triCount = static_cast(tris.size() / 3); - std::vector triAreas(static_cast(triCount), RC_WALKABLE_AREA); - rcRasterizeTriangles(&ctx, verts.data(), static_cast(verts.size() / 3), - tris.data(), triAreas.data(), triCount, *solid, - walkableClimb); + int const vertCount = static_cast(verts.size() / 3); + // Mark as walkable ONLY those triangles whose slope is <= agentMaxSlope. The steeper + // ones remain as RC_NULL_AREA and are not included in the navmesh, so the creature + // goes around them (or doesn't climb them). Previously, everything was marked as RC_WALKABLE_AREA, ignoring + // agentMaxSlope, which is why it would climb any slope, even vertical walls. + std::vector triAreas(static_cast(triCount), 0); + rcMarkWalkableTriangles(&ctx, _config.agentMaxSlope, verts.data(), vertCount, + tris.data(), triCount, triAreas.data()); + rcRasterizeTriangles(&ctx, verts.data(), vertCount, tris.data(), + triAreas.data(), triCount, *solid, walkableClimb); rcFilterLowHangingWalkableObstacles(&ctx, walkableClimb, *solid); rcFilterLedgeSpans(&ctx, walkableHeight, walkableClimb, *solid); rcFilterWalkableLowHeightSpans(&ctx, walkableHeight, *solid); From 64631c7d1eeed5d98f54c4746868ab1852d9b1fd Mon Sep 17 00:00:00 2001 From: HkevinH Date: Mon, 1 Jun 2026 17:59:45 -0500 Subject: [PATCH 30/35] feat(commands): implement MySQL command definition repository and integrate command loading --- .../71_world_firelands_commands.sql | 54 +++ src/application/ports/ICommandSessionCore.h | 1 + src/application/services/CommandService.cpp | 327 +++++++++--------- src/application/services/CommandService.h | 24 +- .../ICommandDefinitionRepository.h | 25 ++ src/infrastructure/CMakeLists.txt | 1 + .../MySqlCommandDefinitionRepository.cpp | 39 +++ .../MySqlCommandDefinitionRepository.h | 24 ++ src/world/WorldApplication.cpp | 5 +- 9 files changed, 334 insertions(+), 166 deletions(-) create mode 100644 sql/migrations/71_world_firelands_commands.sql create mode 100644 src/domain/repositories/ICommandDefinitionRepository.h create mode 100644 src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp create mode 100644 src/infrastructure/persistence/MySqlCommandDefinitionRepository.h diff --git a/sql/migrations/71_world_firelands_commands.sql b/sql/migrations/71_world_firelands_commands.sql new file mode 100644 index 0000000..147c541 --- /dev/null +++ b/sql/migrations/71_world_firelands_commands.sql @@ -0,0 +1,54 @@ +USE `firelands_world`; + +DROP TABLE IF EXISTS `firelands_commands`; +CREATE TABLE `firelands_commands` ( + `name` varchar(64) NOT NULL, + `description` varchar(255) NOT NULL DEFAULT '', + `syntax` varchar(255) NOT NULL DEFAULT '', + `min_access_level` tinyint unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO `firelands_commands` (`name`, `description`, `syntax`, `min_access_level`) VALUES +-- Player (0) +('help', 'Show available commands', '.help', 0), +('commands', 'Show available commands (alias)', '.commands', 0), + +-- Moderator (1) +('gps', 'Show current position and map', '.gps', 1), +('mmap', 'Navmesh pathfinding info and visual markers', '.mmap [x y z [mapId]] | .mmap clear', 1), +('email', 'Open mailbox anywhere', '.email', 1), +('online', 'List online players', '.online', 1), +('announce', 'Send server-wide message', '.announce ', 1), +('kick', 'Kick a player from the server', '.kick [reason]', 1), +('goto', 'Teleport to a player', '.goto ', 1), +('appear', 'Teleport to a player (alias)', '.appear ', 1), + +-- Game Master (2) +('tele', 'Teleport to coordinates', '.tele [mapId]', 2), +('gm', 'Toggle GM mode (NPCs ignore you)', '.gm [on|off]', 2), +('dnd', 'Toggle Do Not Disturb tag', '.dnd [on|off]', 2), +('dev', 'Toggle Developer tag', '.dev [on|off]', 2), +('visible', 'Toggle GM visibility to players', '.visible [on|off]', 2), +('fly', 'Toggle fly mode', '.fly [on|off]', 2), +('speed', 'Set movement speed multiplier', '.speed (1=normal, 2=double, 0.5=half)', 2), +('summon', 'Summon a player to your location', '.summon ', 2), +('learn', 'Learn a spell', '.learn [all]', 2), +('unlearn', 'Unlearn a spell', '.unlearn [all]', 2), +('money', 'Modify money', '.money ', 2), +('additem', 'Add item to inventory', '.additem [count]', 2), +('delitem', 'Delete item from inventory', '.delitem [count]', 2), +('level', 'Set character level', '.level ', 2), +('cd', 'Reset spell cooldowns', '.cd', 2), +('damage', 'Deal damage to targeted creature', '.damage ', 2), +('revive', 'Revive yourself or targeted player', '.revive', 2), +('ticket', 'Manage GM tickets', '.ticket list | .ticket close | .ticket respond ', 2), +('faction', 'Change faction reaction', '.faction ', 2), + +-- Administrator (3) +('account', 'Manage accounts (console only)', '.account create|delete|setaccess ', 3), +('ban', 'Ban an account (console only)', '.ban [reason]', 3), +('unban', 'Unban an account (console only)', '.unban ', 3), +('server', 'Server control commands', '.server shutdown|restart ', 3), +('npc', 'NPC management commands', '.npc spawn|delete ', 3), +('rbac', 'RBAC role management (console only)', '.rbac', 3); diff --git a/src/application/ports/ICommandSessionCore.h b/src/application/ports/ICommandSessionCore.h index 22d5994..2e3f70b 100644 --- a/src/application/ports/ICommandSessionCore.h +++ b/src/application/ports/ICommandSessionCore.h @@ -36,6 +36,7 @@ class ICommandSessionCore { virtual void SendPacket(WorldPacket &packet) { (void)packet; } virtual uint64_t GetClientSelectionGuid() const { return 0; } virtual uint64_t GetActiveCharacterObjectGuid() const { return 0; } + virtual AccessLevel GetAccountAccessLevel() const { return AccessLevel::Player; } }; } // namespace Firelands diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 023ce07..cde3669 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -5,8 +5,9 @@ #include #include #include -#include #include +#include +#include #include #include #include @@ -34,6 +35,7 @@ #include #include #include +#include namespace Firelands { @@ -510,6 +512,11 @@ static void SendMmapMarkerCreate(std::shared_ptr const &session static void SendMmapMarkerDespawn(std::shared_ptr const &session, uint32_t mapId, uint64_t markerGuid) { WorldService::Instance().RemoveCreatureFromMap(mapId, markerGuid); + // The background sweep may run when the player is offline: still remove the + // creature from the map, but only push the out-of-range packet if a session is + // there to receive it. + if (!session) + return; UpdateData update(static_cast(mapId)); update.AddOutOfRangeObjects({markerGuid}); WorldPacket pkt(SMSG_UPDATE_OBJECT); @@ -583,144 +590,103 @@ CommandService::CommandService( std::shared_ptr accountRepo, std::shared_ptr characterService, std::shared_ptr gmTicketService, - std::shared_ptr rbacRepo) + std::shared_ptr rbacRepo, + std::shared_ptr commandDefRepo) : _onlineCharacters(std::move(onlineCharacters)), _accountRepo(std::move(accountRepo)), _characterService(std::move(characterService)), _gmTicketService(std::move(gmTicketService)), - _rbacRepo(std::move(rbacRepo)) { - RegisterCommand("gps", {[this](auto s, auto a, auto o) { return HandleGps(s, a, o); }, - ToMask(Permission::CommandGps), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand("mmap", {[this](auto s, auto a, auto o) { return HandleMmap(s, a, o); }, - ToMask(Permission::CommandGps), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand("tele", {[this](auto s, auto a, auto o) { return HandleTele(s, a, o); }, - ToMask(Permission::CommandTeleport), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand("help", {[this](auto s, auto a, auto o) { return HandleHelp(s, a, o); }, - 0, CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "commands", {[this](auto s, auto a, auto o) { return HandleHelp(s, a, o); }, 0, - CommandAvailability::Both, ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "account", {[this](auto s, auto a, auto o) { return HandleAccount(s, a, o); }, - ToMask(Permission::ManageAccounts), CommandAvailability::Console, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "rbac", {[this](auto s, auto a, auto o) { return HandleRbac(s, a, o); }, - ToMask(Permission::ManageAccounts), CommandAvailability::Console, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand("gm", {[this](auto s, auto a, auto o) { return HandleGmTag(s, a, o); }, - ToMask(Permission::CommandGmTools), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand("dnd", {[this](auto s, auto a, auto o) { return HandleDndTag(s, a, o); }, - ToMask(Permission::CommandGmTools), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand("dev", {[this](auto s, auto a, auto o) { return HandleDevTag(s, a, o); }, - ToMask(Permission::CommandGmTools), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "visible", {[this](auto s, auto a, auto o) { return HandleGmVisible(s, a, o); }, - ToMask(Permission::CommandGmTools), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand("fly", {[this](auto s, auto a, auto o) { return HandleGmFly(s, a, o); }, - ToMask(Permission::CommandGmTools), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "speed", {[this](auto s, auto a, auto o) { return HandleGmSpeed(s, a, o); }, - ToMask(Permission::CommandGmTools), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "online", {[this](auto s, auto a, auto o) { return HandleOnline(s, a, o); }, - ToMask(Permission::ManagePlayers), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "announce", {[this](auto s, auto a, auto o) { return HandleAnnounce(s, a, o); }, - ToMask(Permission::ManagePlayers), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "kick", {[this](auto s, auto a, auto o) { return HandleKick(s, a, o); }, - ToMask(Permission::ManagePlayers), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "goto", {[this](auto s, auto a, auto o) { return HandleGoto(s, a, o); }, - ToMask(Permission::ManagePlayers), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "appear", {[this](auto s, auto a, auto o) { return HandleGoto(s, a, o); }, - ToMask(Permission::ManagePlayers), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "summon", {[this](auto s, auto a, auto o) { return HandleSummon(s, a, o); }, - ToMask(Permission::ManagePlayers), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "learn", {[this](auto s, auto a, auto o) { return HandleLearn(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "unlearn", {[this](auto s, auto a, auto o) { return HandleUnlearn(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "money", {[this](auto s, auto a, auto o) { return HandleMoney(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "additem", {[this](auto s, auto a, auto o) { return HandleAdditem(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "delitem", {[this](auto s, auto a, auto o) { return HandleDelitem(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "level", {[this](auto s, auto a, auto o) { return HandleLevel(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "cd", {[this](auto s, auto a, auto o) { return HandleCd(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "damage", {[this](auto s, auto a, auto o) { return HandleDamage(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Game, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "revive", {[this](auto s, auto a, auto o) { return HandleRevive(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Game, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "ban", {[this](auto s, auto a, auto o) { return HandleBan(s, a, o); }, - ToMask(Permission::ManageAccounts), CommandAvailability::Console, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "unban", {[this](auto s, auto a, auto o) { return HandleUnban(s, a, o); }, - ToMask(Permission::ManageAccounts), CommandAvailability::Console, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "ticket", {[this](auto s, auto a, auto o) { return HandleTicket(s, a, o); }, - ToMask(Permission::ManageGmTickets), CommandAvailability::Game, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "email", {[this](auto s, auto a, auto o) { return HandleEmail(s, a, o); }, - ToMask(Permission::CommandMailbox), CommandAvailability::Game, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "server", {[this](auto s, auto a, auto o) { return HandleServer(s, a, o); }, - ToMask(Permission::ServerControl), CommandAvailability::Both, - ConsoleArgLayout::SameAsInGame}); - RegisterCommand( - "npc", {[this](auto s, auto a, auto o) { return HandleNpc(s, a, o); }, - ToMask(Permission::ServerControl), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); - RegisterCommand( - "faction", - {[this](auto s, auto a, auto o) { return HandleFaction(s, a, o); }, - ToMask(Permission::CommandGameplay), CommandAvailability::Both, - ConsoleArgLayout::TargetOnlineCharacterFirst}); + _rbacRepo(std::move(rbacRepo)), + _commandDefRepo(std::move(commandDefRepo)) {} + +void CommandService::LoadCommandsFromDb() { + // Build handler map (name → lambda capturing this) + std::unordered_map handlers; + handlers["gps"] = [this](auto s, auto a, auto o) { return HandleGps(s, a, o); }; + handlers["mmap"] = [this](auto s, auto a, auto o) { return HandleMmap(s, a, o); }; + handlers["tele"] = [this](auto s, auto a, auto o) { return HandleTele(s, a, o); }; + handlers["help"] = [this](auto s, auto a, auto o) { return HandleHelp(s, a, o); }; + handlers["commands"] = [this](auto s, auto a, auto o) { return HandleHelp(s, a, o); }; + handlers["account"] = [this](auto s, auto a, auto o) { return HandleAccount(s, a, o); }; + handlers["rbac"] = [this](auto s, auto a, auto o) { return HandleRbac(s, a, o); }; + handlers["gm"] = [this](auto s, auto a, auto o) { return HandleGmTag(s, a, o); }; + handlers["dnd"] = [this](auto s, auto a, auto o) { return HandleDndTag(s, a, o); }; + handlers["dev"] = [this](auto s, auto a, auto o) { return HandleDevTag(s, a, o); }; + handlers["visible"] = [this](auto s, auto a, auto o) { return HandleGmVisible(s, a, o); }; + handlers["fly"] = [this](auto s, auto a, auto o) { return HandleGmFly(s, a, o); }; + handlers["speed"] = [this](auto s, auto a, auto o) { return HandleGmSpeed(s, a, o); }; + handlers["online"] = [this](auto s, auto a, auto o) { return HandleOnline(s, a, o); }; + handlers["announce"] = [this](auto s, auto a, auto o) { return HandleAnnounce(s, a, o); }; + handlers["kick"] = [this](auto s, auto a, auto o) { return HandleKick(s, a, o); }; + handlers["goto"] = [this](auto s, auto a, auto o) { return HandleGoto(s, a, o); }; + handlers["appear"] = [this](auto s, auto a, auto o) { return HandleGoto(s, a, o); }; + handlers["summon"] = [this](auto s, auto a, auto o) { return HandleSummon(s, a, o); }; + handlers["learn"] = [this](auto s, auto a, auto o) { return HandleLearn(s, a, o); }; + handlers["unlearn"] = [this](auto s, auto a, auto o) { return HandleUnlearn(s, a, o); }; + handlers["money"] = [this](auto s, auto a, auto o) { return HandleMoney(s, a, o); }; + handlers["additem"] = [this](auto s, auto a, auto o) { return HandleAdditem(s, a, o); }; + handlers["delitem"] = [this](auto s, auto a, auto o) { return HandleDelitem(s, a, o); }; + handlers["level"] = [this](auto s, auto a, auto o) { return HandleLevel(s, a, o); }; + handlers["cd"] = [this](auto s, auto a, auto o) { return HandleCd(s, a, o); }; + handlers["damage"] = [this](auto s, auto a, auto o) { return HandleDamage(s, a, o); }; + handlers["revive"] = [this](auto s, auto a, auto o) { return HandleRevive(s, a, o); }; + handlers["ban"] = [this](auto s, auto a, auto o) { return HandleBan(s, a, o); }; + handlers["unban"] = [this](auto s, auto a, auto o) { return HandleUnban(s, a, o); }; + handlers["ticket"] = [this](auto s, auto a, auto o) { return HandleTicket(s, a, o); }; + handlers["email"] = [this](auto s, auto a, auto o) { return HandleEmail(s, a, o); }; + handlers["server"] = [this](auto s, auto a, auto o) { return HandleServer(s, a, o); }; + handlers["npc"] = [this](auto s, auto a, auto o) { return HandleNpc(s, a, o); }; + handlers["faction"] = [this](auto s, auto a, auto o) { return HandleFaction(s, a, o); }; + + // Default permissions per command name + static std::unordered_map const permDefaults = { + {"help",0},{"commands",0},{"gps",ToMask(Permission::CommandGps)},{"mmap",ToMask(Permission::CommandGps)}, + {"email",ToMask(Permission::CommandMailbox)},{"tele",ToMask(Permission::CommandTeleport)}, + {"gm",ToMask(Permission::CommandGmTools)},{"dnd",ToMask(Permission::CommandGmTools)}, + {"dev",ToMask(Permission::CommandGmTools)},{"visible",ToMask(Permission::CommandGmTools)}, + {"fly",ToMask(Permission::CommandGmTools)},{"speed",ToMask(Permission::CommandGmTools)}, + {"online",ToMask(Permission::ManagePlayers)},{"announce",ToMask(Permission::ManagePlayers)}, + {"kick",ToMask(Permission::ManagePlayers)},{"goto",ToMask(Permission::ManagePlayers)}, + {"appear",ToMask(Permission::ManagePlayers)},{"summon",ToMask(Permission::ManagePlayers)}, + {"learn",ToMask(Permission::CommandGameplay)},{"unlearn",ToMask(Permission::CommandGameplay)}, + {"money",ToMask(Permission::CommandGameplay)},{"additem",ToMask(Permission::CommandGameplay)}, + {"delitem",ToMask(Permission::CommandGameplay)},{"level",ToMask(Permission::CommandGameplay)}, + {"cd",ToMask(Permission::CommandGameplay)},{"damage",ToMask(Permission::CommandGameplay)}, + {"revive",ToMask(Permission::CommandGameplay)},{"faction",ToMask(Permission::CommandGameplay)}, + {"ticket",ToMask(Permission::ManageGmTickets)}, + {"account",ToMask(Permission::ManageAccounts)},{"ban",ToMask(Permission::ManageAccounts)}, + {"unban",ToMask(Permission::ManageAccounts)},{"rbac",ToMask(Permission::ManageAccounts)}, + {"server",ToMask(Permission::ServerControl)},{"npc",ToMask(Permission::ServerControl)}, + }; + + auto permFor = [&](std::string const &name) -> uint64_t { + auto it = permDefaults.find(name); + return it != permDefaults.end() ? it->second : 0; + }; + + // Load from DB first — only commands with handlers are registered + if (_commandDefRepo) { + auto const defs = _commandDefRepo->LoadAll(); + for (auto const &def : defs) { + auto hit = handlers.find(def.name); + if (hit == handlers.end()) + continue; + CommandEntry entry; + entry.handler = hit->second; + entry.requiredPermissions = permFor(def.name); + _commands[def.name] = std::move(entry); + } + } + + // Fallback: register any handler not yet in _commands (no DB row, but handler exists) + for (auto &[name, handler] : handlers) { + if (_commands.count(name) > 0) + continue; + CommandEntry entry; + entry.handler = handler; + entry.requiredPermissions = permFor(name); + _commands[name] = std::move(entry); + } } void CommandService::RegisterCommand(const std::string &name, CommandEntry entry) { @@ -875,26 +841,10 @@ bool CommandService::HandleMmap(std::shared_ptr session, return HandleMmapTestArea(session, *collision, map, mapId); } - // Auto-remove expired markers (older than 9s) - { - auto mit = _mmapMarkers.find(playerGuid); - if (mit != _mmapMarkers.end()) { - auto const now = std::chrono::steady_clock::now(); - auto &markers = mit->second; - markers.erase( - std::remove_if(markers.begin(), markers.end(), - [&](auto const &p) { - if (now - p.second > std::chrono::seconds(9)) { - SendMmapMarkerDespawn(session, mapId, p.first); - return true; - } - return false; - }), - markers.end()); - if (markers.empty()) - _mmapMarkers.erase(mit); - } - } + // Auto-remove expired markers (older than 9s). The background sweep in + // PollScheduledRestart does this on time without another .mmap call; this keeps + // it prompt on the next invocation too. + SweepExpiredMmapMarkers(); // .mmap clear — remove visual markers if (!args.empty() && args[0] == "clear") { @@ -1065,7 +1015,12 @@ bool CommandService::HandleMmap(std::shared_ptr session, mapId, markerGuid, i, wp.x, wp.y, wp.z, groundZ, z); markers.emplace_back(markerGuid, now); } - _mmapMarkers[playerGuid] = std::move(markers); + { + std::lock_guard lock(_mmapMarkersMutex); + auto &set = _mmapMarkers[playerGuid]; + set.mapId = mapId; + set.markers = std::move(markers); + } session->SendNotification("MMAP: " + std::to_string(result.waypoints.size()) + " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); @@ -1079,20 +1034,47 @@ bool CommandService::HandleMmap(std::shared_ptr session, void CommandService::ClearMmapMarkers(std::shared_ptr session, uint64_t playerGuid, uint32_t mapId) { + (void)mapId; + std::lock_guard lock(_mmapMarkersMutex); auto it = _mmapMarkers.find(playerGuid); if (it == _mmapMarkers.end()) return; - // Remove expired + all markers - auto &markers = it->second; - for (auto &[guid, spawnTime] : markers) { + // Remove all markers (despawn on the map they were spawned on). + for (auto &[guid, spawnTime] : it->second.markers) { (void)spawnTime; - SendMmapMarkerDespawn(session, mapId, guid); + SendMmapMarkerDespawn(session, it->second.mapId, guid); } - markers.clear(); _mmapMarkers.erase(it); } +void CommandService::SweepExpiredMmapMarkers() { + std::lock_guard lock(_mmapMarkersMutex); + if (_mmapMarkers.empty()) + return; + auto const now = std::chrono::steady_clock::now(); + for (auto it = _mmapMarkers.begin(); it != _mmapMarkers.end();) { + std::shared_ptr const session = + _onlineCharacters ? _onlineCharacters->TryResolveByObjectGuid(it->first) + : nullptr; + auto &set = it->second; + set.markers.erase( + std::remove_if(set.markers.begin(), set.markers.end(), + [&](auto const &p) { + if (now - p.second > std::chrono::seconds(9)) { + SendMmapMarkerDespawn(session, set.mapId, p.first); + return true; + } + return false; + }), + set.markers.end()); + if (set.markers.empty()) + it = _mmapMarkers.erase(it); + else + ++it; + } +} + bool CommandService::HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin) { @@ -1436,6 +1418,26 @@ static void EmitFilteredStaffHelp(std::shared_ptr session, bool CommandService::HandleHelp(std::shared_ptr session, const std::vector &, PrivilegeOrigin origin) { + // Emit commands from DB (firelands_commands table) filtered by access level + if (_commandDefRepo) { + auto cmds = _commandDefRepo->LoadAll(); + if (!cmds.empty()) { + AccessLevel const level = session->GetAccountAccessLevel(); + session->SendNotification("|cffFFD200--- Available Commands (Lvl " + + std::to_string(static_cast(level)) + + ") ---|r"); + for (auto const &cmd : cmds) { + if (static_cast(level) >= static_cast(cmd.minAccessLevel)) { + std::string line = "|cffCCCCCC." + cmd.name + "|r"; + if (!cmd.syntax.empty()) + line += " |cff888888" + cmd.syntax + "|r"; + if (!cmd.description.empty()) + line += " |cff666666-|r " + cmd.description; + session->SendNotification(line); + } + } + } + } EmitFilteredStaffHelp(std::move(session), origin); return true; } @@ -2752,6 +2754,9 @@ bool CommandService::HandleEmail(std::shared_ptr session, } void CommandService::PollScheduledRestart() { + // Despawn expired .mmap path markers on time (runs on the main loop tick, so it + // happens at ~9s even if the player never invokes .mmap again). + SweepExpiredMmapMarkers(); if (!_restartDeadline) return; auto const now = std::chrono::steady_clock::now(); diff --git a/src/application/services/CommandService.h b/src/application/services/CommandService.h index afd6fa5..c11497f 100644 --- a/src/application/services/CommandService.h +++ b/src/application/services/CommandService.h @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -14,7 +15,9 @@ namespace Firelands { class ICommandSession; class IAccountRepository; +class ICommandDefinitionRepository; class IRbacRepository; +class ICommandDefinitionRepository; class OnlineCharacterSessionRegistry; class CharacterService; class GmTicketService; @@ -44,7 +47,10 @@ class CommandService : public ICommandService { std::shared_ptr accountRepo = {}, std::shared_ptr characterService = {}, std::shared_ptr gmTicketService = {}, - std::shared_ptr rbacRepo = {}); + std::shared_ptr rbacRepo = {}, + std::shared_ptr commandDefRepo = {}); + + void LoadCommandsFromDb(); bool ExecuteCommand(std::shared_ptr session, const std::string &message, @@ -63,8 +69,6 @@ class CommandService : public ICommandService { PermissionMask requiredPermissions = 0; CommandAvailability availability = CommandAvailability::Both; ConsoleArgLayout consoleLayout = ConsoleArgLayout::SameAsInGame; - std::string description; - std::string syntax; }; void RegisterCommand(const std::string &name, CommandEntry entry); @@ -75,6 +79,9 @@ class CommandService : public ICommandService { const std::vector &args, PrivilegeOrigin origin); void ClearMmapMarkers(std::shared_ptr session, uint64_t playerGuid, uint32_t mapId); + /// Despawn .mmap path markers older than 9s. Called from PollScheduledRestart + /// (main loop) so markers vanish on time even without another .mmap call. + void SweepExpiredMmapMarkers(); bool HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); bool HandleHelp(std::shared_ptr session, @@ -143,8 +150,17 @@ class CommandService : public ICommandService { std::shared_ptr _characterService; std::shared_ptr _gmTicketService; std::shared_ptr _rbacRepo; + std::shared_ptr _commandDefRepo; std::map _commands; - std::unordered_map>> _mmapMarkers; + /// .mmap path visual markers per player (key = player object guid). mapId is + /// stored so the background sweep can despawn them without a live session map. + struct MmapMarkerSet { + uint32_t mapId = 0; + std::vector> + markers; + }; + std::mutex _mmapMarkersMutex; + std::unordered_map _mmapMarkers; std::function _shutdownRequestHandler; std::optional _restartDeadline; diff --git a/src/domain/repositories/ICommandDefinitionRepository.h b/src/domain/repositories/ICommandDefinitionRepository.h new file mode 100644 index 0000000..66e3731 --- /dev/null +++ b/src/domain/repositories/ICommandDefinitionRepository.h @@ -0,0 +1,25 @@ +#ifndef FIRELANDS_DOMAIN_REPOSITORIES_I_COMMAND_DEFINITION_REPOSITORY_H +#define FIRELANDS_DOMAIN_REPOSITORIES_I_COMMAND_DEFINITION_REPOSITORY_H + +#include +#include +#include + +namespace Firelands { + +struct CommandDefinition { + std::string name; + std::string description; + std::string syntax; + uint8_t minAccessLevel = 0; +}; + +class ICommandDefinitionRepository { +public: + virtual ~ICommandDefinitionRepository() = default; + virtual std::vector LoadAll() = 0; +}; + +} // namespace Firelands + +#endif diff --git a/src/infrastructure/CMakeLists.txt b/src/infrastructure/CMakeLists.txt index c4b3566..3d10590 100644 --- a/src/infrastructure/CMakeLists.txt +++ b/src/infrastructure/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(FirelandsInfrastructure STATIC dbc/SpellEntryDbcStore.cpp persistence/MySqlAccountRepository.cpp persistence/MySqlRbacRepository.cpp + persistence/MySqlCommandDefinitionRepository.cpp persistence/MySqlAccountDataRepository.cpp persistence/MySqlRealmRepository.cpp persistence/MySqlCharacterRepository.cpp diff --git a/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp new file mode 100644 index 0000000..e300c92 --- /dev/null +++ b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp @@ -0,0 +1,39 @@ +#include + +#include +#include +#include + +namespace Firelands { + +MySqlCommandDefinitionRepository::MySqlCommandDefinitionRepository( + std::shared_ptr conn) + : _conn(std::move(conn)) {} + +std::vector MySqlCommandDefinitionRepository::LoadAll() { + std::vector result; + if (!_conn) + return result; + + try { + std::unique_ptr stmt(_conn->createStatement()); + std::unique_ptr rs(stmt->executeQuery( + "SELECT name, description, syntax, min_access_level " + "FROM firelands_commands ORDER BY min_access_level, name")); + + while (rs->next()) { + CommandDefinition def; + def.name = rs->getString(1); + def.description = rs->getString(2); + def.syntax = rs->getString(3); + def.minAccessLevel = static_cast(rs->getUInt(4)); + result.push_back(std::move(def)); + } + } catch (sql::SQLException &e) { + (void)e; + } + + return result; +} + +} // namespace Firelands diff --git a/src/infrastructure/persistence/MySqlCommandDefinitionRepository.h b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.h new file mode 100644 index 0000000..ead3f49 --- /dev/null +++ b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.h @@ -0,0 +1,24 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_PERSISTENCE_MYSQL_COMMAND_DEFINITION_REPOSITORY_H +#define FIRELANDS_INFRASTRUCTURE_PERSISTENCE_MYSQL_COMMAND_DEFINITION_REPOSITORY_H + +#include +#include + +namespace sql { +class Connection; +} + +namespace Firelands { + +class MySqlCommandDefinitionRepository : public ICommandDefinitionRepository { +public: + explicit MySqlCommandDefinitionRepository(std::shared_ptr conn); + std::vector LoadAll() override; + +private: + std::shared_ptr _conn; +}; + +} // namespace Firelands + +#endif diff --git a/src/world/WorldApplication.cpp b/src/world/WorldApplication.cpp index 0cf5ed3..f94d290 100644 --- a/src/world/WorldApplication.cpp +++ b/src/world/WorldApplication.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -173,7 +174,9 @@ int RunWorldGameStack(std::shared_ptr tui_runtime, auto gmTicketService = std::make_shared(gmTicketRepo, charService); auto commandService = std::make_shared( - onlineCharRegistry, accountRepo, charService, gmTicketService, rbacRepo); + onlineCharRegistry, accountRepo, charService, gmTicketService, rbacRepo, + std::make_shared(worldConn)); + commandService->LoadCommandsFromDb(); auto languagesDbc = std::make_shared(); if (!languagesDbc->Load(dbcBasePath + "/Languages.dbc")) { From 4ab635953f9e9f141833832600924f3ccf54cb18 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Mon, 1 Jun 2026 18:17:40 -0500 Subject: [PATCH 31/35] fix(mmap): update agentMaxSlope to match WoW standards for walkable terrain --- tools/vmap/mmap_generator/MmapGenerator.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/vmap/mmap_generator/MmapGenerator.h b/tools/vmap/mmap_generator/MmapGenerator.h index db8d6a2..e94df6c 100644 --- a/tools/vmap/mmap_generator/MmapGenerator.h +++ b/tools/vmap/mmap_generator/MmapGenerator.h @@ -18,7 +18,12 @@ struct MmapGeneratorConfig { float agentHeight = 2.0f; float agentRadius = 0.6f; float agentMaxClimb = 0.9f; - float agentMaxSlope = 45.0f; + // Maximum walkable slope = the WoW client's walkable slope limit. + // 45 was too restrictive (it wouldn't follow the player uphill); 55 was too permissive + // (it would climb slopes that the player CANNOT climb). 50 is the standard value in WoW: + // the creature climbs exactly what a player climbs and goes around the steepest part + // where the player would go. Adjust only if you see that it does not match the client. + float agentMaxSlope = 50.0f; float minRegionArea = 10.0f; float mergeRegionArea = 20.0f; float maxEdgeLen = 12.0f; From 9d8fec0ffd6d01b2be8f2a06579123138965ad99 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Mon, 1 Jun 2026 22:12:21 -0500 Subject: [PATCH 32/35] feat(commands): update command permissions to use required_permission_mask instead of min_access_level --- .../71_world_firelands_commands.sql | 93 +++++++++++-------- .../combat/CreatureChaseMovement.cpp | 8 +- src/application/ports/ICommandSessionCore.h | 1 - src/application/services/CommandService.cpp | 40 ++++++-- .../ICommandDefinitionRepository.h | 2 +- .../worldsession/WorldSessionCombat.cpp | 12 ++- .../MySqlCommandDefinitionRepository.cpp | 6 +- 7 files changed, 106 insertions(+), 56 deletions(-) diff --git a/sql/migrations/71_world_firelands_commands.sql b/sql/migrations/71_world_firelands_commands.sql index 147c541..6e8c923 100644 --- a/sql/migrations/71_world_firelands_commands.sql +++ b/sql/migrations/71_world_firelands_commands.sql @@ -5,50 +5,65 @@ CREATE TABLE `firelands_commands` ( `name` varchar(64) NOT NULL, `description` varchar(255) NOT NULL DEFAULT '', `syntax` varchar(255) NOT NULL DEFAULT '', - `min_access_level` tinyint unsigned NOT NULL DEFAULT '0', + `required_permission_mask` bigint unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -INSERT INTO `firelands_commands` (`name`, `description`, `syntax`, `min_access_level`) VALUES --- Player (0) +-- Permission mask values match shared/game/Permissions.h +-- 0 = anyone, otherwise must match GetAccountRolePermissionMask() + +INSERT INTO `firelands_commands` (`name`, `description`, `syntax`, `required_permission_mask`) VALUES +-- Anyone (mask=0) ('help', 'Show available commands', '.help', 0), ('commands', 'Show available commands (alias)', '.commands', 0), --- Moderator (1) +-- GPS / Position (mask=1 = CommandGps) ('gps', 'Show current position and map', '.gps', 1), ('mmap', 'Navmesh pathfinding info and visual markers', '.mmap [x y z [mapId]] | .mmap clear', 1), -('email', 'Open mailbox anywhere', '.email', 1), -('online', 'List online players', '.online', 1), -('announce', 'Send server-wide message', '.announce ', 1), -('kick', 'Kick a player from the server', '.kick [reason]', 1), -('goto', 'Teleport to a player', '.goto ', 1), -('appear', 'Teleport to a player (alias)', '.appear ', 1), - --- Game Master (2) -('tele', 'Teleport to coordinates', '.tele [mapId]', 2), -('gm', 'Toggle GM mode (NPCs ignore you)', '.gm [on|off]', 2), -('dnd', 'Toggle Do Not Disturb tag', '.dnd [on|off]', 2), -('dev', 'Toggle Developer tag', '.dev [on|off]', 2), -('visible', 'Toggle GM visibility to players', '.visible [on|off]', 2), -('fly', 'Toggle fly mode', '.fly [on|off]', 2), -('speed', 'Set movement speed multiplier', '.speed (1=normal, 2=double, 0.5=half)', 2), -('summon', 'Summon a player to your location', '.summon ', 2), -('learn', 'Learn a spell', '.learn [all]', 2), -('unlearn', 'Unlearn a spell', '.unlearn [all]', 2), -('money', 'Modify money', '.money ', 2), -('additem', 'Add item to inventory', '.additem [count]', 2), -('delitem', 'Delete item from inventory', '.delitem [count]', 2), -('level', 'Set character level', '.level ', 2), -('cd', 'Reset spell cooldowns', '.cd', 2), -('damage', 'Deal damage to targeted creature', '.damage ', 2), -('revive', 'Revive yourself or targeted player', '.revive', 2), -('ticket', 'Manage GM tickets', '.ticket list | .ticket close | .ticket respond ', 2), -('faction', 'Change faction reaction', '.faction ', 2), - --- Administrator (3) -('account', 'Manage accounts (console only)', '.account create|delete|setaccess ', 3), -('ban', 'Ban an account (console only)', '.ban [reason]', 3), -('unban', 'Unban an account (console only)', '.unban ', 3), -('server', 'Server control commands', '.server shutdown|restart ', 3), -('npc', 'NPC management commands', '.npc spawn|delete ', 3), -('rbac', 'RBAC role management (console only)', '.rbac', 3); + +-- Mailbox (mask=512 = CommandMailbox) +('email', 'Open mailbox anywhere', '.email', 512), + +-- Teleport (mask=2 = CommandTeleport) +('tele', 'Teleport to coordinates or location name', '.tele [mapId]', 2), + +-- GM Tools (mask=64 = CommandGmTools) +('gm', 'Toggle GM mode on/off (NPCs ignore you)', '.gm [on|off]', 64), +('dnd', 'Toggle Do Not Disturb tag', '.dnd [on|off]', 64), +('dev', 'Toggle Developer tag', '.dev [on|off]', 64), +('visible', 'Toggle GM visibility to players', '.visible [on|off]', 64), +('fly', 'Toggle fly mode', '.fly [on|off]', 64), +('speed', 'Set movement speed multiplier', '.speed (1=normal)', 64), + +-- Manage Players (mask=8) +('online', 'List online players', '.online', 8), +('announce', 'Send server-wide announcement', '.announce ', 8), +('kick', 'Kick a player from the server', '.kick [reason]', 8), +('goto', 'Teleport to a player', '.goto ', 8), +('appear', 'Teleport to a player (alias)', '.appear ', 8), +('summon', 'Summon a player to your location', '.summon ', 8), + +-- Gameplay (mask=128 = CommandGameplay) +('learn', 'Learn a spell by ID', '.learn [all]', 128), +('unlearn', 'Unlearn a spell by ID', '.unlearn [all]', 128), +('money', 'Modify money (copper)', '.money ', 128), +('additem', 'Add item to inventory', '.additem [count]', 128), +('delitem', 'Delete item from inventory', '.delitem [count]', 128), +('level', 'Set character level', '.level ', 128), +('cd', 'Reset all spell cooldowns', '.cd', 128), +('damage', 'Deal damage to targeted creature', '.damage ', 128), +('revive', 'Revive yourself or targeted player', '.revive', 128), +('faction', 'Change faction reaction', '.faction ', 128), + +-- GM Tickets (mask=256 = ManageGmTickets) +('ticket', 'Manage GM tickets', '.ticket list|close|respond', 256), + +-- Accounts (mask=16 = ManageAccounts) +('account', 'Manage accounts (console only)', '.account create|delete|setaccess', 16), +('ban', 'Ban an account (console only)', '.ban [reason]', 16), +('unban', 'Unban an account (console only)', '.unban ', 16), +('rbac', 'RBAC role management (console only)', '.rbac setstaff|grant|revoke|show', 16), + +-- Server Control (mask=32) +('server', 'Server control commands', '.server shutdown|restart ', 32), +('npc', 'NPC management', '.npc spawn|delete|info ', 32); diff --git a/src/application/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index e7a9c42..2b26d78 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -1,6 +1,7 @@ #include "CreatureChaseMovement.h" #include +#include #include #include #include @@ -171,8 +172,13 @@ CreatureChaseStepResult StepCreatureAlongNavMeshPath( // float on slopes (when climbing slowly) or sink into/pass through the slope (the Z value doesn’t rise // as fast as the terrain). Solving the Z value against the actual ground eliminates both // problems, and the creature always stays attached to the terrain. + // Only ground creatures get pinned to the floor. Airborne ones (flying / + // no-gravity / hover) keep the Z the step produced, so a flyer isn't yanked to + // the terrain while chasing. Today ground creatures carry no airborne flags, so + // this is inert for them and behaves exactly as before. + bool const airborne = Firelands::MovementIsAirborneTier(current); auto snapToGround = [&](CreatureChaseStepResult res) { - if (collision && res.moved) { + if (collision && res.moved && !airborne) { float const ground = collision->GetHeight(mapId, res.position.x, res.position.y, res.position.z); if (std::isfinite(ground)) diff --git a/src/application/ports/ICommandSessionCore.h b/src/application/ports/ICommandSessionCore.h index 2e3f70b..22d5994 100644 --- a/src/application/ports/ICommandSessionCore.h +++ b/src/application/ports/ICommandSessionCore.h @@ -36,7 +36,6 @@ class ICommandSessionCore { virtual void SendPacket(WorldPacket &packet) { (void)packet; } virtual uint64_t GetClientSelectionGuid() const { return 0; } virtual uint64_t GetActiveCharacterObjectGuid() const { return 0; } - virtual AccessLevel GetAccountAccessLevel() const { return AccessLevel::Player; } }; } // namespace Firelands diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index cde3669..792b3fc 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -178,6 +178,8 @@ class DelegatingCommandSession final : public ICommandSession { return _subject->GmRevivePlayer(playerGuid); } + bool GmReviveSelf() override { return _subject->GmReviveSelf(); } + uint64_t GetClientSelectionGuid() const override { return _operatorSession ? _operatorSession->GetClientSelectionGuid() : 0; } @@ -664,7 +666,21 @@ void CommandService::LoadCommandsFromDb() { return it != permDefaults.end() ? it->second : 0; }; - // Load from DB first — only commands with handlers are registered + // From the server console these commands take an online character name as the + // first argument and delegate the action to that player. Without it they run + // against the console session (no character) and fail — e.g. `.revive` could + // only ever revive "self", which the console has none of. + auto consoleLayoutFor = [](std::string const &name) { + return name == "revive" ? ConsoleArgLayout::TargetOnlineCharacterFirst + : ConsoleArgLayout::SameAsInGame; + }; + + // Load from DB first — only commands with handlers are registered. La tabla + // `firelands_commands` es la fuente autoritativa del permiso de EJECUCION (su + // columna required_permission_mask, donde 0 = cualquiera). Asi coincide con lo + // que filtra `.help`, que lee la misma tabla -> una sola fuente de verdad. El + // mapa hardcodeado permFor() solo queda como red de seguridad para comandos sin + // fila en la tabla (DB caida / tabla vacia). if (_commandDefRepo) { auto const defs = _commandDefRepo->LoadAll(); for (auto const &def : defs) { @@ -673,18 +689,22 @@ void CommandService::LoadCommandsFromDb() { continue; CommandEntry entry; entry.handler = hit->second; - entry.requiredPermissions = permFor(def.name); + entry.requiredPermissions = def.requiredPermissionMask; + entry.consoleLayout = consoleLayoutFor(def.name); _commands[def.name] = std::move(entry); } } - // Fallback: register any handler not yet in _commands (no DB row, but handler exists) + // Fallback: register any handler not yet in _commands (no DB row, but handler + // exists). Aqui SI usamos permFor() porque no hay fila de tabla de donde sacar + // el permiso, y un comando staff nunca debe quedar publico por accidente. for (auto &[name, handler] : handlers) { if (_commands.count(name) > 0) continue; CommandEntry entry; entry.handler = handler; entry.requiredPermissions = permFor(name); + entry.consoleLayout = consoleLayoutFor(name); _commands[name] = std::move(entry); } } @@ -1418,16 +1438,18 @@ static void EmitFilteredStaffHelp(std::shared_ptr session, bool CommandService::HandleHelp(std::shared_ptr session, const std::vector &, PrivilegeOrigin origin) { - // Emit commands from DB (firelands_commands table) filtered by access level + // Emit commands from DB (firelands_commands table) filtered by RBAC permission mask if (_commandDefRepo) { auto cmds = _commandDefRepo->LoadAll(); if (!cmds.empty()) { - AccessLevel const level = session->GetAccountAccessLevel(); - session->SendNotification("|cffFFD200--- Available Commands (Lvl " + - std::to_string(static_cast(level)) + - ") ---|r"); + PermissionMask const mask = session->GetAccountRolePermissionMask(); + std::ostringstream hexMask; + hexMask << std::hex << static_cast(mask); + session->SendNotification("|cffFFD200--- Available Commands (mask=0x" + + hexMask.str() + ") ---|r"); for (auto const &cmd : cmds) { - if (static_cast(level) >= static_cast(cmd.minAccessLevel)) { + if (cmd.requiredPermissionMask == 0 || + (mask & cmd.requiredPermissionMask) == cmd.requiredPermissionMask) { std::string line = "|cffCCCCCC." + cmd.name + "|r"; if (!cmd.syntax.empty()) line += " |cff888888" + cmd.syntax + "|r"; diff --git a/src/domain/repositories/ICommandDefinitionRepository.h b/src/domain/repositories/ICommandDefinitionRepository.h index 66e3731..9e2a33f 100644 --- a/src/domain/repositories/ICommandDefinitionRepository.h +++ b/src/domain/repositories/ICommandDefinitionRepository.h @@ -11,7 +11,7 @@ struct CommandDefinition { std::string name; std::string description; std::string syntax; - uint8_t minAccessLevel = 0; + uint64_t requiredPermissionMask = 0; }; class ICommandDefinitionRepository { diff --git a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp index 8501379..0f2e58d 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionCombat.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -373,7 +374,11 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // ticks (otherwise an airborne player would trigger a replan every tick). // Home splines already track a grounded point. float const targetZRaw = targetZ; - if (collisionQueries && !returnHomeSpline) { + // Ground creatures pull the target down to the floor so they never chase a + // flying player up into the air. Airborne creatures (flyers) skip this and + // follow the target's real Z. Inert for ground creatures today (no airborne + // flags), so behaviour is unchanged for them. + if (collisionQueries && !returnHomeSpline && !MovementIsAirborneTier(from)) { float const projected = collisionQueries->GetHeight(map->GetMapId(), targetX, targetY, from.z); @@ -433,7 +438,10 @@ bool TryBroadcastCreatureSplineStep(std::shared_ptr const &map, // Belt-and-suspenders: even when the step skipped Z motion (stop-range case // or 2D-only waypoint), keep the broadcast position on the navmesh floor. - if (collisionQueries && !returnHomeSpline) { + // Skip for airborne creatures (flying / no-gravity / hover): forcing their Z to + // the ground would yank a flyer down to the terrain. For ground creatures this + // is inert today (they carry no airborne flags) and clamps as before. + if (collisionQueries && !returnHomeSpline && !MovementIsAirborneTier(from)) { float const preClampZ = projected.position.z; float groundZ = collisionQueries->GetHeight( map->GetMapId(), projected.position.x, projected.position.y, diff --git a/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp index e300c92..b82f09c 100644 --- a/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp +++ b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp @@ -18,15 +18,15 @@ std::vector MySqlCommandDefinitionRepository::LoadAll() { try { std::unique_ptr stmt(_conn->createStatement()); std::unique_ptr rs(stmt->executeQuery( - "SELECT name, description, syntax, min_access_level " - "FROM firelands_commands ORDER BY min_access_level, name")); + "SELECT name, description, syntax, required_permission_mask " + "FROM firelands_commands ORDER BY required_permission_mask, name")); while (rs->next()) { CommandDefinition def; def.name = rs->getString(1); def.description = rs->getString(2); def.syntax = rs->getString(3); - def.minAccessLevel = static_cast(rs->getUInt(4)); + def.requiredPermissionMask = rs->getUInt64(4); result.push_back(std::move(def)); } } catch (sql::SQLException &e) { From b35518b66fd5e0eee9e61b7494d2fcfc02107694 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Thu, 4 Jun 2026 20:16:04 -0500 Subject: [PATCH 33/35] fix(commands): correct firelands_commands seed for speed, cd, faction, ticket Update syntax/description rows to match actual handlers: .speed takes a number or reset (default 7, affects run+fly), .cd resets racials too, .faction uses forced/template subcommands, and .ticket lists queue|mine|ui|take|reply|close. --- sql/migrations/71_world_firelands_commands.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sql/migrations/71_world_firelands_commands.sql b/sql/migrations/71_world_firelands_commands.sql index 6e8c923..8dc4f48 100644 --- a/sql/migrations/71_world_firelands_commands.sql +++ b/sql/migrations/71_world_firelands_commands.sql @@ -33,7 +33,7 @@ INSERT INTO `firelands_commands` (`name`, `description`, `syntax`, `required_per ('dev', 'Toggle Developer tag', '.dev [on|off]', 64), ('visible', 'Toggle GM visibility to players', '.visible [on|off]', 64), ('fly', 'Toggle fly mode', '.fly [on|off]', 64), -('speed', 'Set movement speed multiplier', '.speed (1=normal)', 64), +('speed', 'Set run and flight speed (also affects fly)', '.speed | .speed reset (default 7)', 64), -- Manage Players (mask=8) ('online', 'List online players', '.online', 8), @@ -50,13 +50,13 @@ INSERT INTO `firelands_commands` (`name`, `description`, `syntax`, `required_per ('additem', 'Add item to inventory', '.additem [count]', 128), ('delitem', 'Delete item from inventory', '.delitem [count]', 128), ('level', 'Set character level', '.level ', 128), -('cd', 'Reset all spell cooldowns', '.cd', 128), +('cd', 'Reset all spell cooldowns (including racials)', '.cd', 128), ('damage', 'Deal damage to targeted creature', '.damage ', 128), ('revive', 'Revive yourself or targeted player', '.revive', 128), -('faction', 'Change faction reaction', '.faction ', 128), +('faction', 'Force faction reaction rank or set faction template', '.faction forced set | forced clear | forced clearall | template self|target ', 128), -- GM Tickets (mask=256 = ManageGmTickets) -('ticket', 'Manage GM tickets', '.ticket list|close|respond', 256), +('ticket', 'Manage GM tickets', '.ticket queue|mine|ui|take |reply |close ', 256), -- Accounts (mask=16 = ManageAccounts) ('account', 'Manage accounts (console only)', '.account create|delete|setaccess', 16), From a2436fcde89bc4b733f9d59e89cea7b937d27d71 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Thu, 4 Jun 2026 22:20:04 -0500 Subject: [PATCH 34/35] refactor(commands): extract .mmap into MmapDebugCommands Move the .mmap GM command (navmesh queries + visual path markers) and its marker state out of CommandService into a dedicated MmapDebugCommands class. Share JoinArgs/AsciiEqualsLower/IsAllDigitAscii via CommandTextUtils.h, relocate FindPathStatusName next to its enum, and drop the unused RegisterCommand. --- src/application/CMakeLists.txt | 1 + src/application/ports/IMapCollisionQueries.h | 15 + src/application/services/CommandService.cpp | 583 +----------------- src/application/services/CommandService.h | 22 +- src/application/services/CommandTextUtils.h | 50 ++ .../services/MmapDebugCommands.cpp | 554 +++++++++++++++++ src/application/services/MmapDebugCommands.h | 54 ++ 7 files changed, 682 insertions(+), 597 deletions(-) create mode 100644 src/application/services/CommandTextUtils.h create mode 100644 src/application/services/MmapDebugCommands.cpp create mode 100644 src/application/services/MmapDebugCommands.h diff --git a/src/application/CMakeLists.txt b/src/application/CMakeLists.txt index 562dfbd..84222d6 100644 --- a/src/application/CMakeLists.txt +++ b/src/application/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(FirelandsApplication STATIC services/CharacterActionButtons.cpp services/RealmListService.cpp services/CommandService.cpp + services/MmapDebugCommands.cpp services/GmTicketService.cpp services/OnlineCharacterSessionRegistry.cpp services/MapService.cpp diff --git a/src/application/ports/IMapCollisionQueries.h b/src/application/ports/IMapCollisionQueries.h index 3ea6681..0561ae1 100644 --- a/src/application/ports/IMapCollisionQueries.h +++ b/src/application/ports/IMapCollisionQueries.h @@ -20,6 +20,21 @@ enum class FindPathStatus : uint8_t { NavMeshMissing }; +/// Human-readable name for logging / GM command output. +inline char const *FindPathStatusName(FindPathStatus status) { + switch (status) { + case FindPathStatus::Complete: + return "Complete"; + case FindPathStatus::Partial: + return "Partial"; + case FindPathStatus::NoPath: + return "NoPath"; + case FindPathStatus::NavMeshMissing: + return "NavMeshMissing"; + } + return "Unknown"; +} + struct FindPathRequest { uint32_t mapId = 0; float startX = 0.0f; diff --git a/src/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index 792b3fc..83f5366 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -1,5 +1,6 @@ #include "CommandService.h" #include +#include #include #include #include @@ -44,14 +45,6 @@ namespace { /// Blood Elf Female civilian — usable placeholder when `.npc add` omits displayId. constexpr uint32_t kDefaultGmNpcDisplayId = 15688u; -std::atomic g_nextMmapMarkerLow{0x71000000u}; - -uint64_t AllocateMmapMarkerGuid(uint32_t creatureEntry) { - uint32_t const low = - g_nextMmapMarkerLow.fetch_add(1u, std::memory_order_relaxed); - return MakeCreatureObjectGuid(creatureEntry, low); -} - class DelegatingCommandSession final : public ICommandSession { std::shared_ptr _subject; std::shared_ptr _operatorSession; @@ -196,40 +189,6 @@ class DelegatingCommandSession final : public ICommandSession { uint32_t GetAccountId() const override { return _subject->GetAccountId(); } }; -static std::string JoinArgs(std::vector::const_iterator begin, - std::vector::const_iterator end) { - std::string out; - for (auto it = begin; it != end; ++it) { - if (!out.empty()) - out += ' '; - out += *it; - } - return out; -} - -static bool AsciiEqualsLower(std::string const &a, char const *b) { - size_t const n = std::strlen(b); - if (a.size() != n) - return false; - for (size_t i = 0; i < n; ++i) { - if (std::tolower(static_cast(a[i])) != - static_cast(b[i])) { - return false; - } - } - return true; -} - -static bool IsAllDigitAscii(std::string const &s) { - if (s.empty()) - return false; - for (unsigned char c : s) { - if (!std::isdigit(c)) - return false; - } - return true; -} - static bool IsWowColorHexDigit(char c) { unsigned char const u = static_cast(c); return (u >= '0' && u <= '9') || (u >= 'a' && u <= 'f') || @@ -269,263 +228,6 @@ static std::string StripWowChatColorTokens(std::string const &in) { static constexpr uint64_t kMaxRestartDelaySeconds = 7ULL * 24 * 3600; -static char const *FindPathStatusName(FindPathStatus status) { - switch (status) { - case FindPathStatus::Complete: - return "Complete"; - case FindPathStatus::Partial: - return "Partial"; - case FindPathStatus::NoPath: - return "NoPath"; - case FindPathStatus::NavMeshMissing: - return "NavMeshMissing"; - } - return "Unknown"; -} - -static std::string FormatVec3(Vec3 const &v) { - std::ostringstream ss; - ss << "(" << v.x << ", " << v.y << ", " << v.z << ")"; - return ss.str(); -} - -static constexpr float kMmapGridSize = 533.3333f; -static constexpr float kMmapNavMeshOrigin = -17066.66656f; - -static bool IsMmapSubcommand(std::string const &token, char const *name) { - return AsciiEqualsLower(token, name); -} - -static std::pair ComputeMmapGridTile(float x, float y) { - int32_t gx = 32 - static_cast(x / kMmapGridSize); - int32_t gy = 32 - static_cast(y / kMmapGridSize); - return {gx, gy}; -} - -static std::pair ComputeMmapNavTile(float x, float y) { - int32_t tileX = static_cast(std::floor((x - kMmapNavMeshOrigin) / - kMmapGridSize)); - int32_t tileY = static_cast(std::floor((y - kMmapNavMeshOrigin) / - kMmapGridSize)); - return {tileX, tileY}; -} - -static bool HandleMmapLoadedTiles(std::shared_ptr const &session, - IMapCollisionQueries const &collision, - uint32_t mapId) { - if (!collision.IsNavMeshDataAvailable(mapId)) { - session->SendNotification("NavMesh not loaded for current map."); - return true; - } - - session->SendNotification("mmap loadedtiles:"); - auto tiles = collision.GetLoadedTiles(mapId); - if (tiles.empty()) { - session->SendNotification(" (none)"); - return true; - } - - for (auto const &[tileX, tileY] : tiles) { - session->SendNotification("[" + (tileX < 10 ? std::string("0") : std::string()) + - std::to_string(tileX) + ", " + - (tileY < 10 ? std::string("0") : std::string()) + - std::to_string(tileY) + "]"); - } - return true; -} - -static bool HandleMmapLoc(std::shared_ptr const &session, - IMapCollisionQueries const &collision, - uint32_t mapId) { - auto const &pos = session->GetPosition(); - session->SendNotification("mmap tileloc:"); - - if (!collision.IsNavMeshDataAvailable(mapId)) { - session->SendNotification("NavMesh not loaded for current map."); - return true; - } - - auto const [tileX, tileY] = ComputeMmapNavTile(pos.x, pos.y); - session->SendNotification(std::to_string(mapId) + "_" + - std::to_string(tileX) + "_" + - std::to_string(tileY) + ".mmtile"); - session->SendNotification("tileloc [" + - (tileX < 10 ? std::string("0") : std::string()) + - std::to_string(tileX) + ", " + - (tileY < 10 ? std::string("0") : std::string()) + - std::to_string(tileY) + "]"); - - auto const [gridX, gridY] = ComputeMmapGridTile(pos.x, pos.y); - session->SendNotification("legacy [" + std::to_string(gridY) + ", " + - std::to_string(gridX) + "]"); - - session->SendNotification("Calc [" + - (tileX < 10 ? std::string("0") : std::string()) + - std::to_string(tileX) + ", " + - (tileY < 10 ? std::string("0") : std::string()) + - std::to_string(tileY) + "]"); - - auto tiles = collision.GetLoadedTiles(mapId); - bool const loaded = std::find(tiles.begin(), tiles.end(), std::make_pair( - static_cast(tileX), - static_cast(tileY))) != - tiles.end(); - if (loaded) - session->SendNotification("Dt [" + - (tileX < 10 ? std::string("0") : std::string()) + - std::to_string(tileX) + "," + - (tileY < 10 ? std::string("0") : std::string()) + - std::to_string(tileY) + "]"); - else - session->SendNotification("Dt [??,??] (no tile loaded)"); - - return true; -} - -static bool HandleMmapStats(std::shared_ptr const &session, - IMapCollisionQueries const &collision, - uint32_t mapId) { - session->SendNotification("mmap stats:"); - session->SendNotification(std::string(" global mmap pathfinding is ") + - (collision.IsNavMeshDataAvailable(mapId) ? "enabled" - : "disabled")); - session->SendNotification(" " + std::to_string(collision.GetLoadedMapCount()) + - " maps loaded with " + - std::to_string(collision.GetLoadedTileCount()) + - " tiles overall"); - session->SendNotification("Navmesh stats:"); - session->SendNotification(" " + std::to_string(collision.GetLoadedTiles(mapId).size()) + - " tiles loaded"); - return true; -} - -static bool HandleMmapTestArea(std::shared_ptr const &session, - IMapCollisionQueries const &collision, - std::shared_ptr const &map, - uint32_t mapId) { - if (!map) { - session->SendNotification("MMAP: current map is not available."); - return true; - } - - float radius = 40.0f; - float const cellRadius = 1.0f; - float const playerX = session->GetPosition().x; - float const playerY = session->GetPosition().y; - float const playerZ = session->GetPosition().z; - - uint32_t creatureCount = 0; - uint32_t pathCount = 0; - auto const started = std::chrono::steady_clock::now(); - - map->ForEachCreatureNear(playerX, playerY, static_cast(cellRadius), - [&](std::shared_ptr const &creature) { - ++creatureCount; - FindPathRequest req; - req.mapId = mapId; - req.startX = creature->GetX(); - req.startY = creature->GetY(); - req.startZ = creature->GetZ(); - req.endX = playerX; - req.endY = playerY; - req.endZ = playerZ; - req.smoothPath = true; - req.allowPartialPath = true; - if (collision.FindPath(req).status != FindPathStatus::NavMeshMissing) - ++pathCount; - }); - - auto const elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - started) - .count(); - - if (creatureCount != 0) { - session->SendNotification("Found " + std::to_string(creatureCount) + - " Creatures."); - session->SendNotification("Generated " + std::to_string(pathCount) + - " paths in " + std::to_string(elapsed) + " ms"); - } else { - session->SendNotification("No creatures in " + std::to_string(radius) + - " yard range."); - } - return true; -} - -static std::shared_ptr ResolveMmapChaseCreature( - std::shared_ptr const &session, - std::shared_ptr const &map) { - if (!map) - return nullptr; - - uint64_t const targetGuid = session->GetClientSelectionGuid(); - if (targetGuid != 0) { - if (auto selected = map->TryGetCreature(targetGuid)) - return selected; - } - - MovementInfo const &playerPos = session->GetPosition(); - std::shared_ptr nearest; - float nearestDistSq = std::numeric_limits::max(); - constexpr int kSearchCellRadius = 2; - - map->ForEachCreatureNear( - playerPos.x, playerPos.y, kSearchCellRadius, - [&](std::shared_ptr const &creature) { - float const dx = creature->GetX() - playerPos.x; - float const dy = creature->GetY() - playerPos.y; - float const dz = creature->GetZ() - playerPos.z; - float const distSq = dx * dx + dy * dy + dz * dz; - if (distSq < nearestDistSq) { - nearestDistSq = distSq; - nearest = creature; - } - }); - - return nearest; -} - -static void SendMmapMarkerCreate(std::shared_ptr const &session, - uint32_t mapId, uint64_t markerGuid, - Vec3 const &pos, uint32_t entry, - uint32_t displayId, - uint32_t factionTemplate) { - auto marker = std::make_shared(markerGuid, entry, displayId, 100u, 1u, - factionTemplate); - marker->SetPosition(MovementInfo{.x = pos.x, .y = pos.y, .z = pos.z, .orientation = 0.0f}); - marker->SetCombatStats(BuildCreatureCombatStats(1u, 1u)); - WorldService::Instance().AddCreatureToMap(mapId, std::move(marker)); - - MovementInfo move{}; - move.x = pos.x; - move.y = pos.y; - move.z = pos.z; - move.orientation = 0.0f; - - UpdateData update(static_cast(mapId)); - update.AddCreateObject( - markerGuid, TYPEID_UNIT, move, - WorldSessionObjectUpdate::BuildMinimalNpcUnitCreateFields( - markerGuid, entry, displayId, 1u, 1u, 1u, 0u, factionTemplate)); - WorldPacket pkt(SMSG_UPDATE_OBJECT); - update.Build(pkt); - session->SendPacket(pkt); -} - -static void SendMmapMarkerDespawn(std::shared_ptr const &session, - uint32_t mapId, uint64_t markerGuid) { - WorldService::Instance().RemoveCreatureFromMap(mapId, markerGuid); - // The background sweep may run when the player is offline: still remove the - // creature from the map, but only push the out-of-range packet if a session is - // there to receive it. - if (!session) - return; - UpdateData update(static_cast(mapId)); - update.AddOutOfRangeObjects({markerGuid}); - WorldPacket pkt(SMSG_UPDATE_OBJECT); - update.Build(pkt); - session->SendPacket(pkt); -} - static bool ParseRestartDelayToken(std::string const &token, std::chrono::seconds &out) { if (token.size() < 2) @@ -605,7 +307,7 @@ void CommandService::LoadCommandsFromDb() { // Build handler map (name → lambda capturing this) std::unordered_map handlers; handlers["gps"] = [this](auto s, auto a, auto o) { return HandleGps(s, a, o); }; - handlers["mmap"] = [this](auto s, auto a, auto o) { return HandleMmap(s, a, o); }; + handlers["mmap"] = [this](auto s, auto a, auto o) { return _mmapCommands.Handle(s, a, o, _onlineCharacters.get()); }; handlers["tele"] = [this](auto s, auto a, auto o) { return HandleTele(s, a, o); }; handlers["help"] = [this](auto s, auto a, auto o) { return HandleHelp(s, a, o); }; handlers["commands"] = [this](auto s, auto a, auto o) { return HandleHelp(s, a, o); }; @@ -675,12 +377,7 @@ void CommandService::LoadCommandsFromDb() { : ConsoleArgLayout::SameAsInGame; }; - // Load from DB first — only commands with handlers are registered. La tabla - // `firelands_commands` es la fuente autoritativa del permiso de EJECUCION (su - // columna required_permission_mask, donde 0 = cualquiera). Asi coincide con lo - // que filtra `.help`, que lee la misma tabla -> una sola fuente de verdad. El - // mapa hardcodeado permFor() solo queda como red de seguridad para comandos sin - // fila en la tabla (DB caida / tabla vacia). + if (_commandDefRepo) { auto const defs = _commandDefRepo->LoadAll(); for (auto const &def : defs) { @@ -694,10 +391,6 @@ void CommandService::LoadCommandsFromDb() { _commands[def.name] = std::move(entry); } } - - // Fallback: register any handler not yet in _commands (no DB row, but handler - // exists). Aqui SI usamos permFor() porque no hay fila de tabla de donde sacar - // el permiso, y un comando staff nunca debe quedar publico por accidente. for (auto &[name, handler] : handlers) { if (_commands.count(name) > 0) continue; @@ -709,10 +402,6 @@ void CommandService::LoadCommandsFromDb() { } } -void CommandService::RegisterCommand(const std::string &name, CommandEntry entry) { - _commands[name] = std::move(entry); -} - bool CommandService::IsCommand(const std::string &message) const { return !message.empty() && message[0] == '.'; } @@ -831,270 +520,6 @@ bool CommandService::HandleGps(std::shared_ptr session, return true; } -bool CommandService::HandleMmap(std::shared_ptr session, - const std::vector &args, - PrivilegeOrigin origin) { - (void)origin; - auto collision = WorldService::Instance().GetCollisionQueries(); - if (!collision) { - LOG_MMAP_ERROR("[MMAP] collision service is not configured."); - session->SendNotification("MMAP: collision service is not configured."); - return true; - } - - MovementInfo const &playerPos = session->GetPosition(); - uint32_t mapId = session->GetMapId(); - uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); - auto map = WorldService::Instance().GetMap(mapId); - - LOG_MMAP_DEBUG("[MMAP] request: playerGuid={} mapId={} args={}", playerGuid, mapId, - args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); - - if (!args.empty()) { - if (IsMmapSubcommand(args[0], "loadedtiles")) - return HandleMmapLoadedTiles(session, *collision, mapId); - if (IsMmapSubcommand(args[0], "loc")) - return HandleMmapLoc(session, *collision, mapId); - if (IsMmapSubcommand(args[0], "stats")) - return HandleMmapStats(session, *collision, mapId); - if (IsMmapSubcommand(args[0], "testarea")) - return HandleMmapTestArea(session, *collision, map, mapId); - } - - // Auto-remove expired markers (older than 9s). The background sweep in - // PollScheduledRestart does this on time without another .mmap call; this keeps - // it prompt on the next invocation too. - SweepExpiredMmapMarkers(); - - // .mmap clear — remove visual markers - if (!args.empty() && args[0] == "clear") { - LOG_MMAP_DEBUG("[MMAP] clear: playerGuid={} mapId={}", playerGuid, mapId); - ClearMmapMarkers(session, playerGuid, mapId); - session->SendNotification("[MMAP] visual markers removed."); - return true; - } - - std::vector pathArgs = args; - bool const chasePath = - !pathArgs.empty() && IsMmapSubcommand(pathArgs[0], "chase"); - if (!pathArgs.empty() && - (IsMmapSubcommand(pathArgs[0], "path") || - IsMmapSubcommand(pathArgs[0], "chase"))) - pathArgs.erase(pathArgs.begin()); - - // Determine start/end positions for pathfinding - Vec3 startPos{playerPos.x, playerPos.y, playerPos.z}; - Vec3 endPos{playerPos.x, playerPos.y, playerPos.z}; - bool checkPath = false; - std::string pathLabel; - - try { - if (!pathArgs.empty() || chasePath || - (!args.empty() && IsMmapSubcommand(args[0], "path"))) { - bool const numericArgs = pathArgs.size() >= 3 && - IsAllDigitAscii(pathArgs[0].empty() ? std::string() : pathArgs[0]); - if (pathArgs.size() < 3) { - if (!IsMmapSubcommand(args[0], "path") && !chasePath) { - LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", - playerGuid, mapId, pathArgs.size()); - session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap chase | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); - return false; - } - } - if (pathArgs.size() >= 3 && numericArgs) { - endPos.x = std::stof(pathArgs[0]); - endPos.y = std::stof(pathArgs[1]); - endPos.z = std::stof(pathArgs[2]); - if (pathArgs.size() > 3) - mapId = static_cast(std::stoul(pathArgs[3])); - pathLabel = "you -> " + FormatVec3(endPos); - checkPath = true; - } else if (IsMmapSubcommand(args[0], "path") || chasePath || - pathArgs.empty()) { - auto creature = chasePath ? ResolveMmapChaseCreature(session, map) - : nullptr; - if (!creature) { - uint64_t const targetGuid = session->GetClientSelectionGuid(); - if (targetGuid != 0 && map) - creature = map->TryGetCreature(targetGuid); - } - if (creature) { - MovementInfo const &crPos = creature->GetPosition(); - startPos = Vec3{crPos.x, crPos.y, crPos.z}; - endPos = Vec3{playerPos.x, playerPos.y, playerPos.z}; - pathLabel = std::string("creature(") + - std::to_string(creature->GetEntry()) + ") -> you"; - checkPath = true; - LOG_MMAP_DEBUG("MMAP chase creature resolved: playerGuid={} entry={} mapId={} start=({}, {}, {})", - playerGuid, creature->GetEntry(), mapId, crPos.x, crPos.y, - crPos.z); - } - if (!checkPath) { - session->SendNotification( - chasePath ? "MMAP: no nearby creature found for chase path." - : "MMAP: targeted object is not a creature."); - } - } - if (!checkPath && pathArgs.size() >= 3 && !numericArgs) { - LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", - playerGuid, mapId, pathArgs.size()); - session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap chase | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); - return false; - } - } - } catch (std::exception const &) { - LOG_MMAP_ERROR("MMAP invalid coordinates parse error: playerGuid={} mapId={} args={}", - playerGuid, mapId, - pathArgs.empty() ? std::string("") : JoinArgs(pathArgs.begin(), pathArgs.end())); - session->SendNotification("MMAP: invalid coordinates."); - return false; - } - - bool const available = collision->IsNavMeshDataAvailable(mapId); - LOG_MMAP_DEBUG("MMAP navmesh availability: mapId={} available={}", mapId, available); - float const height = collision->GetHeight(mapId, playerPos.x, playerPos.y, playerPos.z); - std::ostringstream head; - head << "MMAP map=" << mapId << " navmesh=" << (available ? "loaded" : "missing") - << " player=" << FormatVec3(Vec3{playerPos.x, playerPos.y, playerPos.z}) - << " height=" << height; - session->SendNotification(head.str()); - - if (!checkPath) - return true; - - FindPathRequest req; - req.mapId = mapId; - req.startX = startPos.x; - req.startY = startPos.y; - req.startZ = startPos.z; - req.endX = endPos.x; - req.endY = endPos.y; - req.endZ = endPos.z; - req.smoothPath = true; - req.allowPartialPath = true; - - FindPathResult result = collision->FindPath(req); - LOG_MMAP_DEBUG("MMAP path result: mapId={} status={} waypoints={}", mapId, - FindPathStatusName(result.status), result.waypoints.size()); - std::ostringstream path; - path << "MMAP path " << FormatVec3(startPos) << " -> " - << FormatVec3(endPos) << " (" << pathLabel << ")" - << " status=" << FindPathStatusName(result.status) - << " waypoints=" << result.waypoints.size(); - session->SendNotification(path.str()); - - constexpr size_t kMaxPrintedWaypoints = 8; - for (size_t i = 0; i < result.waypoints.size() && i < kMaxPrintedWaypoints; ++i) { - session->SendNotification(" wp[" + std::to_string(i) + "] " + - FormatVec3(result.waypoints[i])); - } - if (result.waypoints.size() > kMaxPrintedWaypoints) { - session->SendNotification(" ... " + - std::to_string(result.waypoints.size() - - kMaxPrintedWaypoints) + - " more waypoint(s)"); - } - - // Spawn visual markers at each waypoint (auto-despawn after 9s) - ClearMmapMarkers(session, playerGuid, mapId); - - if (!result.waypoints.empty()) { - constexpr uint32_t kMarkerEntry = 1u; - uint32_t kMarkerDisplayId = 15688u; - if (auto const repo = WorldService::Instance().GetNpcTemplateSearch()) { - if (auto const tpl = repo->TryGetByEntry(kMarkerEntry)) { - kMarkerDisplayId = ResolveCreatureDisplayId( - 0, tpl->displayIds[0], tpl->displayIds[1], tpl->displayIds[2], - tpl->displayIds[3]); - } - } - // Float the marker well above a player model so it's visible even when - // start ≈ end and the waypoint lands inside the caster or the target. - constexpr float kMarkerVisualLiftYards = 3.0f; - auto const now = std::chrono::steady_clock::now(); - std::vector> markers; - markers.reserve(result.waypoints.size()); - - for (size_t i = 0; i < result.waypoints.size(); ++i) { - auto const &wp = result.waypoints[i]; - // Re-ground each waypoint Z before placing the marker. Detour's - // findStraightPath returns Z from the navmesh corridor, and on - // corrupted/ghost mmtiles that Z can be tens of yards above the real - // floor. Anchoring to the live GetHeight (which goes through the - // floor-under-(x,y) query) keeps markers visible on the ground. - float const groundZ = - collision->GetHeight(mapId, wp.x, wp.y, wp.z); - float const z = groundZ + kMarkerVisualLiftYards; - uint64_t const markerGuid = AllocateMmapMarkerGuid(kMarkerEntry); - SendMmapMarkerCreate(session, mapId, markerGuid, - Vec3{wp.x, wp.y, z}, kMarkerEntry, - kMarkerDisplayId, Creature::kDefaultFactionTemplate); - LOG_MMAP_DEBUG( - "MMAP marker spawn: mapId={} guid={} wpIndex={} wp=({}, {}, {}) " - "groundZ={} placedZ={}", - mapId, markerGuid, i, wp.x, wp.y, wp.z, groundZ, z); - markers.emplace_back(markerGuid, now); - } - { - std::lock_guard lock(_mmapMarkersMutex); - auto &set = _mmapMarkers[playerGuid]; - set.mapId = mapId; - set.markers = std::move(markers); - } - session->SendNotification("MMAP: " + - std::to_string(result.waypoints.size()) + - " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); - } else { - session->SendNotification( - "MMAP: no waypoints to mark (start and end resolved to the same " - "navmesh point)."); - } - return true; -} - -void CommandService::ClearMmapMarkers(std::shared_ptr session, - uint64_t playerGuid, uint32_t mapId) { - (void)mapId; - std::lock_guard lock(_mmapMarkersMutex); - auto it = _mmapMarkers.find(playerGuid); - if (it == _mmapMarkers.end()) - return; - - // Remove all markers (despawn on the map they were spawned on). - for (auto &[guid, spawnTime] : it->second.markers) { - (void)spawnTime; - SendMmapMarkerDespawn(session, it->second.mapId, guid); - } - _mmapMarkers.erase(it); -} - -void CommandService::SweepExpiredMmapMarkers() { - std::lock_guard lock(_mmapMarkersMutex); - if (_mmapMarkers.empty()) - return; - auto const now = std::chrono::steady_clock::now(); - for (auto it = _mmapMarkers.begin(); it != _mmapMarkers.end();) { - std::shared_ptr const session = - _onlineCharacters ? _onlineCharacters->TryResolveByObjectGuid(it->first) - : nullptr; - auto &set = it->second; - set.markers.erase( - std::remove_if(set.markers.begin(), set.markers.end(), - [&](auto const &p) { - if (now - p.second > std::chrono::seconds(9)) { - SendMmapMarkerDespawn(session, set.mapId, p.first); - return true; - } - return false; - }), - set.markers.end()); - if (set.markers.empty()) - it = _mmapMarkers.erase(it); - else - ++it; - } -} - bool CommandService::HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin) { @@ -2778,7 +2203,7 @@ bool CommandService::HandleEmail(std::shared_ptr session, void CommandService::PollScheduledRestart() { // Despawn expired .mmap path markers on time (runs on the main loop tick, so it // happens at ~9s even if the player never invokes .mmap again). - SweepExpiredMmapMarkers(); + _mmapCommands.SweepExpiredMmapMarkers(_onlineCharacters.get()); if (!_restartDeadline) return; auto const now = std::chrono::steady_clock::now(); diff --git a/src/application/services/CommandService.h b/src/application/services/CommandService.h index c11497f..96286db 100644 --- a/src/application/services/CommandService.h +++ b/src/application/services/CommandService.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -71,17 +72,8 @@ class CommandService : public ICommandService { ConsoleArgLayout consoleLayout = ConsoleArgLayout::SameAsInGame; }; - void RegisterCommand(const std::string &name, CommandEntry entry); - bool HandleGps(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); - bool HandleMmap(std::shared_ptr session, - const std::vector &args, PrivilegeOrigin origin); - void ClearMmapMarkers(std::shared_ptr session, - uint64_t playerGuid, uint32_t mapId); - /// Despawn .mmap path markers older than 9s. Called from PollScheduledRestart - /// (main loop) so markers vanish on time even without another .mmap call. - void SweepExpiredMmapMarkers(); bool HandleTele(std::shared_ptr session, const std::vector &args, PrivilegeOrigin origin); bool HandleHelp(std::shared_ptr session, @@ -152,15 +144,9 @@ class CommandService : public ICommandService { std::shared_ptr _rbacRepo; std::shared_ptr _commandDefRepo; std::map _commands; - /// .mmap path visual markers per player (key = player object guid). mapId is - /// stored so the background sweep can despawn them without a live session map. - struct MmapMarkerSet { - uint32_t mapId = 0; - std::vector> - markers; - }; - std::mutex _mmapMarkersMutex; - std::unordered_map _mmapMarkers; + /// `.mmap` GM debug command (navmesh queries + visual path markers), extracted + /// so its marker state and packet plumbing live with the feature. + MmapDebugCommands _mmapCommands; std::function _shutdownRequestHandler; std::optional _restartDeadline; diff --git a/src/application/services/CommandTextUtils.h b/src/application/services/CommandTextUtils.h new file mode 100644 index 0000000..a70b536 --- /dev/null +++ b/src/application/services/CommandTextUtils.h @@ -0,0 +1,50 @@ +#ifndef FIRELANDS_APPLICATION_SERVICES_COMMAND_TEXT_UTILS_H +#define FIRELANDS_APPLICATION_SERVICES_COMMAND_TEXT_UTILS_H + +#include +#include +#include +#include + +namespace Firelands { + +/// Join argument tokens with single spaces. Shared by several command handlers. +inline std::string JoinArgs(std::vector::const_iterator begin, + std::vector::const_iterator end) { + std::string out; + for (auto it = begin; it != end; ++it) { + if (!out.empty()) + out += ' '; + out += *it; + } + return out; +} + +/// Case-insensitive ASCII compare of `a` against the null-terminated `b`. +inline bool AsciiEqualsLower(std::string const &a, char const *b) { + size_t const n = std::strlen(b); + if (a.size() != n) + return false; + for (size_t i = 0; i < n; ++i) { + if (std::tolower(static_cast(a[i])) != + static_cast(b[i])) { + return false; + } + } + return true; +} + +/// True when `s` is non-empty and every character is an ASCII digit. +inline bool IsAllDigitAscii(std::string const &s) { + if (s.empty()) + return false; + for (unsigned char c : s) { + if (!std::isdigit(c)) + return false; + } + return true; +} + +} // namespace Firelands + +#endif // FIRELANDS_APPLICATION_SERVICES_COMMAND_TEXT_UTILS_H diff --git a/src/application/services/MmapDebugCommands.cpp b/src/application/services/MmapDebugCommands.cpp new file mode 100644 index 0000000..c03127c --- /dev/null +++ b/src/application/services/MmapDebugCommands.cpp @@ -0,0 +1,554 @@ +#include "MmapDebugCommands.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Firelands { + +namespace { + +constexpr float kMmapGridSize = 533.3333f; +constexpr float kMmapNavMeshOrigin = -17066.66656f; + +std::atomic g_nextMmapMarkerLow{0x71000000u}; + +uint64_t AllocateMmapMarkerGuid(uint32_t creatureEntry) { + uint32_t const low = + g_nextMmapMarkerLow.fetch_add(1u, std::memory_order_relaxed); + return MakeCreatureObjectGuid(creatureEntry, low); +} + +std::string FormatVec3(Vec3 const &v) { + std::ostringstream ss; + ss << "(" << v.x << ", " << v.y << ", " << v.z << ")"; + return ss.str(); +} + +bool IsMmapSubcommand(std::string const &token, char const *name) { + return AsciiEqualsLower(token, name); +} + +std::pair ComputeMmapGridTile(float x, float y) { + int32_t gx = 32 - static_cast(x / kMmapGridSize); + int32_t gy = 32 - static_cast(y / kMmapGridSize); + return {gx, gy}; +} + +std::pair ComputeMmapNavTile(float x, float y) { + int32_t tileX = static_cast(std::floor((x - kMmapNavMeshOrigin) / + kMmapGridSize)); + int32_t tileY = static_cast(std::floor((y - kMmapNavMeshOrigin) / + kMmapGridSize)); + return {tileX, tileY}; +} + +bool HandleMmapLoadedTiles(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + uint32_t mapId) { + if (!collision.IsNavMeshDataAvailable(mapId)) { + session->SendNotification("NavMesh not loaded for current map."); + return true; + } + + session->SendNotification("mmap loadedtiles:"); + auto tiles = collision.GetLoadedTiles(mapId); + if (tiles.empty()) { + session->SendNotification(" (none)"); + return true; + } + + for (auto const &[tileX, tileY] : tiles) { + session->SendNotification("[" + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + ", " + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + } + return true; +} + +bool HandleMmapLoc(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + uint32_t mapId) { + auto const &pos = session->GetPosition(); + session->SendNotification("mmap tileloc:"); + + if (!collision.IsNavMeshDataAvailable(mapId)) { + session->SendNotification("NavMesh not loaded for current map."); + return true; + } + + auto const [tileX, tileY] = ComputeMmapNavTile(pos.x, pos.y); + session->SendNotification(std::to_string(mapId) + "_" + + std::to_string(tileX) + "_" + + std::to_string(tileY) + ".mmtile"); + session->SendNotification("tileloc [" + + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + ", " + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + + auto const [gridX, gridY] = ComputeMmapGridTile(pos.x, pos.y); + session->SendNotification("legacy [" + std::to_string(gridY) + ", " + + std::to_string(gridX) + "]"); + + session->SendNotification("Calc [" + + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + ", " + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + + auto tiles = collision.GetLoadedTiles(mapId); + bool const loaded = std::find(tiles.begin(), tiles.end(), std::make_pair( + static_cast(tileX), + static_cast(tileY))) != + tiles.end(); + if (loaded) + session->SendNotification("Dt [" + + (tileX < 10 ? std::string("0") : std::string()) + + std::to_string(tileX) + "," + + (tileY < 10 ? std::string("0") : std::string()) + + std::to_string(tileY) + "]"); + else + session->SendNotification("Dt [??,??] (no tile loaded)"); + + return true; +} + +bool HandleMmapStats(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + uint32_t mapId) { + session->SendNotification("mmap stats:"); + session->SendNotification(std::string(" global mmap pathfinding is ") + + (collision.IsNavMeshDataAvailable(mapId) ? "enabled" + : "disabled")); + session->SendNotification(" " + std::to_string(collision.GetLoadedMapCount()) + + " maps loaded with " + + std::to_string(collision.GetLoadedTileCount()) + + " tiles overall"); + session->SendNotification("Navmesh stats:"); + session->SendNotification(" " + std::to_string(collision.GetLoadedTiles(mapId).size()) + + " tiles loaded"); + return true; +} + +bool HandleMmapTestArea(std::shared_ptr const &session, + IMapCollisionQueries const &collision, + std::shared_ptr const &map, + uint32_t mapId) { + if (!map) { + session->SendNotification("MMAP: current map is not available."); + return true; + } + + float radius = 40.0f; + float const cellRadius = 1.0f; + float const playerX = session->GetPosition().x; + float const playerY = session->GetPosition().y; + float const playerZ = session->GetPosition().z; + + uint32_t creatureCount = 0; + uint32_t pathCount = 0; + auto const started = std::chrono::steady_clock::now(); + + map->ForEachCreatureNear(playerX, playerY, static_cast(cellRadius), + [&](std::shared_ptr const &creature) { + ++creatureCount; + FindPathRequest req; + req.mapId = mapId; + req.startX = creature->GetX(); + req.startY = creature->GetY(); + req.startZ = creature->GetZ(); + req.endX = playerX; + req.endY = playerY; + req.endZ = playerZ; + req.smoothPath = true; + req.allowPartialPath = true; + if (collision.FindPath(req).status != FindPathStatus::NavMeshMissing) + ++pathCount; + }); + + auto const elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started) + .count(); + + if (creatureCount != 0) { + session->SendNotification("Found " + std::to_string(creatureCount) + + " Creatures."); + session->SendNotification("Generated " + std::to_string(pathCount) + + " paths in " + std::to_string(elapsed) + " ms"); + } else { + session->SendNotification("No creatures in " + std::to_string(radius) + + " yard range."); + } + return true; +} + +std::shared_ptr ResolveMmapChaseCreature( + std::shared_ptr const &session, + std::shared_ptr const &map) { + if (!map) + return nullptr; + + uint64_t const targetGuid = session->GetClientSelectionGuid(); + if (targetGuid != 0) { + if (auto selected = map->TryGetCreature(targetGuid)) + return selected; + } + + MovementInfo const &playerPos = session->GetPosition(); + std::shared_ptr nearest; + float nearestDistSq = std::numeric_limits::max(); + constexpr int kSearchCellRadius = 2; + + map->ForEachCreatureNear( + playerPos.x, playerPos.y, kSearchCellRadius, + [&](std::shared_ptr const &creature) { + float const dx = creature->GetX() - playerPos.x; + float const dy = creature->GetY() - playerPos.y; + float const dz = creature->GetZ() - playerPos.z; + float const distSq = dx * dx + dy * dy + dz * dz; + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearest = creature; + } + }); + + return nearest; +} + +void SendMmapMarkerCreate(std::shared_ptr const &session, + uint32_t mapId, uint64_t markerGuid, + Vec3 const &pos, uint32_t entry, + uint32_t displayId, + uint32_t factionTemplate) { + auto marker = std::make_shared(markerGuid, entry, displayId, 100u, 1u, + factionTemplate); + marker->SetPosition(MovementInfo{.x = pos.x, .y = pos.y, .z = pos.z, .orientation = 0.0f}); + marker->SetCombatStats(BuildCreatureCombatStats(1u, 1u)); + WorldService::Instance().AddCreatureToMap(mapId, std::move(marker)); + + MovementInfo move{}; + move.x = pos.x; + move.y = pos.y; + move.z = pos.z; + move.orientation = 0.0f; + + UpdateData update(static_cast(mapId)); + update.AddCreateObject( + markerGuid, TYPEID_UNIT, move, + WorldSessionObjectUpdate::BuildMinimalNpcUnitCreateFields( + markerGuid, entry, displayId, 1u, 1u, 1u, 0u, factionTemplate)); + WorldPacket pkt(SMSG_UPDATE_OBJECT); + update.Build(pkt); + session->SendPacket(pkt); +} + +void SendMmapMarkerDespawn(std::shared_ptr const &session, + uint32_t mapId, uint64_t markerGuid) { + WorldService::Instance().RemoveCreatureFromMap(mapId, markerGuid); + // The background sweep may run when the player is offline: still remove the + // creature from the map, but only push the out-of-range packet if a session is + // there to receive it. + if (!session) + return; + UpdateData update(static_cast(mapId)); + update.AddOutOfRangeObjects({markerGuid}); + WorldPacket pkt(SMSG_UPDATE_OBJECT); + update.Build(pkt); + session->SendPacket(pkt); +} + +} // namespace + +bool MmapDebugCommands::Handle(std::shared_ptr session, + const std::vector &args, + PrivilegeOrigin origin, + OnlineCharacterSessionRegistry *online) { + (void)origin; + auto collision = WorldService::Instance().GetCollisionQueries(); + if (!collision) { + LOG_MMAP_ERROR("[MMAP] collision service is not configured."); + session->SendNotification("MMAP: collision service is not configured."); + return true; + } + + MovementInfo const &playerPos = session->GetPosition(); + uint32_t mapId = session->GetMapId(); + uint64_t const playerGuid = session->GetActiveCharacterObjectGuid(); + auto map = WorldService::Instance().GetMap(mapId); + + LOG_MMAP_DEBUG("[MMAP] request: playerGuid={} mapId={} args={}", playerGuid, mapId, + args.empty() ? std::string("") : JoinArgs(args.begin(), args.end())); + + if (!args.empty()) { + if (IsMmapSubcommand(args[0], "loadedtiles")) + return HandleMmapLoadedTiles(session, *collision, mapId); + if (IsMmapSubcommand(args[0], "loc")) + return HandleMmapLoc(session, *collision, mapId); + if (IsMmapSubcommand(args[0], "stats")) + return HandleMmapStats(session, *collision, mapId); + if (IsMmapSubcommand(args[0], "testarea")) + return HandleMmapTestArea(session, *collision, map, mapId); + } + + // Auto-remove expired markers (older than 9s). The background sweep in + // PollScheduledRestart does this on time without another .mmap call; this keeps + // it prompt on the next invocation too. + SweepExpiredMmapMarkers(online); + + // .mmap clear — remove visual markers + if (!args.empty() && args[0] == "clear") { + LOG_MMAP_DEBUG("[MMAP] clear: playerGuid={} mapId={}", playerGuid, mapId); + ClearMmapMarkers(session, playerGuid, mapId); + session->SendNotification("[MMAP] visual markers removed."); + return true; + } + + std::vector pathArgs = args; + bool const chasePath = + !pathArgs.empty() && IsMmapSubcommand(pathArgs[0], "chase"); + if (!pathArgs.empty() && + (IsMmapSubcommand(pathArgs[0], "path") || + IsMmapSubcommand(pathArgs[0], "chase"))) + pathArgs.erase(pathArgs.begin()); + + // Determine start/end positions for pathfinding + Vec3 startPos{playerPos.x, playerPos.y, playerPos.z}; + Vec3 endPos{playerPos.x, playerPos.y, playerPos.z}; + bool checkPath = false; + std::string pathLabel; + + try { + if (!pathArgs.empty() || chasePath || + (!args.empty() && IsMmapSubcommand(args[0], "path"))) { + bool const numericArgs = pathArgs.size() >= 3 && + IsAllDigitAscii(pathArgs[0].empty() ? std::string() : pathArgs[0]); + if (pathArgs.size() < 3) { + if (!IsMmapSubcommand(args[0], "path") && !chasePath) { + LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + playerGuid, mapId, pathArgs.size()); + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap chase | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); + return false; + } + } + if (pathArgs.size() >= 3 && numericArgs) { + endPos.x = std::stof(pathArgs[0]); + endPos.y = std::stof(pathArgs[1]); + endPos.z = std::stof(pathArgs[2]); + if (pathArgs.size() > 3) + mapId = static_cast(std::stoul(pathArgs[3])); + pathLabel = "you -> " + FormatVec3(endPos); + checkPath = true; + } else if (IsMmapSubcommand(args[0], "path") || chasePath || + pathArgs.empty()) { + auto creature = chasePath ? ResolveMmapChaseCreature(session, map) + : nullptr; + if (!creature) { + uint64_t const targetGuid = session->GetClientSelectionGuid(); + if (targetGuid != 0 && map) + creature = map->TryGetCreature(targetGuid); + } + if (creature) { + MovementInfo const &crPos = creature->GetPosition(); + startPos = Vec3{crPos.x, crPos.y, crPos.z}; + endPos = Vec3{playerPos.x, playerPos.y, playerPos.z}; + pathLabel = std::string("creature(") + + std::to_string(creature->GetEntry()) + ") -> you"; + checkPath = true; + LOG_MMAP_DEBUG("MMAP chase creature resolved: playerGuid={} entry={} mapId={} start=({}, {}, {})", + playerGuid, creature->GetEntry(), mapId, crPos.x, crPos.y, + crPos.z); + } + if (!checkPath) { + session->SendNotification( + chasePath ? "MMAP: no nearby creature found for chase path." + : "MMAP: targeted object is not a creature."); + } + } + if (!checkPath && pathArgs.size() >= 3 && !numericArgs) { + LOG_MMAP_WARN("MMAP invalid coordinates: playerGuid={} mapId={} argCount={}", + playerGuid, mapId, pathArgs.size()); + session->SendNotification("Usage: .mmap [x y z [mapId]] | .mmap path | .mmap chase | .mmap loc | .mmap stats | .mmap loadedtiles | .mmap testarea | .mmap clear"); + return false; + } + } + } catch (std::exception const &) { + LOG_MMAP_ERROR("MMAP invalid coordinates parse error: playerGuid={} mapId={} args={}", + playerGuid, mapId, + pathArgs.empty() ? std::string("") : JoinArgs(pathArgs.begin(), pathArgs.end())); + session->SendNotification("MMAP: invalid coordinates."); + return false; + } + + bool const available = collision->IsNavMeshDataAvailable(mapId); + LOG_MMAP_DEBUG("MMAP navmesh availability: mapId={} available={}", mapId, available); + float const height = collision->GetHeight(mapId, playerPos.x, playerPos.y, playerPos.z); + std::ostringstream head; + head << "MMAP map=" << mapId << " navmesh=" << (available ? "loaded" : "missing") + << " player=" << FormatVec3(Vec3{playerPos.x, playerPos.y, playerPos.z}) + << " height=" << height; + session->SendNotification(head.str()); + + if (!checkPath) + return true; + + FindPathRequest req; + req.mapId = mapId; + req.startX = startPos.x; + req.startY = startPos.y; + req.startZ = startPos.z; + req.endX = endPos.x; + req.endY = endPos.y; + req.endZ = endPos.z; + req.smoothPath = true; + req.allowPartialPath = true; + + FindPathResult result = collision->FindPath(req); + LOG_MMAP_DEBUG("MMAP path result: mapId={} status={} waypoints={}", mapId, + FindPathStatusName(result.status), result.waypoints.size()); + std::ostringstream path; + path << "MMAP path " << FormatVec3(startPos) << " -> " + << FormatVec3(endPos) << " (" << pathLabel << ")" + << " status=" << FindPathStatusName(result.status) + << " waypoints=" << result.waypoints.size(); + session->SendNotification(path.str()); + + constexpr size_t kMaxPrintedWaypoints = 8; + for (size_t i = 0; i < result.waypoints.size() && i < kMaxPrintedWaypoints; ++i) { + session->SendNotification(" wp[" + std::to_string(i) + "] " + + FormatVec3(result.waypoints[i])); + } + if (result.waypoints.size() > kMaxPrintedWaypoints) { + session->SendNotification(" ... " + + std::to_string(result.waypoints.size() - + kMaxPrintedWaypoints) + + " more waypoint(s)"); + } + + // Spawn visual markers at each waypoint (auto-despawn after 9s) + ClearMmapMarkers(session, playerGuid, mapId); + + if (!result.waypoints.empty()) { + constexpr uint32_t kMarkerEntry = 1u; + uint32_t kMarkerDisplayId = 15688u; + if (auto const repo = WorldService::Instance().GetNpcTemplateSearch()) { + if (auto const tpl = repo->TryGetByEntry(kMarkerEntry)) { + kMarkerDisplayId = ResolveCreatureDisplayId( + 0, tpl->displayIds[0], tpl->displayIds[1], tpl->displayIds[2], + tpl->displayIds[3]); + } + } + // Float the marker well above a player model so it's visible even when + // start ≈ end and the waypoint lands inside the caster or the target. + constexpr float kMarkerVisualLiftYards = 3.0f; + auto const now = std::chrono::steady_clock::now(); + std::vector> markers; + markers.reserve(result.waypoints.size()); + + for (size_t i = 0; i < result.waypoints.size(); ++i) { + auto const &wp = result.waypoints[i]; + // Re-ground each waypoint Z before placing the marker. Detour's + // findStraightPath returns Z from the navmesh corridor, and on + // corrupted/ghost mmtiles that Z can be tens of yards above the real + // floor. Anchoring to the live GetHeight (which goes through the + // floor-under-(x,y) query) keeps markers visible on the ground. + float const groundZ = + collision->GetHeight(mapId, wp.x, wp.y, wp.z); + float const z = groundZ + kMarkerVisualLiftYards; + uint64_t const markerGuid = AllocateMmapMarkerGuid(kMarkerEntry); + SendMmapMarkerCreate(session, mapId, markerGuid, + Vec3{wp.x, wp.y, z}, kMarkerEntry, + kMarkerDisplayId, Creature::kDefaultFactionTemplate); + LOG_MMAP_DEBUG( + "MMAP marker spawn: mapId={} guid={} wpIndex={} wp=({}, {}, {}) " + "groundZ={} placedZ={}", + mapId, markerGuid, i, wp.x, wp.y, wp.z, groundZ, z); + markers.emplace_back(markerGuid, now); + } + { + std::lock_guard lock(_mmapMarkersMutex); + auto &set = _mmapMarkers[playerGuid]; + set.mapId = mapId; + set.markers = std::move(markers); + } + session->SendNotification("MMAP: " + + std::to_string(result.waypoints.size()) + + " marker(s) spawned (despawn in 9s). |cffffffff.mmap clear|r to remove earlier."); + } else { + session->SendNotification( + "MMAP: no waypoints to mark (start and end resolved to the same " + "navmesh point)."); + } + return true; +} + +void MmapDebugCommands::ClearMmapMarkers(std::shared_ptr session, + uint64_t playerGuid, uint32_t mapId) { + (void)mapId; + std::lock_guard lock(_mmapMarkersMutex); + auto it = _mmapMarkers.find(playerGuid); + if (it == _mmapMarkers.end()) + return; + + // Remove all markers (despawn on the map they were spawned on). + for (auto &[guid, spawnTime] : it->second.markers) { + (void)spawnTime; + SendMmapMarkerDespawn(session, it->second.mapId, guid); + } + _mmapMarkers.erase(it); +} + +void MmapDebugCommands::SweepExpiredMmapMarkers( + OnlineCharacterSessionRegistry *online) { + std::lock_guard lock(_mmapMarkersMutex); + if (_mmapMarkers.empty()) + return; + auto const now = std::chrono::steady_clock::now(); + for (auto it = _mmapMarkers.begin(); it != _mmapMarkers.end();) { + std::shared_ptr const session = + online ? online->TryResolveByObjectGuid(it->first) : nullptr; + auto &set = it->second; + set.markers.erase( + std::remove_if(set.markers.begin(), set.markers.end(), + [&](auto const &p) { + if (now - p.second > std::chrono::seconds(9)) { + SendMmapMarkerDespawn(session, set.mapId, p.first); + return true; + } + return false; + }), + set.markers.end()); + if (set.markers.empty()) + it = _mmapMarkers.erase(it); + else + ++it; + } +} + +} // namespace Firelands diff --git a/src/application/services/MmapDebugCommands.h b/src/application/services/MmapDebugCommands.h new file mode 100644 index 0000000..5370c2d --- /dev/null +++ b/src/application/services/MmapDebugCommands.h @@ -0,0 +1,54 @@ +#ifndef FIRELANDS_APPLICATION_SERVICES_MMAP_DEBUG_COMMANDS_H +#define FIRELANDS_APPLICATION_SERVICES_MMAP_DEBUG_COMMANDS_H + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Firelands { + +class OnlineCharacterSessionRegistry; + +/// Handles the `.mmap` GM debug command: navmesh stats / tile inspection, +/// pathfinding queries, and the per-player visual path markers. Extracted from +/// CommandService so the marker state and packet plumbing live with the feature +/// instead of bloating the command router. +class MmapDebugCommands { +public: + /// Runs `.mmap `. Returns false on usage errors (mirrors the command + /// handler contract). `online` resolves marker owners during the prompt sweep. + bool Handle(std::shared_ptr session, + const std::vector &args, PrivilegeOrigin origin, + OnlineCharacterSessionRegistry *online); + + /// Despawn `.mmap` path markers older than 9s. Called from the main loop so + /// markers vanish on time even without another `.mmap` call. `online` resolves + /// the owning player's session to push the despawn packet (may be offline). + void SweepExpiredMmapMarkers(OnlineCharacterSessionRegistry *online); + +private: + void ClearMmapMarkers(std::shared_ptr session, + uint64_t playerGuid, uint32_t mapId); + + /// .mmap path visual markers per player (key = player object guid). mapId is + /// stored so the background sweep can despawn them without a live session map. + struct MmapMarkerSet { + uint32_t mapId = 0; + std::vector> + markers; + }; + std::mutex _mmapMarkersMutex; + std::unordered_map _mmapMarkers; +}; + +} // namespace Firelands + +#endif // FIRELANDS_APPLICATION_SERVICES_MMAP_DEBUG_COMMANDS_H From 46cd40afebb6116fb94768a45a4ed5c33ba77ed9 Mon Sep 17 00:00:00 2001 From: HkevinH Date: Thu, 4 Jun 2026 22:20:05 -0500 Subject: [PATCH 35/35] fix(logger): only create the mmap log when a path is configured The shared Logger unconditionally created an -mmaps.log (deriving a default path when none was set), so the auth server produced a stray mmaps log. Create the mmap sink only when WithMmapFile() sets a path; MmapXxx() falls back to the main logger when absent. --- src/shared/Logger.h | 57 +++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/shared/Logger.h b/src/shared/Logger.h index e1e08e8..aafe4dd 100644 --- a/src/shared/Logger.h +++ b/src/shared/Logger.h @@ -294,32 +294,32 @@ class Logger { template void MmapTrace(spdlog::format_string_t fmt, Args &&...args) { - mmapSpdlogger_->trace(fmt, std::forward(args)...); + MmapSink()->trace(fmt, std::forward(args)...); } template void MmapDebug(spdlog::format_string_t fmt, Args &&...args) { - mmapSpdlogger_->debug(fmt, std::forward(args)...); + MmapSink()->debug(fmt, std::forward(args)...); } template void MmapInfo(spdlog::format_string_t fmt, Args &&...args) { - mmapSpdlogger_->info(fmt, std::forward(args)...); + MmapSink()->info(fmt, std::forward(args)...); } template void MmapWarn(spdlog::format_string_t fmt, Args &&...args) { - mmapSpdlogger_->warn(fmt, std::forward(args)...); + MmapSink()->warn(fmt, std::forward(args)...); } template void MmapError(spdlog::format_string_t fmt, Args &&...args) { - mmapSpdlogger_->error(fmt, std::forward(args)...); + MmapSink()->error(fmt, std::forward(args)...); } template void MmapCritical(spdlog::format_string_t fmt, Args &&...args) { - mmapSpdlogger_->critical(fmt, std::forward(args)...); + MmapSink()->critical(fmt, std::forward(args)...); } // ── Runtime configuration ───────────────────────────────────────────── @@ -392,30 +392,31 @@ class Logger { spdlog::register_logger(spdlogger_); - std::string mmapFilePath = config.mmapFilePath; - if (mmapFilePath.empty()) { - std::filesystem::path basePath(config.filePath); - if (basePath.has_filename()) { - std::filesystem::path mmapPath = basePath; - mmapPath.replace_filename(basePath.stem().string() + "-mmaps" + - basePath.extension().string()); - mmapFilePath = mmapPath.string(); - } else { - mmapFilePath = "logs/firelands-mmaps.log"; - } + // The mmap navmesh log is opt-in: only the world server requests it via + // WithMmapFile(). Without an explicit path (e.g. the auth server, or the + // early bootstrap logger) we skip the sink entirely so no stray + // "-mmaps.log" file is created. MmapXxx() falls back to the main + // logger when this is null. + if (!config.mmapFilePath.empty()) { + auto mmapSink = std::make_shared( + config.mmapFilePath); + mmapSink->set_level( + static_cast(config.mmapFileLevel)); + mmapSink->set_pattern(config.filePattern); + + mmapSpdlogger_ = std::make_shared(config.name + ".mmap", + mmapSink); + mmapSpdlogger_->set_level(spdlog::level::trace); + mmapSpdlogger_->flush_on(spdlog::level::err); + spdlog::register_logger(mmapSpdlogger_); } + } - auto mmapSink = - std::make_shared(mmapFilePath); - mmapSink->set_level( - static_cast(config.mmapFileLevel)); - mmapSink->set_pattern(config.filePattern); - - mmapSpdlogger_ = std::make_shared(config.name + ".mmap", - mmapSink); - mmapSpdlogger_->set_level(spdlog::level::trace); - mmapSpdlogger_->flush_on(spdlog::level::err); - spdlog::register_logger(mmapSpdlogger_); + // mmap logging is opt-in (only the world server configures an mmap file). + // When no mmap sink exists, route MmapXxx() to the main logger instead of + // dereferencing a null logger. + const std::shared_ptr &MmapSink() const noexcept { + return mmapSpdlogger_ ? mmapSpdlogger_ : spdlogger_; } std::shared_ptr spdlogger_;