diff --git a/CMakeLists.txt b/CMakeLists.txt index afe5807..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,11 +134,27 @@ 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) FetchContent_MakeAvailable(lua_cmake) +# RecastNavigation (Recast + Detour for navmesh pathfinding) +FetchContent_Declare(recastnavigation + GIT_REPOSITORY https://github.com/recastnavigation/recastnavigation.git + 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) FetchContent_Populate(mariadb-connector-cpp) @@ -148,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") @@ -186,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 @@ -207,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/sql/migrations/71_world_firelands_commands.sql b/sql/migrations/71_world_firelands_commands.sql new file mode 100644 index 0000000..8dc4f48 --- /dev/null +++ b/sql/migrations/71_world_firelands_commands.sql @@ -0,0 +1,69 @@ +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 '', + `required_permission_mask` bigint unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 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), + +-- 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), + +-- 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 run and flight speed (also affects fly)', '.speed | .speed reset (default 7)', 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 (including racials)', '.cd', 128), +('damage', 'Deal damage to targeted creature', '.damage ', 128), +('revive', 'Revive yourself or targeted player', '.revive', 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 queue|mine|ui|take |reply |close ', 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/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/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/combat/CreatureChaseMovement.cpp b/src/application/combat/CreatureChaseMovement.cpp index d2bbe29..2b26d78 100644 --- a/src/application/combat/CreatureChaseMovement.cpp +++ b/src/application/combat/CreatureChaseMovement.cpp @@ -1,11 +1,15 @@ #include "CreatureChaseMovement.h" #include +#include +#include +#include #include using Firelands::MOVEMENTFLAG_FORWARD; using Firelands::MOVEMENTFLAG_NONE; using Firelands::MovementInfo; +using Firelands::Vec3; namespace application::combat { @@ -124,4 +128,168 @@ 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) { + LOG_DEBUG("CHASE navmesh path skipped: mapId={} no collision service", mapId); + 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); + } 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; +} + +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) { + // 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. + // 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 && !airborne) { + 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 + // 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, + kChaseReplanThresholdYards); + + if (targetRelocated || state.waypoints.empty()) { + state.lastTargetX = targetX; + state.lastTargetY = targetY; + state.lastTargetZ = targetZ; + state.currentWaypoint = 0; + 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 snapToGround(StepCreatureTowardTarget(current, targetX, targetY, + targetZ, deltaSeconds, config)); + } + + 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; + 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 snapToGround(StepCreatureTowardTarget(current, targetX, targetY, + targetZ, 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; + float const dy = current.y - wp.y; + float const distSq = dx * dx + dy * dy; + + 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; + } + + // 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 snapToGround(step); + } + + return snapToGround(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/ICommandSessionCore.h b/src/application/ports/ICommandSessionCore.h index 14d4f49..22d5994 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 { @@ -32,6 +33,7 @@ class ICommandSessionCore { /// OR of assigned `rbac_role` permission masks (staff capabilities). virtual PermissionMask GetAccountRolePermissionMask() const { return 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/ports/IMapCollisionQueries.h b/src/application/ports/IMapCollisionQueries.h index 9da25ad..0561ae1 100644 --- a/src/application/ports/IMapCollisionQueries.h +++ b/src/application/ports/IMapCollisionQueries.h @@ -2,9 +2,56 @@ #define FIRELANDS_APPLICATION_PORTS_I_MAP_COLLISION_QUERIES_H #include +#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 +}; + +/// 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; + 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 { @@ -14,9 +61,28 @@ 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; + + /// 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/application/services/CommandService.cpp b/src/application/services/CommandService.cpp index e10399a..83f5366 100644 --- a/src/application/services/CommandService.cpp +++ b/src/application/services/CommandService.cpp @@ -1,9 +1,16 @@ #include "CommandService.h" #include +#include #include #include #include +#include +#include #include +#include +#include +#include +#include #include #include #include @@ -13,8 +20,14 @@ #include #include #include +#include #include +#include #include +#include +#include +#include +#include #include #include #include @@ -23,6 +36,7 @@ #include #include #include +#include namespace Firelands { @@ -46,6 +60,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(); } @@ -152,6 +171,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; } @@ -168,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') || @@ -307,145 +294,112 @@ 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("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 _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); }; + 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; + }; + + // 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; + }; + -void CommandService::RegisterCommand(const std::string &name, CommandEntry entry) { - _commands[name] = std::move(entry); + 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 = def.requiredPermissionMask; + entry.consoleLayout = consoleLayoutFor(def.name); + _commands[def.name] = std::move(entry); + } + } + 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); + } } bool CommandService::IsCommand(const std::string &message) const { @@ -679,13 +633,24 @@ 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 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. 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" @@ -898,6 +863,28 @@ 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 RBAC permission mask + if (_commandDefRepo) { + auto cmds = _commandDefRepo->LoadAll(); + if (!cmds.empty()) { + 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 (cmd.requiredPermissionMask == 0 || + (mask & cmd.requiredPermissionMask) == cmd.requiredPermissionMask) { + 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; } @@ -2214,6 +2201,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). + _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 87292ab..96286db 100644 --- a/src/application/services/CommandService.h +++ b/src/application/services/CommandService.h @@ -1,19 +1,24 @@ #pragma once #include +#include #include #include +#include #include #include #include #include #include +#include #include namespace Firelands { class ICommandSession; class IAccountRepository; +class ICommandDefinitionRepository; class IRbacRepository; +class ICommandDefinitionRepository; class OnlineCharacterSessionRegistry; class CharacterService; class GmTicketService; @@ -43,7 +48,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, @@ -64,8 +72,6 @@ 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 HandleTele(std::shared_ptr session, @@ -136,7 +142,11 @@ class CommandService : public ICommandService { std::shared_ptr _characterService; std::shared_ptr _gmTicketService; std::shared_ptr _rbacRepo; + std::shared_ptr _commandDefRepo; std::map _commands; + /// `.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 diff --git a/src/application/services/WorldService.cpp b/src/application/services/WorldService.cpp index 50470a0..269e4bf 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(); @@ -138,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 1bc308e..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. @@ -42,6 +43,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); @@ -72,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 @@ -102,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/domain/repositories/ICommandDefinitionRepository.h b/src/domain/repositories/ICommandDefinitionRepository.h new file mode 100644 index 0000000..9e2a33f --- /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; + uint64_t requiredPermissionMask = 0; +}; + +class ICommandDefinitionRepository { +public: + virtual ~ICommandDefinitionRepository() = default; + virtual std::vector LoadAll() = 0; +}; + +} // namespace Firelands + +#endif diff --git a/src/domain/world/Player.h b/src/domain/world/Player.h index c36da42..2875060 100644 --- a/src/domain/world/Player.h +++ b/src/domain/world/Player.h @@ -97,6 +97,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; @@ -121,6 +124,7 @@ class Player : public WorldObject { PhaseShift m_phaseShift; UnitAuraState m_auraState; + bool m_gmModeEnabled = false; }; } // namespace Firelands diff --git a/src/infrastructure/CMakeLists.txt b/src/infrastructure/CMakeLists.txt index 43e7cea..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 @@ -64,6 +65,10 @@ add_library(FirelandsInfrastructure STATIC network/rest/RestAuthServer.cpp scripting/LuaGameScriptHost.cpp world/MapCollisionQueriesStub.cpp + collision/DetourNavMeshManager.cpp + collision/MapCollisionQueriesReal.cpp + collision/VMapManager2.cpp + collision/WorldModelRuntime.cpp ) set_source_files_properties(scripting/LuaGameScriptHost.cpp @@ -75,6 +80,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..0b9778b --- /dev/null +++ b/src/infrastructure/collision/DetourNavMeshManager.cpp @@ -0,0 +1,611 @@ +#include + +#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; +constexpr uint32_t kTileCountPerAxis = 64; +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]; +}; + +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; + out[2] = y; +} + +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, + 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(); +} + +namespace { +// 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, 63u - tileX, 63u - tileY); + 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 { + 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) { + // 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; + } + + fseek(file, 0, SEEK_END); + long fileSize = ftell(file); + fseek(file, 0, SEEK_SET); + + if (fileSize < static_cast(sizeof(MmapTileHeader) + sizeof(dtMeshHeader))) { + LOG_MMAP_ERROR("MMAP tile too small: mapId={} tileX={} tileY={} path={} size={}", + mapId, tileX, tileY, fileName, fileSize); + fclose(file); + return false; + } + + std::vector data(fileSize); + size_t readSize = fread(data.data(), 1, fileSize, file); + fclose(file); + + if (readSize != static_cast(fileSize)) + { + LOG_MMAP_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()); + if (mmapHeader->mmapMagic != kMmapMagic || + mmapHeader->dtVersion != DT_NAVMESH_VERSION || + mmapHeader->mmapSize == 0 || + sizeof(MmapTileHeader) + mmapHeader->mmapSize > data.size()) { + 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; + } + + 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) { + LOG_MMAP_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) { + LOG_MMAP_ERROR("MMAP tile alloc failed: mapId={} tileX={} tileY={} size={}", + mapId, tileX, tileY, mmapHeader->mmapSize); + 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); + if (dtStatusFailed(status)) { + LOG_MMAP_ERROR("MMAP tile add failed: mapId={} tileX={} tileY={} status=0x{:x}", + mapId, tileX, tileY, static_cast(status)); + dtFree(tileData); + } + return dtStatusSucceed(status); +} + +bool DetourNavMeshManager::LoadMapNavMesh(uint32_t mapId) { + if (_loadedMaps.count(mapId) > 0) + return true; + + dtNavMeshParams params{}; + float const navOrigin[3] = {kMapOrigin, 0.0f, kMapOrigin}; + dtVcopy(params.orig, navOrigin); + params.tileWidth = kTileSize; + params.tileHeight = kTileSize; + // 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, + params.maxTiles, params.maxPolys); + + dtNavMesh* navMesh = dtAllocNavMesh(); + if (!navMesh) + return false; + + dtStatus status = navMesh->init(¶ms); + if (dtStatusFailed(status)) { + LOG_MMAP_ERROR("MMAP navmesh init failed: mapId={} status=0x{:x}", mapId, + static_cast(status)); + if (dtStatusDetail(status, DT_INVALID_PARAM)) { + LOG_MMAP_ERROR("MMAP navmesh init detail: invalid params for mapId={} maxTiles={} maxPolys={}", + mapId, params.maxTiles, params.maxPolys); + } + dtFreeNavMesh(navMesh); + return false; + } + + 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); + } + } + } + + 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); + return false; + } + + dtNavMeshQuery* navQuery = dtAllocNavMeshQuery(); + if (!navQuery) { + dtFreeNavMesh(navMesh); + return false; + } + + status = navQuery->init(navMesh, _config.maxNavMeshNodes); + if (dtStatusFailed(status)) { + LOG_MMAP_ERROR("MMAP navmesh query init failed: mapId={} status=0x{:x}", mapId, + static_cast(status)); + dtFreeNavMeshQuery(navQuery); + dtFreeNavMesh(navMesh); + return false; + } + + 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()) + 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; +} + +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); + + // 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); + + 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; + + // 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( + 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; + + dtPolyRef startRef = 0; + dtPolyRef endRef = 0; + float startNearest[3]{}; + float endNearest[3]{}; + 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); + filter.setExcludeFlags(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, 0u); + result.status = FindPathStatus::NoPath; + return result; + } + + 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, 0u); + 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; + + int const maxPathPolys = std::clamp(_config.maxPathPolys, 1, 256); + 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)); + result.status = FindPathStatus::NoPath; + return result; + } + + status = navQuery->findStraightPath( + startNearest, endNearest, pathPolys, pathCount, straightPath, + straightPathFlags, straightPathPolys, &straightPathCount, + maxPathPolys, 0); + if (dtStatusFailed(status) || straightPathCount == 0) { + LOG_MMAP_DEBUG("MMAP straight path failed: mapId={} straightCount={} status=0x{:x}", + req.mapId, straightPathCount, static_cast(status)); + result.status = FindPathStatus::NoPath; + return result; + } + + result.waypoints.reserve(straightPathCount); + for (int i = 0; i < straightPathCount; ++i) { + result.waypoints.push_back(DetourToWow(&straightPath[i * 3])); + } + + RemoveDuplicateWaypoints(result.waypoints); + + 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 = + 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..11dd8d2 --- /dev/null +++ b/src/infrastructure/collision/DetourNavMeshManager.h @@ -0,0 +1,69 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_COLLISION_DETOUR_NAV_MESH_MANAGER_H +#define FIRELANDS_INFRASTRUCTURE_COLLISION_DETOUR_NAV_MESH_MANAGER_H + +#include +#include +#include +#include +#include +#include +#include + +class dtNavMesh; +class dtNavMeshQuery; + +namespace Firelands { + +struct DetourNavMeshConfig { + float maxSearchRadius = 100.0f; + int 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; + 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; } + + 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; + dtNavMeshQuery* navQuery = nullptr; + std::vector> loadedTiles; + }; + + 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..1ad50c5 --- /dev/null +++ b/src/infrastructure/collision/MapCollisionQueriesReal.cpp @@ -0,0 +1,73 @@ +#include + +#include + +namespace Firelands { + +MapCollisionQueriesReal::MapCollisionQueriesReal(std::string dataRoot) + : _navMeshManager(dataRoot), _vmapManager() {} + +bool MapCollisionQueriesReal::IsNavMeshDataAvailable(uint32_t mapId) const { + if (_navMeshManager.IsNavMeshLoaded(mapId)) + return true; + 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 { + 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; +} + +FindPathResult MapCollisionQueriesReal::FindPath( + FindPathRequest const& req) const { + if (!_navMeshManager.IsNavMeshLoaded(req.mapId)) { + if (!_navMeshManager.LoadMapNavMesh(req.mapId)) { + 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; + result.status = FindPathStatus::NavMeshMissing; + return result; + } + } + return _navMeshManager.FindPath(req); +} + +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)) + return _vmapManager.GetHeight(x, y, zHint); + return zHint; +} + +} // namespace Firelands diff --git a/src/infrastructure/collision/MapCollisionQueriesReal.h b/src/infrastructure/collision/MapCollisionQueriesReal.h new file mode 100644 index 0000000..c09d93a --- /dev/null +++ b/src/infrastructure/collision/MapCollisionQueriesReal.h @@ -0,0 +1,34 @@ +#ifndef FIRELANDS_INFRASTRUCTURE_COLLISION_MAP_COLLISION_QUERIES_REAL_H +#define FIRELANDS_INFRASTRUCTURE_COLLISION_MAP_COLLISION_QUERIES_REAL_H + +#include +#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; + 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; + float GetHeight(uint32_t mapId, float x, float y, + float zHint) const override; + +private: + mutable DetourNavMeshManager _navMeshManager; + mutable VMapManager2 _vmapManager; +}; + +} // namespace Firelands + +#endif diff --git a/src/infrastructure/collision/VMapManager2.cpp b/src/infrastructure/collision/VMapManager2.cpp new file mode 100644 index 0000000..37859f1 --- /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")).string(); + + 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")).string(); + 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")).string(); + 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..c870821 --- /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; } + bool RayTriangleIntersect(Vec3 const& orig, Vec3 const& dir, + Vec3 const& v0, Vec3 const& v1, + Vec3 const& v2, float& t) const; + +private: + AaBox3 _bound; + uint32_t _mogpFlags = 0; + uint32_t _groupWMOID = 0; + std::vector _vertices; + std::vector _triangles; +}; + +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/src/infrastructure/network/sessions/WorldSession.h b/src/infrastructure/network/sessions/WorldSession.h index 84c71c5..0652fbe 100644 --- a/src/infrastructure/network/sessions/WorldSession.h +++ b/src/infrastructure/network/sessions/WorldSession.h @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -214,6 +215,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..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 @@ -113,10 +114,33 @@ 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) { + 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; +} + 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, @@ -211,17 +235,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); - return 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(), 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. @@ -288,6 +318,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; @@ -326,6 +363,53 @@ 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. + float const targetZRaw = targetZ; + // 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); + // 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. + // 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={} rawZ={} deltaFrom={} -- ground " + "resolved above footing, keeping fromZ (likely corrupted .mmtile)", + map->GetMapId(), creature->GetGuid(), targetX, targetY, projected, + from.z, targetZRaw, 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={}", + map->GetMapId(), creature->GetGuid(), from.x, from.y, from.z, targetX, + targetY, targetZRaw, targetZ, targetZ - targetZRaw); + } + bool const chaseTargetMoved = !returnHomeSpline && (!runtime.lastChaseTargetPos.has_value() || @@ -334,7 +418,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); @@ -343,8 +426,56 @@ 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); + application::combat::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); + } + + // 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. + // 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, + 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. + // 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={} -- 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); + groundZ = from.z; + } + projected.position.z = groundZ; + 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) { float const distToPlayerSq = @@ -378,6 +509,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 @@ -565,6 +701,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; @@ -812,6 +953,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 17a31d2..51a3135 100644 --- a/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp +++ b/src/infrastructure/network/sessions/worldsession/WorldSessionGmState.cpp @@ -34,6 +34,20 @@ 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); + } + + 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/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/infrastructure/persistence/MySqlCharacterRepository.cpp b/src/infrastructure/persistence/MySqlCharacterRepository.cpp index 48cc7a7..8411efa 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) { diff --git a/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp b/src/infrastructure/persistence/MySqlCommandDefinitionRepository.cpp new file mode 100644 index 0000000..b82f09c --- /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, 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.requiredPermissionMask = rs->getUInt64(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/infrastructure/world/MapCollisionQueriesStub.cpp b/src/infrastructure/world/MapCollisionQueriesStub.cpp index f1a20fd..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*/, @@ -17,4 +30,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..e82497b 100644 --- a/src/infrastructure/world/MapCollisionQueriesStub.h +++ b/src/infrastructure/world/MapCollisionQueriesStub.h @@ -12,8 +12,14 @@ 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; + float GetHeight(uint32_t mapId, float x, float y, + float zHint) const override; private: std::string _dataRoot; diff --git a/src/shared/Logger.h b/src/shared/Logger.h index b6aebc0..aafe4dd 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... @@ -144,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. * @@ -279,6 +292,36 @@ class Logger { spdlogger_->critical(fmt, std::forward(args)...); } + template + void MmapTrace(spdlog::format_string_t fmt, Args &&...args) { + MmapSink()->trace(fmt, std::forward(args)...); + } + + template + void MmapDebug(spdlog::format_string_t fmt, Args &&...args) { + MmapSink()->debug(fmt, std::forward(args)...); + } + + template + void MmapInfo(spdlog::format_string_t fmt, Args &&...args) { + MmapSink()->info(fmt, std::forward(args)...); + } + + template + void MmapWarn(spdlog::format_string_t fmt, Args &&...args) { + MmapSink()->warn(fmt, std::forward(args)...); + } + + template + void MmapError(spdlog::format_string_t fmt, Args &&...args) { + MmapSink()->error(fmt, std::forward(args)...); + } + + template + void MmapCritical(spdlog::format_string_t fmt, Args &&...args) { + MmapSink()->critical(fmt, std::forward(args)...); + } + // ── Runtime configuration ───────────────────────────────────────────── /** @@ -300,6 +343,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 +391,36 @@ class Logger { spdlogger_->flush_on(spdlog::level::err); spdlog::register_logger(spdlogger_); + + // 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_); + } + } + + // 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_; + std::shared_ptr mmapSpdlogger_; std::string console_pattern_; static std::unique_ptr instance_; }; @@ -365,5 +440,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 diff --git a/src/world/WorldApplication.cpp b/src/world/WorldApplication.cpp index 89d71e5..f94d290 100644 --- a/src/world/WorldApplication.cpp +++ b/src/world/WorldApplication.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -100,8 +102,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"); @@ -167,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")) { @@ -302,6 +311,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 = @@ -469,6 +479,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()); @@ -550,4 +564,4 @@ int RunWorldApplication(int argc, char **argv) { return rc; } -} // namespace Firelands \ No newline at end of file +} // namespace Firelands diff --git a/src/world/WorldInteractiveConsole.cpp b/src/world/WorldInteractiveConsole.cpp index ec22345..8fc4323 100644 --- a/src/world/WorldInteractiveConsole.cpp +++ b/src/world/WorldInteractiveConsole.cpp @@ -44,6 +44,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; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e460f93..9c034c6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -116,6 +116,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/combat/CreatureChaseMovementTest.cpp b/tests/unit/combat/CreatureChaseMovementTest.cpp index 6dc4c40..dd9acf3 100644 --- a/tests/unit/combat/CreatureChaseMovementTest.cpp +++ b/tests/unit/combat/CreatureChaseMovementTest.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -12,6 +13,33 @@ 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; } + 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; + } + 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 +93,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/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)); +} 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); } 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/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/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/CMakeLists.txt b/tools/vmap/mmap_generator/CMakeLists.txt new file mode 100644 index 0000000..b8f47ef --- /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 +) + +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 new file mode 100644 index 0000000..5e8d174 --- /dev/null +++ b/tools/vmap/mmap_generator/MmapGenerator.cpp @@ -0,0 +1,593 @@ +#include "MmapGenerator.h" + +#include +#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; +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; +} + +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) << 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; + 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); +} + +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) + : _config(std::move(config)) {} + +bool MmapGenerator::LoadTerrainData(uint32_t tileX, uint32_t tileY, + TileTerrainData& out) const { + std::string const fileName = + 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 != kMapMagic) { + 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); + + 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.minZ = gridHeight; + out.maxZ = gridMaxHeight; + out.heights.resize(kTerrainVertexCount * kTerrainVertexCount); + + 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 & kMapHeightAsInt16) { + float const invStep = heightRange > 0.0f ? heightRange / 65535.0f : 0.0f; + 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; + } + } + } else if (heightFlags & kMapHeightAsInt8) { + float const invStep = heightRange > 0.0f ? heightRange / 255.0f : 0.0f; + 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; + } + } + } else { + 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)]; + } + } + } + + 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; +} + +bool MmapGenerator::BuildTileNavMesh(TileTerrainData const& terrain, + uint32_t tileX, uint32_t tileY, + std::string const& outputPath) const { + 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); + 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) { + PrintTileFailure(tileX, tileY, "could not allocate heightfield"); + return false; + } + if (!rcCreateHeightfield(&ctx, *solid, tileW, tileH, bmin, bmax, cellSize, cellHeight)) { + PrintTileFailure(tileX, tileY, "could not create heightfield"); + rcFreeHeightField(solid); + return false; + } + 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)); + + // 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) { + 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); + } + } + + 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; + // 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); + } + } + + int const triCount = static_cast(tris.size() / 3); + 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); + PrintTileProgress(tileX, tileY, 30, "terrain rasterized"); + + rcCompactHeightfield* chf = rcAllocCompactHeightfield(); + if (!chf) { + PrintTileFailure(tileX, tileY, "could not allocate compact heightfield"); + rcFreeHeightField(solid); + return false; + } + if (!rcBuildCompactHeightfield(&ctx, walkableClimb, walkableHeight, *solid, *chf)) { + PrintTileFailure(tileX, tileY, "could not compact heightfield"); + rcFreeCompactHeightfield(chf); + rcFreeHeightField(solid); + return false; + } + rcFreeHeightField(solid); + + 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)) { + 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); + return false; + } + 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_NULL_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; + 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; + 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; + 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; + } + + dtNavMesh* tileNavMesh = dtAllocNavMesh(); + if (!tileNavMesh) { + PrintTileFailure(tileX, tileY, "could not allocate Detour nav mesh"); + 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)) { + PrintTileFailure(tileX, tileY, "could not initialize Detour nav mesh"); + dtFreeNavMesh(tileNavMesh); + dtFree(navData); + rcFreePolyMeshDetail(dmesh); + rcFreePolyMesh(pmesh); + return false; + } + + dtStatusFlags = tileNavMesh->addTile(navData, navDataSize, DT_TILE_FREE_DATA, 0, nullptr); + if (dtStatusFailed(dtStatusFlags)) { + PrintTileFailure(tileX, tileY, "could not add Detour tile"); + dtFreeNavMesh(tileNavMesh); + 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); + rcFreePolyMesh(pmesh); + return true; +} + +bool MmapGenerator::Generate(uint32_t tileX, uint32_t tileY) { + TileTerrainData terrain; + if (!LoadTerrainData(tileX, tileY, terrain)) { + PrintTileFailure(tileX, tileY, "terrain .map missing or invalid"); + return false; + } + + std::filesystem::create_directories(_config.mmapsDir); + return BuildTileNavMesh(terrain, tileX, tileY, _config.mmapsDir); +} + +bool MmapGenerator::GenerateAllTiles(BatchProgress const* batchProgress) { + bool anySuccess = false; + uint32_t processed = 0; + uint32_t succeeded = 0; + 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; + } + + 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) { + if (!std::filesystem::exists( + MapTilePath(_config.mapsDir, _config.mapId, tileX, tileY))) { + continue; + } + + ++processed; + int const percent = static_cast((processed * 100u) / totalTiles); + 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; + } + } + } + printf("\nDone: %u/%u existing terrain tiles generated.\n", succeeded, + totalTiles); + 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..e94df6c --- /dev/null +++ b/tools/vmap/mmap_generator/MmapGenerator.h @@ -0,0 +1,73 @@ +#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; + // 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; + float maxSimplificationError = 1.3f; + int maxVertsPerPoly = 6; + float detailSampleDist = 6.0f; + float detailSampleMaxError = 1.0f; + + static MmapGeneratorConfig Default() { return {}; } +}; + +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(BatchProgress const* batchProgress = nullptr); + +private: + struct TileTerrainData { + std::vector heights; + int width = 0; + int height = 0; + float minX = 0.0f; + float minY = 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; + 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..375b6f6 --- /dev/null +++ b/tools/vmap/mmap_generator/main.cpp @@ -0,0 +1,207 @@ +#include "MmapGenerator.h" + +#include +#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("\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"); + printf(" -t Optional: generate only tile (x,y) instead of all\n"); + 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; +} + +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; + bool allMaps = 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], "--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]; + 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; + } + + 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; + } + + 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: %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/%zu: %u (%u tile(s), %u/%u global done)\n", + processedMaps, mapIds.size(), mapId, mapTiles, processedTiles, + totalTiles); + printf("============================================================\n"); + 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/%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; + } + + 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("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("\nTile (%u,%u) generated successfully.\n", tileX, tileY); + } else { + 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("\nNavmesh generation complete.\n"); + } + + return 0; +} diff --git a/worldserver.yaml b/worldserver.yaml index ee07d28..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: @@ -66,3 +68,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: ""