Kelsidavis-WoWee/src/core/application.cpp
Paul a916270a13 chore(lua): refactor addon Lua engine API + progress docs
- Refactor Lua addon integration:
  - Update CMakeLists.txt for addon build paths
  - Enhance addons API headers and Lua engine interface
  - Add new Lua API addon modules (`lua_api_helpers`, `lua_api_registrations`, `lua_services`, `lua_action_api`, `lua_inventory_api`, `lua_quest_api`, `lua_social_api`, `lua_spell_api`, `lua_system_api`, `lua_unit_api`)
  - Update implementation in addon_manager.cpp, lua_engine.cpp, application.cpp, game_handler.cpp
2026-04-03 07:31:06 +03:00

4166 lines
204 KiB
C++

#include "core/application.hpp"
#include "core/coordinates.hpp"
#include <unordered_set>
#include <cmath>
#include <chrono>
#include "core/spawn_presets.hpp"
#include "core/logger.hpp"
#include "core/memory_monitor.hpp"
#include "rendering/renderer.hpp"
#include "rendering/vk_context.hpp"
#include "audio/npc_voice_manager.hpp"
#include "rendering/camera.hpp"
#include "rendering/camera_controller.hpp"
#include "rendering/terrain_renderer.hpp"
#include "rendering/terrain_manager.hpp"
#include "rendering/performance_hud.hpp"
#include "rendering/water_renderer.hpp"
#include "rendering/skybox.hpp"
#include "rendering/celestial.hpp"
#include "rendering/starfield.hpp"
#include "rendering/clouds.hpp"
#include "rendering/lens_flare.hpp"
#include "rendering/weather.hpp"
#include "rendering/character_renderer.hpp"
#include "rendering/wmo_renderer.hpp"
#include "rendering/m2_renderer.hpp"
#include "rendering/minimap.hpp"
#include "rendering/quest_marker_renderer.hpp"
#include "rendering/loading_screen.hpp"
#include "audio/music_manager.hpp"
#include "audio/footstep_manager.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/audio_engine.hpp"
#include "audio/audio_coordinator.hpp"
#include "addons/addon_manager.hpp"
#include <imgui.h>
#include "pipeline/m2_loader.hpp"
#include "pipeline/wmo_loader.hpp"
#include "pipeline/wdt_loader.hpp"
#include "pipeline/dbc_loader.hpp"
#include "ui/ui_manager.hpp"
#include "ui/ui_services.hpp"
#include "auth/auth_handler.hpp"
#include "game/game_handler.hpp"
#include "game/transport_manager.hpp"
#include "game/world.hpp"
#include "game/expansion_profile.hpp"
#include "game/packet_parsers.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_layout.hpp"
#include <SDL2/SDL.h>
#include <cstdlib>
#include <climits>
#include <algorithm>
#include <cctype>
#include <optional>
#include <sstream>
#include <set>
#include <filesystem>
#include <fstream>
#include <thread>
#ifdef __linux__
#include <sched.h>
#include <pthread.h>
#elif defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <mach/mach.h>
#include <mach/thread_policy.h>
#include <pthread.h>
#endif
namespace wowee {
namespace core {
namespace {
bool envFlagEnabled(const char* key, bool defaultValue = false) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' ||
raw[0] == 'n' || raw[0] == 'N');
}
} // namespace
Application* Application::instance = nullptr;
Application::Application() {
instance = this;
}
Application::~Application() {
shutdown();
instance = nullptr;
}
bool Application::initialize() {
LOG_INFO("Initializing Wowee Native Client");
// Initialize memory monitoring for dynamic cache sizing
core::MemoryMonitor::getInstance().initialize();
// Create window
WindowConfig windowConfig;
windowConfig.title = "Wowee";
windowConfig.width = 1280;
windowConfig.height = 720;
windowConfig.vsync = false;
window = std::make_unique<Window>(windowConfig);
if (!window->initialize()) {
LOG_FATAL("Failed to initialize window");
return false;
}
// Create renderer
renderer = std::make_unique<rendering::Renderer>();
if (!renderer->initialize(window.get())) {
LOG_FATAL("Failed to initialize renderer");
return false;
}
// Create and initialize audio coordinator (owns all audio managers)
audioCoordinator_ = std::make_unique<audio::AudioCoordinator>();
audioCoordinator_->initialize();
renderer->setAudioCoordinator(audioCoordinator_.get());
// Create UI manager
uiManager = std::make_unique<ui::UIManager>();
if (!uiManager->initialize(window.get())) {
LOG_FATAL("Failed to initialize UI manager");
return false;
}
// Create subsystems
authHandler = std::make_unique<auth::AuthHandler>();
world = std::make_unique<game::World>();
// Create and initialize expansion registry
expansionRegistry_ = std::make_unique<game::ExpansionRegistry>();
// Create DBC layout
dbcLayout_ = std::make_unique<pipeline::DBCLayout>();
// Create asset manager
assetManager = std::make_unique<pipeline::AssetManager>();
// Populate game services — all subsystems now available
gameServices_.renderer = renderer.get();
gameServices_.audioCoordinator = audioCoordinator_.get();
gameServices_.assetManager = assetManager.get();
gameServices_.expansionRegistry = expansionRegistry_.get();
// Create game handler with explicit service dependencies
gameHandler = std::make_unique<game::GameHandler>(gameServices_);
// Try to get WoW data path from environment variable
const char* dataPathEnv = std::getenv("WOW_DATA_PATH");
std::string dataPath = dataPathEnv ? dataPathEnv : "./Data";
// Scan for available expansion profiles
expansionRegistry_->initialize(dataPath);
// Load expansion-specific opcode table
if (gameHandler && expansionRegistry_) {
auto* profile = expansionRegistry_->getActive();
if (profile) {
std::string opcodesPath = profile->dataPath + "/opcodes.json";
if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) {
LOG_ERROR("Failed to load opcodes from ", opcodesPath);
}
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
// Load expansion-specific update field table
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
LOG_ERROR("Failed to load update fields from ", updateFieldsPath);
}
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
// Create expansion-specific packet parsers
gameHandler->setPacketParsers(game::createPacketParsers(profile->id));
// Load expansion-specific DBC layouts
if (dbcLayout_) {
std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json";
if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) {
LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath);
}
pipeline::setActiveDBCLayout(dbcLayout_.get());
}
}
}
// Try expansion-specific asset path first, fall back to base Data/
std::string assetPath = dataPath;
if (expansionRegistry_) {
auto* profile = expansionRegistry_->getActive();
if (profile && !profile->dataPath.empty()) {
// Enable expansion-specific CSV DBC lookup (Data/expansions/<id>/db/*.csv).
assetManager->setExpansionDataPath(profile->dataPath);
std::string expansionManifest = profile->dataPath + "/manifest.json";
if (std::filesystem::exists(expansionManifest)) {
assetPath = profile->dataPath;
LOG_INFO("Using expansion-specific asset path: ", assetPath);
// Register base Data/ as fallback so world terrain files are found
// even when the expansion path only contains DBC overrides.
if (assetPath != dataPath) {
assetManager->setBaseFallbackPath(dataPath);
}
}
}
}
LOG_INFO("Attempting to load WoW assets from: ", assetPath);
if (assetManager->initialize(assetPath)) {
LOG_INFO("Asset manager initialized successfully");
// Eagerly load creature display DBC lookups so first spawn doesn't stall
entitySpawner_ = std::make_unique<EntitySpawner>(
renderer.get(), assetManager.get(), gameHandler.get(),
dbcLayout_.get(), &gameServices_);
entitySpawner_->initialize();
appearanceComposer_ = std::make_unique<AppearanceComposer>(
renderer.get(), assetManager.get(), gameHandler.get(),
dbcLayout_.get(), entitySpawner_.get());
// Wire AppearanceComposer to UI components (Phase A singleton breaking)
if (uiManager) {
uiManager->setAppearanceComposer(appearanceComposer_.get());
// Wire all services to UI components (Phase B singleton breaking)
ui::UIServices uiServices;
uiServices.window = window.get();
uiServices.renderer = renderer.get();
uiServices.assetManager = assetManager.get();
uiServices.gameHandler = gameHandler.get();
uiServices.expansionRegistry = expansionRegistry_.get();
uiServices.addonManager = addonManager_.get(); // May be nullptr here, re-wire later
uiServices.audioCoordinator = audioCoordinator_.get();
uiServices.entitySpawner = entitySpawner_.get();
uiServices.appearanceComposer = appearanceComposer_.get();
uiServices.worldLoader = worldLoader_.get();
uiManager->setServices(uiServices);
}
// Ensure the main in-world CharacterRenderer can load textures immediately.
// Previously this was only wired during terrain initialization, which meant early spawns
// (before terrain load) would render with white fallback textures (notably hair).
if (renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->setAssetManager(assetManager.get());
}
// Load transport paths from TransportAnimation.dbc and TaxiPathNode.dbc
if (gameHandler && gameHandler->getTransportManager()) {
gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get());
gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get());
}
// Initialize addon system
addonManager_ = std::make_unique<addons::AddonManager>();
addons::LuaServices luaSvc;
luaSvc.window = window.get();
luaSvc.audioCoordinator = audioCoordinator_.get();
luaSvc.expansionRegistry = expansionRegistry_.get();
if (addonManager_->initialize(gameHandler.get(), luaSvc)) {
std::string addonsDir = assetPath + "/interface/AddOns";
addonManager_->scanAddons(addonsDir);
// Wire Lua errors to UI error display
addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) {
if (gh) gh->addUIError(err);
});
// Wire chat messages to addon event dispatch
gameHandler->setAddonChatCallback([this](const game::MessageChatData& msg) {
if (!addonManager_ || !addonsLoaded_) return;
// Map ChatType to WoW event name
const char* eventName = nullptr;
switch (msg.type) {
case game::ChatType::SAY: eventName = "CHAT_MSG_SAY"; break;
case game::ChatType::YELL: eventName = "CHAT_MSG_YELL"; break;
case game::ChatType::WHISPER: eventName = "CHAT_MSG_WHISPER"; break;
case game::ChatType::PARTY: eventName = "CHAT_MSG_PARTY"; break;
case game::ChatType::GUILD: eventName = "CHAT_MSG_GUILD"; break;
case game::ChatType::OFFICER: eventName = "CHAT_MSG_OFFICER"; break;
case game::ChatType::RAID: eventName = "CHAT_MSG_RAID"; break;
case game::ChatType::RAID_WARNING: eventName = "CHAT_MSG_RAID_WARNING"; break;
case game::ChatType::BATTLEGROUND: eventName = "CHAT_MSG_BATTLEGROUND"; break;
case game::ChatType::SYSTEM: eventName = "CHAT_MSG_SYSTEM"; break;
case game::ChatType::CHANNEL: eventName = "CHAT_MSG_CHANNEL"; break;
case game::ChatType::EMOTE:
case game::ChatType::TEXT_EMOTE: eventName = "CHAT_MSG_EMOTE"; break;
case game::ChatType::ACHIEVEMENT: eventName = "CHAT_MSG_ACHIEVEMENT"; break;
case game::ChatType::GUILD_ACHIEVEMENT: eventName = "CHAT_MSG_GUILD_ACHIEVEMENT"; break;
case game::ChatType::WHISPER_INFORM: eventName = "CHAT_MSG_WHISPER_INFORM"; break;
case game::ChatType::RAID_LEADER: eventName = "CHAT_MSG_RAID_LEADER"; break;
case game::ChatType::BATTLEGROUND_LEADER: eventName = "CHAT_MSG_BATTLEGROUND_LEADER"; break;
case game::ChatType::MONSTER_SAY: eventName = "CHAT_MSG_MONSTER_SAY"; break;
case game::ChatType::MONSTER_YELL: eventName = "CHAT_MSG_MONSTER_YELL"; break;
case game::ChatType::MONSTER_EMOTE: eventName = "CHAT_MSG_MONSTER_EMOTE"; break;
case game::ChatType::MONSTER_WHISPER: eventName = "CHAT_MSG_MONSTER_WHISPER"; break;
case game::ChatType::RAID_BOSS_EMOTE: eventName = "CHAT_MSG_RAID_BOSS_EMOTE"; break;
case game::ChatType::RAID_BOSS_WHISPER: eventName = "CHAT_MSG_RAID_BOSS_WHISPER"; break;
case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break;
case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break;
case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break;
case game::ChatType::MONSTER_PARTY: eventName = "CHAT_MSG_MONSTER_PARTY"; break;
case game::ChatType::AFK: eventName = "CHAT_MSG_AFK"; break;
case game::ChatType::DND: eventName = "CHAT_MSG_DND"; break;
case game::ChatType::LOOT: eventName = "CHAT_MSG_LOOT"; break;
case game::ChatType::SKILL: eventName = "CHAT_MSG_SKILL"; break;
default: break;
}
if (eventName) {
addonManager_->fireEvent(eventName, {msg.message, msg.senderName});
}
});
// Wire generic game events to addon dispatch
gameHandler->setAddonEventCallback([this](const std::string& event, const std::vector<std::string>& args) {
if (addonManager_ && addonsLoaded_) {
addonManager_->fireEvent(event, args);
}
});
// Wire spell icon path resolver for Lua API (GetSpellInfo, UnitBuff icon, etc.)
{
auto spellIconPaths = std::make_shared<std::unordered_map<uint32_t, std::string>>();
auto spellIconIds = std::make_shared<std::unordered_map<uint32_t, uint32_t>>();
auto loaded = std::make_shared<bool>(false);
auto* am = assetManager.get();
gameHandler->setSpellIconPathResolver([spellIconPaths, spellIconIds, loaded, am](uint32_t spellId) -> std::string {
if (!am) return {};
// Lazy-load SpellIcon.dbc + Spell.dbc icon IDs on first call
if (!*loaded) {
*loaded = true;
auto iconDbc = am->loadDBC("SpellIcon.dbc");
const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr;
if (iconDbc && iconDbc->isLoaded()) {
for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) {
uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0);
std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1);
if (!path.empty() && id > 0) (*spellIconPaths)[id] = path;
}
}
auto spellDbc = am->loadDBC("Spell.dbc");
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
if (spellDbc && spellDbc->isLoaded()) {
uint32_t fieldCount = spellDbc->getFieldCount();
uint32_t iconField = 133; // WotLK default
uint32_t idField = 0;
if (spellL) {
uint32_t layoutIcon = (*spellL)["IconID"];
if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) {
iconField = layoutIcon;
idField = (*spellL)["ID"];
}
}
for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) {
uint32_t id = spellDbc->getUInt32(i, idField);
uint32_t iconId = spellDbc->getUInt32(i, iconField);
if (id > 0 && iconId > 0) (*spellIconIds)[id] = iconId;
}
}
}
auto iit = spellIconIds->find(spellId);
if (iit == spellIconIds->end()) return {};
auto pit = spellIconPaths->find(iit->second);
if (pit == spellIconPaths->end()) return {};
return pit->second;
});
}
// Wire item icon path resolver: displayInfoId -> "Interface\\Icons\\INV_..."
{
auto iconNames = std::make_shared<std::unordered_map<uint32_t, std::string>>();
auto loaded = std::make_shared<bool>(false);
auto* am = assetManager.get();
gameHandler->setItemIconPathResolver([iconNames, loaded, am](uint32_t displayInfoId) -> std::string {
if (!am || displayInfoId == 0) return {};
if (!*loaded) {
*loaded = true;
auto dbc = am->loadDBC("ItemDisplayInfo.dbc");
const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
if (dbc && dbc->isLoaded()) {
uint32_t iconField = dispL ? (*dispL)["InventoryIcon"] : 5;
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
uint32_t id = dbc->getUInt32(i, 0); // field 0 = ID
std::string name = dbc->getString(i, iconField);
if (id > 0 && !name.empty()) (*iconNames)[id] = name;
}
LOG_INFO("Loaded ", iconNames->size(), " item icon names from ItemDisplayInfo.dbc");
}
}
auto it = iconNames->find(displayInfoId);
if (it == iconNames->end()) return {};
return "Interface\\Icons\\" + it->second;
});
}
// Wire spell data resolver: spellId -> {castTimeMs, minRange, maxRange}
{
auto castTimeMap = std::make_shared<std::unordered_map<uint32_t, uint32_t>>();
auto rangeMap = std::make_shared<std::unordered_map<uint32_t, std::pair<float,float>>>();
auto spellCastIdx = std::make_shared<std::unordered_map<uint32_t, uint32_t>>(); // spellId→castTimeIdx
auto spellRangeIdx = std::make_shared<std::unordered_map<uint32_t, uint32_t>>(); // spellId→rangeIdx
struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; };
auto spellCostMap = std::make_shared<std::unordered_map<uint32_t, SpellCostEntry>>();
auto loaded = std::make_shared<bool>(false);
auto* am = assetManager.get();
gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo {
if (!am) return {};
if (!*loaded) {
*loaded = true;
// Load SpellCastTimes.dbc
auto ctDbc = am->loadDBC("SpellCastTimes.dbc");
if (ctDbc && ctDbc->isLoaded()) {
for (uint32_t i = 0; i < ctDbc->getRecordCount(); ++i) {
uint32_t id = ctDbc->getUInt32(i, 0);
int32_t base = static_cast<int32_t>(ctDbc->getUInt32(i, 1));
if (id > 0 && base > 0) (*castTimeMap)[id] = static_cast<uint32_t>(base);
}
}
// Load SpellRange.dbc
const auto* srL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr;
uint32_t minRField = srL ? (*srL)["MinRange"] : 1;
uint32_t maxRField = srL ? (*srL)["MaxRange"] : 4;
auto rDbc = am->loadDBC("SpellRange.dbc");
if (rDbc && rDbc->isLoaded()) {
for (uint32_t i = 0; i < rDbc->getRecordCount(); ++i) {
uint32_t id = rDbc->getUInt32(i, 0);
float minR = rDbc->getFloat(i, minRField);
float maxR = rDbc->getFloat(i, maxRField);
if (id > 0) (*rangeMap)[id] = {minR, maxR};
}
}
// Load Spell.dbc: extract castTimeIndex and rangeIndex per spell
auto sDbc = am->loadDBC("Spell.dbc");
const auto* spL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
if (sDbc && sDbc->isLoaded()) {
uint32_t idF = spL ? (*spL)["ID"] : 0;
uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default
uint32_t rF = spL ? (*spL)["RangeIndex"] : 132;
uint32_t ptF = UINT32_MAX, mcF = UINT32_MAX;
if (spL) {
try { ptF = (*spL)["PowerType"]; } catch (...) {}
try { mcF = (*spL)["ManaCost"]; } catch (...) {}
}
uint32_t fc = sDbc->getFieldCount();
for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) {
uint32_t id = sDbc->getUInt32(i, idF);
if (id == 0) continue;
uint32_t ct = sDbc->getUInt32(i, ctF);
uint32_t ri = sDbc->getUInt32(i, rF);
if (ct > 0) (*spellCastIdx)[id] = ct;
if (ri > 0) (*spellRangeIdx)[id] = ri;
// Extract power cost
uint32_t mc = (mcF < fc) ? sDbc->getUInt32(i, mcF) : 0;
uint8_t pt = (ptF < fc) ? static_cast<uint8_t>(sDbc->getUInt32(i, ptF)) : 0;
if (mc > 0) (*spellCostMap)[id] = {mc, pt};
}
}
LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ",
spellRangeIdx->size(), " range indices");
}
game::GameHandler::SpellDataInfo info;
auto ciIt = spellCastIdx->find(spellId);
if (ciIt != spellCastIdx->end()) {
auto ctIt = castTimeMap->find(ciIt->second);
if (ctIt != castTimeMap->end()) info.castTimeMs = ctIt->second;
}
auto riIt = spellRangeIdx->find(spellId);
if (riIt != spellRangeIdx->end()) {
auto rIt = rangeMap->find(riIt->second);
if (rIt != rangeMap->end()) {
info.minRange = rIt->second.first;
info.maxRange = rIt->second.second;
}
}
auto mcIt = spellCostMap->find(spellId);
if (mcIt != spellCostMap->end()) {
info.manaCost = mcIt->second.manaCost;
info.powerType = mcIt->second.powerType;
}
return info;
});
}
// Wire random property/suffix name resolver for item display
{
auto propNames = std::make_shared<std::unordered_map<int32_t, std::string>>();
auto propLoaded = std::make_shared<bool>(false);
auto* amPtr = assetManager.get();
gameHandler->setRandomPropertyNameResolver([propNames, propLoaded, amPtr](int32_t id) -> std::string {
if (!amPtr || id == 0) return {};
if (!*propLoaded) {
*propLoaded = true;
// ItemRandomProperties.dbc: ID=0, Name=4 (string)
if (auto dbc = amPtr->loadDBC("ItemRandomProperties.dbc"); dbc && dbc->isLoaded()) {
uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1;
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
int32_t rid = static_cast<int32_t>(dbc->getUInt32(r, 0));
std::string name = dbc->getString(r, nameField);
if (!name.empty() && rid > 0) (*propNames)[rid] = name;
}
}
// ItemRandomSuffix.dbc: ID=0, Name=4 (string) — stored as negative IDs
if (auto dbc = amPtr->loadDBC("ItemRandomSuffix.dbc"); dbc && dbc->isLoaded()) {
uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1;
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
int32_t rid = static_cast<int32_t>(dbc->getUInt32(r, 0));
std::string name = dbc->getString(r, nameField);
if (!name.empty() && rid > 0) (*propNames)[-rid] = name;
}
}
}
auto it = propNames->find(id);
return (it != propNames->end()) ? it->second : std::string{};
});
}
LOG_INFO("Addon system initialized, found ", addonManager_->getAddons().size(), " addon(s)");
} else {
LOG_WARNING("Failed to initialize addon system");
addonManager_.reset();
}
// Initialize world loader (handles terrain streaming, world preload, map transitions)
worldLoader_ = std::make_unique<WorldLoader>(
*this, renderer.get(), assetManager.get(), gameHandler.get(),
entitySpawner_.get(), appearanceComposer_.get(), window.get(),
world.get(), addonManager_.get());
// Re-wire UIServices now that all services (addonManager_, worldLoader_) are available
if (uiManager) {
ui::UIServices uiServices;
uiServices.window = window.get();
uiServices.renderer = renderer.get();
uiServices.assetManager = assetManager.get();
uiServices.gameHandler = gameHandler.get();
uiServices.expansionRegistry = expansionRegistry_.get();
uiServices.addonManager = addonManager_.get();
uiServices.audioCoordinator = audioCoordinator_.get();
uiServices.entitySpawner = entitySpawner_.get();
uiServices.appearanceComposer = appearanceComposer_.get();
uiServices.worldLoader = worldLoader_.get();
uiManager->setServices(uiServices);
}
// Start background preload for last-played character's world.
// Warms the file cache so terrain tile loading is faster at Enter World.
{
auto lastWorld = worldLoader_->loadLastWorldInfo();
if (lastWorld.valid) {
worldLoader_->startWorldPreload(lastWorld.mapId, lastWorld.mapName, lastWorld.x, lastWorld.y);
}
}
} else {
LOG_WARNING("Failed to initialize asset manager - asset loading will be unavailable");
LOG_WARNING("Set WOW_DATA_PATH environment variable to your WoW Data directory");
}
// Set up UI callbacks
setupUICallbacks();
LOG_INFO("Application initialized successfully");
running = true;
return true;
}
void Application::run() {
LOG_INFO("Starting main loop");
// Pin main thread to a dedicated CPU core to reduce scheduling jitter
{
int numCores = static_cast<int>(std::thread::hardware_concurrency());
if (numCores >= 2) {
#ifdef __linux__
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
int rc = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
if (rc == 0) {
LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)");
} else {
LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", rc, ")");
}
#elif defined(_WIN32)
DWORD_PTR mask = 1; // Core 0
DWORD_PTR prev = SetThreadAffinityMask(GetCurrentThread(), mask);
if (prev != 0) {
LOG_INFO("Main thread pinned to CPU core 0 (", numCores, " cores available)");
} else {
LOG_WARNING("Failed to pin main thread to CPU core 0 (error ", GetLastError(), ")");
}
#elif defined(__APPLE__)
// macOS doesn't support hard pinning — use affinity tags to hint
// that the main thread should stay on its own core group
thread_affinity_policy_data_t policy = { 1 }; // tag 1 = main thread group
kern_return_t kr = thread_policy_set(
pthread_mach_thread_np(pthread_self()),
THREAD_AFFINITY_POLICY,
reinterpret_cast<thread_policy_t>(&policy),
THREAD_AFFINITY_POLICY_COUNT);
if (kr == KERN_SUCCESS) {
LOG_INFO("Main thread affinity tag set (", numCores, " cores available)");
} else {
LOG_WARNING("Failed to set main thread affinity tag (error ", kr, ")");
}
#endif
}
}
const bool frameProfileEnabled = envFlagEnabled("WOWEE_FRAME_PROFILE", false);
if (frameProfileEnabled) {
LOG_INFO("Frame timing profile enabled (WOWEE_FRAME_PROFILE=1)");
}
auto lastTime = std::chrono::high_resolution_clock::now();
std::atomic<bool> watchdogRunning{true};
std::atomic<int64_t> watchdogHeartbeatMs{
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count()
};
// Signal flag: watchdog sets this when a stall is detected, main loop
// handles the actual SDL calls. SDL2 video functions must only be called
// from the main thread (the one that called SDL_Init); calling them from
// a background thread is UB on macOS (Cocoa) and unsafe on other platforms.
std::atomic<bool> watchdogRequestRelease{false};
std::thread watchdogThread([&watchdogRunning, &watchdogHeartbeatMs, &watchdogRequestRelease]() {
bool signalledForCurrentStall = false;
while (watchdogRunning.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(std::chrono::milliseconds(250));
const int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire);
const int64_t stallMs = nowMs - lastBeatMs;
if (stallMs > 1500) {
if (!signalledForCurrentStall) {
watchdogRequestRelease.store(true, std::memory_order_release);
LOG_WARNING("Main-loop stall detected (", stallMs,
"ms) — requesting mouse capture release");
signalledForCurrentStall = true;
}
} else {
signalledForCurrentStall = false;
}
}
});
try {
while (running && !window->shouldClose()) {
watchdogHeartbeatMs.store(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count(),
std::memory_order_release);
// Handle watchdog mouse-release request on the main thread where
// SDL video calls are safe (required by SDL2 threading model).
if (watchdogRequestRelease.exchange(false, std::memory_order_acq_rel)) {
SDL_SetRelativeMouseMode(SDL_FALSE);
SDL_ShowCursor(SDL_ENABLE);
if (window && window->getSDLWindow()) {
SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE);
}
LOG_WARNING("Watchdog: force-released mouse capture on main thread");
}
// Calculate delta time
auto currentTime = std::chrono::high_resolution_clock::now();
std::chrono::duration<float> deltaTimeDuration = currentTime - lastTime;
float deltaTime = deltaTimeDuration.count();
lastTime = currentTime;
// Cap delta time to prevent large jumps
if (deltaTime > 0.1f) {
deltaTime = 0.1f;
}
// Poll events
SDL_Event event;
while (SDL_PollEvent(&event)) {
// Pass event to UI manager first
if (uiManager) {
uiManager->processEvent(event);
}
// Pass mouse events to camera controller (skip when UI has mouse focus)
if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) {
if (event.type == SDL_MOUSEMOTION) {
renderer->getCameraController()->processMouseMotion(event.motion);
}
else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) {
renderer->getCameraController()->processMouseButton(event.button);
}
else if (event.type == SDL_MOUSEWHEEL) {
renderer->getCameraController()->processMouseWheel(static_cast<float>(event.wheel.y));
}
}
// Handle window events
if (event.type == SDL_QUIT) {
window->setShouldClose(true);
}
else if (event.type == SDL_WINDOWEVENT) {
if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
int newWidth = event.window.data1;
int newHeight = event.window.data2;
window->setSize(newWidth, newHeight);
// Vulkan viewport set in command buffer, not globally
if (renderer && renderer->getCamera()) {
renderer->getCamera()->setAspectRatio(static_cast<float>(newWidth) / newHeight);
}
// Notify addons so UI layouts can adapt to the new size
if (addonManager_)
addonManager_->fireEvent("DISPLAY_SIZE_CHANGED");
}
}
// Debug controls
else if (event.type == SDL_KEYDOWN) {
// Skip non-function-key input when UI (chat) has keyboard focus
bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard;
auto sc = event.key.keysym.scancode;
bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12);
if (uiHasKeyboard && !isFKey) {
continue; // Let ImGui handle the keystroke
}
// F1: Toggle performance HUD
if (event.key.keysym.scancode == SDL_SCANCODE_F1) {
if (renderer && renderer->getPerformanceHUD()) {
renderer->getPerformanceHUD()->toggle();
bool enabled = renderer->getPerformanceHUD()->isEnabled();
LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF");
}
}
// F4: Toggle shadows
else if (event.key.keysym.scancode == SDL_SCANCODE_F4) {
if (renderer) {
bool enabled = !renderer->areShadowsEnabled();
renderer->setShadowsEnabled(enabled);
LOG_INFO("Shadows: ", enabled ? "ON" : "OFF");
}
}
// F8: Debug WMO floor at current position
else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) {
if (renderer && renderer->getWMORenderer()) {
glm::vec3 pos = renderer->getCharacterPosition();
LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")");
renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z);
}
}
}
}
// Update input
Input::getInstance().update();
// Update application state
try {
update(deltaTime);
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::update (state=", static_cast<int>(state),
", dt=", deltaTime, "): ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::update (state=", static_cast<int>(state),
", dt=", deltaTime, "): ", e.what());
throw;
}
// Render
try {
render();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::render (state=", static_cast<int>(state), "): ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::render (state=", static_cast<int>(state), "): ", e.what());
throw;
}
// Swap buffers
try {
window->swapBuffers();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during swapBuffers: ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during swapBuffers: ", e.what());
throw;
}
// Exit gracefully on GPU device lost (unrecoverable)
if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) {
LOG_ERROR("GPU device lost — exiting application");
window->setShouldClose(true);
}
// Soft frame rate cap when vsync is off to prevent 100% CPU usage.
// Target ~240 FPS max (~4.2ms per frame); vsync handles its own pacing.
if (!window->isVsyncEnabled() && deltaTime < 0.004f) {
float sleepMs = (0.004f - deltaTime) * 1000.0f;
if (sleepMs > 0.5f)
std::this_thread::sleep_for(std::chrono::microseconds(
static_cast<int64_t>(sleepMs * 900.0f))); // 90% of target to account for sleep overshoot
}
}
} catch (...) {
watchdogRunning.store(false, std::memory_order_release);
if (watchdogThread.joinable()) {
watchdogThread.join();
}
throw;
}
watchdogRunning.store(false, std::memory_order_release);
if (watchdogThread.joinable()) {
watchdogThread.join();
}
LOG_INFO("Main loop ended");
}
void Application::shutdown() {
LOG_WARNING("Shutting down application...");
// Hide the window immediately so the OS doesn't think the app is frozen
// during the (potentially slow) resource cleanup below.
if (window && window->getSDLWindow()) {
SDL_HideWindow(window->getSDLWindow());
}
// Stop background world preloader before destroying AssetManager
if (worldLoader_) {
worldLoader_->cancelWorldPreload();
};
// Save floor cache before renderer is destroyed
if (renderer && renderer->getWMORenderer()) {
size_t cacheSize = renderer->getWMORenderer()->getFloorCacheSize();
if (cacheSize > 0) {
LOG_WARNING("Saving WMO floor cache (", cacheSize, " entries)...");
renderer->getWMORenderer()->saveFloorCache();
LOG_WARNING("Floor cache saved.");
}
}
// Explicitly shut down the renderer before destroying it — this ensures
// all sub-renderers free their VMA allocations in the correct order,
// before VkContext::shutdown() calls vmaDestroyAllocator().
LOG_WARNING("Shutting down renderer...");
if (renderer) {
renderer->shutdown();
}
LOG_WARNING("Renderer shutdown complete, resetting...");
renderer.reset();
// Shutdown audio coordinator after renderer (renderer may reference audio during shutdown)
if (audioCoordinator_) {
audioCoordinator_->shutdown();
}
audioCoordinator_.reset();
LOG_WARNING("Resetting world...");
world.reset();
LOG_WARNING("Resetting gameHandler...");
gameHandler.reset();
gameServices_ = {};
LOG_WARNING("Resetting authHandler...");
authHandler.reset();
LOG_WARNING("Resetting assetManager...");
assetManager.reset();
LOG_WARNING("Resetting uiManager...");
uiManager.reset();
LOG_WARNING("Resetting window...");
window.reset();
running = false;
LOG_WARNING("Application shutdown complete");
}
void Application::setState(AppState newState) {
if (state == newState) {
return;
}
LOG_INFO("State transition: ", static_cast<int>(state), " -> ", static_cast<int>(newState));
state = newState;
// Handle state transitions
switch (newState) {
case AppState::AUTHENTICATION:
// Show auth screen
break;
case AppState::REALM_SELECTION:
// Show realm screen
break;
case AppState::CHARACTER_CREATION:
// Show character create screen
break;
case AppState::CHARACTER_SELECTION:
// Show character screen
if (uiManager && assetManager) {
uiManager->getCharacterScreen().setAssetManager(assetManager.get());
}
// Ensure no stale in-world player model leaks into the next login attempt.
// If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync.
if (addonManager_ && addonsLoaded_) {
addonManager_->fireEvent("PLAYER_LEAVING_WORLD");
addonManager_->saveAllSavedVariables();
}
npcsSpawned = false;
playerCharacterSpawned = false;
addonsLoaded_ = false;
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
wasAutoAttacking_ = false;
if (worldLoader_) worldLoader_->resetLoadedMap();
spawnedPlayerGuid_ = 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
if (renderer && renderer->getCharacterRenderer()) {
uint32_t oldInst = renderer->getCharacterInstanceId();
if (oldInst > 0) {
renderer->setCharacterFollow(0);
renderer->clearMount();
renderer->getCharacterRenderer()->removeInstance(oldInst);
}
}
break;
case AppState::IN_GAME: {
// Wire up movement opcodes from camera controller
if (renderer && renderer->getCameraController()) {
auto* cc = renderer->getCameraController();
cc->setMovementCallback([this](uint32_t opcode) {
if (gameHandler) {
gameHandler->sendMovement(static_cast<game::Opcode>(opcode));
}
});
cc->setStandUpCallback([this]() {
if (gameHandler) {
gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND)
}
});
cc->setAutoFollowCancelCallback([this]() {
if (gameHandler) {
gameHandler->cancelFollow();
}
});
cc->setUseWoWSpeed(true);
}
if (gameHandler) {
gameHandler->setMeleeSwingCallback([this]() {
if (renderer) {
renderer->triggerMeleeSwing();
}
});
gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) {
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
}
});
gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) {
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->triggerShake(magnitude, frequency, duration);
}
});
gameHandler->setAutoFollowCallback([this](const glm::vec3* renderPos) {
if (renderer && renderer->getCameraController()) {
if (renderPos) {
renderer->getCameraController()->setAutoFollow(renderPos);
} else {
renderer->getCameraController()->cancelAutoFollow();
}
}
});
}
// Load quest marker models
loadQuestMarkerModels();
break;
}
case AppState::DISCONNECTED:
// Back to auth
break;
}
}
void Application::reloadExpansionData() {
if (!expansionRegistry_ || !gameHandler) return;
auto* profile = expansionRegistry_->getActive();
if (!profile) return;
LOG_INFO("Reloading expansion data for: ", profile->name);
std::string opcodesPath = profile->dataPath + "/opcodes.json";
if (!gameHandler->getOpcodeTable().loadFromJson(opcodesPath)) {
LOG_ERROR("Failed to load opcodes from ", opcodesPath);
}
game::setActiveOpcodeTable(&gameHandler->getOpcodeTable());
std::string updateFieldsPath = profile->dataPath + "/update_fields.json";
if (!gameHandler->getUpdateFieldTable().loadFromJson(updateFieldsPath)) {
LOG_ERROR("Failed to load update fields from ", updateFieldsPath);
}
game::setActiveUpdateFieldTable(&gameHandler->getUpdateFieldTable());
gameHandler->setPacketParsers(game::createPacketParsers(profile->id));
if (dbcLayout_) {
std::string dbcLayoutsPath = profile->dataPath + "/dbc_layouts.json";
if (!dbcLayout_->loadFromJson(dbcLayoutsPath)) {
LOG_ERROR("Failed to load DBC layouts from ", dbcLayoutsPath);
}
pipeline::setActiveDBCLayout(dbcLayout_.get());
}
// Update expansion data path for CSV DBC lookups and clear DBC cache
if (assetManager && !profile->dataPath.empty()) {
assetManager->setExpansionDataPath(profile->dataPath);
assetManager->clearDBCCache();
}
// Reset map name cache so it reloads from new expansion's Map.dbc
if (worldLoader_) worldLoader_->resetMapNameCache();
// Reset game handler DBC caches so they reload from new expansion data
if (gameHandler) {
gameHandler->resetDbcCaches();
}
// Rebuild creature display lookups with the new expansion's DBC layout
if (entitySpawner_) entitySpawner_->rebuildLookups();
}
void Application::logoutToLogin() {
LOG_INFO("Logout requested");
// Disconnect TransportManager from WMORenderer before tearing down
if (gameHandler && gameHandler->getTransportManager()) {
gameHandler->getTransportManager()->setWMORenderer(nullptr);
}
if (gameHandler) {
gameHandler->disconnect();
}
// --- Per-session flags ---
npcsSpawned = false;
playerCharacterSpawned = false;
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
wasAutoAttacking_ = false;
if (worldLoader_) worldLoader_->resetLoadedMap();
lastTaxiFlight_ = false;
taxiLandingClampTimer_ = 0.0f;
worldEntryMovementGraceTimer_ = 0.0f;
facingSendCooldown_ = 0.0f;
lastSentCanonicalYaw_ = 1000.0f;
taxiStreamCooldown_ = 0.0f;
idleYawned_ = false;
// --- Charge state ---
chargeActive_ = false;
chargeTimer_ = 0.0f;
chargeDuration_ = 0.0f;
chargeTargetGuid_ = 0;
// --- Player identity ---
spawnedPlayerGuid_ = 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
// --- Reset all EntitySpawner state (mount, creatures, players, GOs, queues, caches) ---
if (entitySpawner_) entitySpawner_->resetAllState();
world.reset();
if (renderer) {
renderer->resetCombatVisualState();
// Remove old player model so it doesn't persist into next session
if (auto* charRenderer = renderer->getCharacterRenderer()) {
charRenderer->removeInstance(1);
}
// Clear all world geometry renderers
if (auto* wmo = renderer->getWMORenderer()) {
wmo->clearInstances();
}
if (auto* m2 = renderer->getM2Renderer()) {
m2->clear();
}
// Clear terrain tile tracking + water surfaces so next world entry starts fresh.
// Use softReset() instead of unloadAll() to avoid blocking on worker thread joins.
if (auto* terrain = renderer->getTerrainManager()) {
terrain->softReset();
}
if (auto* questMarkers = renderer->getQuestMarkerRenderer()) {
questMarkers->clear();
}
renderer->clearMount();
renderer->setCharacterFollow(0);
if (auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr) {
music->stopMusic(0.0f);
}
}
// Clear stale realm/character selection so switching servers starts fresh
if (uiManager) {
uiManager->getRealmScreen().reset();
uiManager->getCharacterScreen().reset();
}
setState(AppState::AUTHENTICATION);
}
void Application::update(float deltaTime) {
const char* updateCheckpoint = "enter";
try {
// Update based on current state
updateCheckpoint = "state switch";
switch (state) {
case AppState::AUTHENTICATION:
updateCheckpoint = "auth: enter";
if (authHandler) {
authHandler->update(deltaTime);
}
break;
case AppState::REALM_SELECTION:
updateCheckpoint = "realm_selection: enter";
if (authHandler) {
authHandler->update(deltaTime);
}
break;
case AppState::CHARACTER_CREATION:
updateCheckpoint = "char_creation: enter";
if (gameHandler) {
gameHandler->update(deltaTime);
}
if (uiManager) {
uiManager->getCharacterCreateScreen().update(deltaTime);
}
break;
case AppState::CHARACTER_SELECTION:
updateCheckpoint = "char_selection: enter";
if (gameHandler) {
gameHandler->update(deltaTime);
}
break;
case AppState::IN_GAME: {
updateCheckpoint = "in_game: enter";
const char* inGameStep = "begin";
try {
auto runInGameStage = [&](const char* stageName, auto&& fn) {
auto stageStart = std::chrono::steady_clock::now();
try {
fn();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during IN_GAME update stage '", stageName, "': ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during IN_GAME update stage '", stageName, "': ", e.what());
throw;
}
auto stageEnd = std::chrono::steady_clock::now();
float stageMs = std::chrono::duration<float, std::milli>(stageEnd - stageStart).count();
if (stageMs > 50.0f) {
LOG_WARNING("SLOW update stage '", stageName, "': ", stageMs, "ms");
}
};
inGameStep = "gameHandler update";
updateCheckpoint = "in_game: gameHandler update";
runInGameStage("gameHandler->update", [&] {
if (gameHandler) {
gameHandler->update(deltaTime);
}
});
if (addonManager_ && addonsLoaded_) {
addonManager_->update(deltaTime);
}
// Always unsheath on combat engage.
inGameStep = "auto-unsheathe";
updateCheckpoint = "in_game: auto-unsheathe";
if (gameHandler) {
const bool autoAttacking = gameHandler->isAutoAttacking();
if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) {
appearanceComposer_->setWeaponsSheathed(false);
appearanceComposer_->loadEquippedWeapons();
}
wasAutoAttacking_ = autoAttacking;
}
// Toggle weapon sheathe state with Z (ignored while UI captures keyboard).
inGameStep = "weapon-toggle input";
updateCheckpoint = "in_game: weapon-toggle input";
{
const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
auto& input = Input::getInstance();
if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z) && appearanceComposer_) {
appearanceComposer_->toggleWeaponsSheathed();
appearanceComposer_->loadEquippedWeapons();
}
}
inGameStep = "world update";
updateCheckpoint = "in_game: world update";
runInGameStage("world->update", [&] {
if (world) {
world->update(deltaTime);
}
});
inGameStep = "spawn/equipment queues";
updateCheckpoint = "in_game: spawn/equipment queues";
runInGameStage("spawn/equipment queues", [&] {
if (entitySpawner_) entitySpawner_->update();
if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) {
cr->processPendingNormalMaps(4);
}
});
// Self-heal missing creature visuals: if a nearby UNIT exists in
// entity state but has no render instance, queue a spawn retry.
inGameStep = "creature resync scan";
updateCheckpoint = "in_game: creature resync scan";
if (gameHandler) {
static float creatureResyncTimer = 0.0f;
creatureResyncTimer += deltaTime;
if (creatureResyncTimer >= 3.0f) {
creatureResyncTimer = 0.0f;
glm::vec3 playerPos(0.0f);
bool havePlayerPos = false;
uint64_t playerGuid = gameHandler->getPlayerGuid();
if (auto playerEntity = gameHandler->getEntityManager().getEntity(playerGuid)) {
playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
havePlayerPos = true;
}
const float kResyncRadiusSq = 260.0f * 260.0f;
for (const auto& pair : gameHandler->getEntityManager().getEntities()) {
uint64_t guid = pair.first;
const auto& entity = pair.second;
if (!entity || guid == playerGuid) continue;
if (entity->getType() != game::ObjectType::UNIT) continue;
auto unit = std::dynamic_pointer_cast<game::Unit>(entity);
if (!unit || unit->getDisplayId() == 0) continue;
if (entitySpawner_->isCreatureSpawned(guid) || entitySpawner_->isCreaturePending(guid)) continue;
if (havePlayerPos) {
glm::vec3 pos(unit->getX(), unit->getY(), unit->getZ());
glm::vec3 delta = pos - playerPos;
float distSq = glm::dot(delta, delta);
if (distSq > kResyncRadiusSq) continue;
}
float retryScale = 1.0f;
{
using game::fieldIndex; using game::UF;
uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
if (si != 0xFFFF) {
uint32_t raw = unit->getField(si);
if (raw != 0) {
float s2 = 1.0f;
std::memcpy(&s2, &raw, sizeof(float));
if (s2 > 0.01f && s2 < 100.0f) retryScale = s2;
}
}
}
entitySpawner_->queueCreatureSpawn(guid, unit->getDisplayId(),
unit->getX(), unit->getY(), unit->getZ(),
unit->getOrientation(), retryScale);
}
}
}
inGameStep = "gameobject/transport queues";
updateCheckpoint = "in_game: gameobject/transport queues";
runInGameStage("gameobject/transport queues", [&] {
// GO/transport queues handled by entitySpawner_->update() above
});
inGameStep = "pending mount";
updateCheckpoint = "in_game: pending mount";
runInGameStage("processPendingMount", [&] {
// Mount processing handled by entitySpawner_->update() above
});
// Update 3D quest markers above NPCs
inGameStep = "quest markers";
updateCheckpoint = "in_game: quest markers";
runInGameStage("updateQuestMarkers", [&] {
updateQuestMarkers();
});
// Sync server run speed to camera controller
inGameStep = "post-update sync";
updateCheckpoint = "in_game: post-update sync";
runInGameStage("post-update sync", [&] {
if (renderer && gameHandler && renderer->getCameraController()) {
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed());
renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed());
renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed());
renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed());
renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed());
renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed());
renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate());
renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted());
renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled());
renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling());
renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking());
renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying());
renderer->getCameraController()->setHoverActive(gameHandler->isHovering());
// Sync camera forward pitch to movement packets during flight / swimming.
// The server writes the pitch field when FLYING or SWIMMING flags are set;
// without this sync it would always be 0 (horizontal), causing other
// players to see the character flying flat even when pitching up/down.
if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) {
if (auto* cam = renderer->getCamera()) {
glm::vec3 fwd = cam->getForward();
float len = glm::length(fwd);
if (len > 1e-4f) {
float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f));
gameHandler->setMovementPitch(pitchRad);
// Tilt the mount/character model to match flight direction
// (taxi flight uses setTaxiOrientationCallback for this instead)
if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) {
renderer->setMountPitchRoll(pitchRad, 0.0f);
}
}
}
} else if (gameHandler->isMounted()) {
// Reset mount pitch when not flying
renderer->setMountPitchRoll(0.0f, 0.0f);
}
}
bool onTaxi = gameHandler &&
(gameHandler->isOnTaxiFlight() ||
gameHandler->isTaxiMountActive() ||
gameHandler->isTaxiActivationPending());
bool onTransportNow = gameHandler && gameHandler->isOnTransport();
// Clear stale client-side transport state when the tracked transport no longer exists.
if (onTransportNow && gameHandler->getTransportManager()) {
auto* currentTracked = gameHandler->getTransportManager()->getTransport(
gameHandler->getPlayerTransportGuid());
if (!currentTracked) {
gameHandler->clearPlayerTransport();
onTransportNow = false;
}
}
// M2 transports (trams) use position-delta approach: player keeps normal
// movement and the transport's frame-to-frame delta is applied on top.
// Only WMO transports (ships) use full external-driven mode.
bool isM2Transport = false;
if (onTransportNow && gameHandler->getTransportManager()) {
auto* tr = gameHandler->getTransportManager()->getTransport(gameHandler->getPlayerTransportGuid());
isM2Transport = (tr && tr->isM2);
}
bool onWMOTransport = onTransportNow && !isM2Transport;
if (worldEntryMovementGraceTimer_ > 0.0f) {
worldEntryMovementGraceTimer_ -= deltaTime;
// Clear stale movement from before teleport each frame
// until grace period expires (keys may still be held)
if (renderer && renderer->getCameraController())
renderer->getCameraController()->clearMovementInputs();
}
// Hearth teleport: keep player frozen until terrain loads at destination
if (hearthTeleportPending_ && renderer && renderer->getTerrainManager()) {
hearthTeleportTimer_ -= deltaTime;
auto terrainH = renderer->getTerrainManager()->getHeightAt(
hearthTeleportPos_.x, hearthTeleportPos_.y);
if (terrainH || hearthTeleportTimer_ <= 0.0f) {
// Terrain loaded (or timeout) — snap to floor and release
if (terrainH) {
hearthTeleportPos_.z = *terrainH + 0.5f;
renderer->getCameraController()->teleportTo(hearthTeleportPos_);
}
renderer->getCameraController()->setExternalFollow(false);
worldEntryMovementGraceTimer_ = 1.0f;
hearthTeleportPending_ = false;
LOG_INFO("Unstuck hearth: terrain loaded, player released",
terrainH ? "" : " (timeout)");
}
}
if (renderer && renderer->getCameraController()) {
const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_;
// Keep physics frozen (externalFollow) during landing clamp when terrain
// hasn't loaded yet — prevents gravity from pulling player through void.
bool hearthFreeze = hearthTeleportPending_;
bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f &&
worldEntryMovementGraceTimer_ <= 0.0f &&
!gameHandler->isMounted();
renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive || hearthFreeze);
renderer->getCameraController()->setExternalMoving(externallyDrivenMotion);
if (externallyDrivenMotion) {
// Drop any stale local movement toggles while server drives taxi motion.
renderer->getCameraController()->clearMovementInputs();
taxiLandingClampTimer_ = 0.0f;
}
if (lastTaxiFlight_ && !onTaxi) {
renderer->getCameraController()->clearMovementInputs();
// Keep clamping until terrain loads at landing position.
// Timer only counts down once a valid floor is found.
taxiLandingClampTimer_ = 2.0f;
}
if (landingClampActive) {
if (renderer && gameHandler) {
glm::vec3 p = renderer->getCharacterPosition();
std::optional<float> terrainFloor;
std::optional<float> wmoFloor;
std::optional<float> m2Floor;
if (renderer->getTerrainManager()) {
terrainFloor = renderer->getTerrainManager()->getHeightAt(p.x, p.y);
}
if (renderer->getWMORenderer()) {
// Probe from above so we can recover when current Z is already below floor.
wmoFloor = renderer->getWMORenderer()->getFloorHeight(p.x, p.y, p.z + 40.0f);
}
if (renderer->getM2Renderer()) {
// Include M2 floors (bridges/platforms) in landing recovery.
m2Floor = renderer->getM2Renderer()->getFloorHeight(p.x, p.y, p.z + 40.0f);
}
std::optional<float> targetFloor;
if (terrainFloor) targetFloor = terrainFloor;
if (wmoFloor && (!targetFloor || *wmoFloor > *targetFloor)) targetFloor = wmoFloor;
if (m2Floor && (!targetFloor || *m2Floor > *targetFloor)) targetFloor = m2Floor;
if (targetFloor) {
// Floor found — snap player to it and start countdown to release
float targetZ = *targetFloor + 0.10f;
if (std::abs(p.z - targetZ) > 0.05f) {
p.z = targetZ;
renderer->getCharacterPosition() = p;
glm::vec3 canonical = core::coords::renderToCanonical(p);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
}
taxiLandingClampTimer_ -= deltaTime;
}
// No floor found: don't decrement timer, keep player frozen until terrain loads
}
}
bool idleOrbit = renderer->getCameraController()->isIdleOrbit();
if (idleOrbit && !idleYawned_ && renderer) {
renderer->playEmote("yawn");
idleYawned_ = true;
} else if (!idleOrbit) {
idleYawned_ = false;
}
}
if (renderer) {
renderer->setTaxiFlight(onTaxi);
}
if (renderer && renderer->getTerrainManager()) {
renderer->getTerrainManager()->setStreamingEnabled(true);
// Taxi flights move fast (32 u/s) — load further ahead so terrain is ready
// before the camera arrives. Keep updates frequent to spot new tiles early.
renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.033f : 0.033f);
renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4);
renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7);
renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi);
}
lastTaxiFlight_ = onTaxi;
// Sync character render position ↔ canonical WoW coords each frame
if (renderer && gameHandler) {
// For position sync branching, only WMO transports use the dedicated
// onTransport branch. M2 transports use the normal movement else branch
// with a position-delta correction applied on top.
bool onTransport = onWMOTransport;
static bool wasOnTransport = false;
bool onTransportNowDbg = gameHandler->isOnTransport();
if (onTransportNowDbg != wasOnTransport) {
LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg,
" isM2=", isM2Transport,
" guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec);
wasOnTransport = onTransportNowDbg;
}
if (onTaxi) {
auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid());
glm::vec3 canonical(0.0f);
bool haveCanonical = false;
if (playerEntity) {
canonical = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
haveCanonical = true;
} else {
// Fallback for brief entity gaps during taxi start/updates:
// movementInfo is still updated by client taxi simulation.
const auto& move = gameHandler->getMovementInfo();
canonical = glm::vec3(move.x, move.y, move.z);
haveCanonical = true;
}
if (haveCanonical) {
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->getCharacterPosition() = renderPos;
if (renderer->getCameraController()) {
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
if (followTarget) {
*followTarget = renderPos;
}
}
}
} else if (onTransport) {
// WMO transport mode (ships): compose world position from transform + local offset
glm::vec3 canonical = gameHandler->getComposedWorldPosition();
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->getCharacterPosition() = renderPos;
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
if (renderer->getCameraController()) {
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
if (followTarget) {
*followTarget = renderPos;
}
}
} else if (chargeActive_) {
// Warrior Charge: lerp position from start to end using smoothstep
chargeTimer_ += deltaTime;
float t = std::min(chargeTimer_ / chargeDuration_, 1.0f);
// smoothstep for natural acceleration/deceleration
float s = t * t * (3.0f - 2.0f * t);
glm::vec3 renderPos = chargeStartPos_ + (chargeEndPos_ - chargeStartPos_) * s;
renderer->getCharacterPosition() = renderPos;
// Keep facing toward target and emit charge effect
glm::vec3 dir = chargeEndPos_ - chargeStartPos_;
float dirLenSq = glm::dot(dir, dir);
if (dirLenSq > 1e-4f) {
dir *= glm::inversesqrt(dirLenSq);
float yawDeg = glm::degrees(std::atan2(dir.x, dir.y));
renderer->setCharacterYaw(yawDeg);
renderer->emitChargeEffect(renderPos, dir);
}
// Sync to game handler
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
// Update camera follow target
if (renderer->getCameraController()) {
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
if (followTarget) {
*followTarget = renderPos;
}
}
// Charge complete
if (t >= 1.0f) {
chargeActive_ = false;
renderer->setCharging(false);
renderer->stopChargeEffect();
renderer->getCameraController()->setExternalFollow(false);
renderer->getCameraController()->setExternalMoving(false);
// Snap to melee range of target's CURRENT position (it may have moved)
if (chargeTargetGuid_ != 0) {
auto targetEntity = gameHandler->getEntityManager().getEntity(chargeTargetGuid_);
if (targetEntity) {
glm::vec3 targetCanonical(targetEntity->getX(), targetEntity->getY(), targetEntity->getZ());
glm::vec3 targetRender = core::coords::canonicalToRender(targetCanonical);
glm::vec3 toTarget = targetRender - renderPos;
float dSq = glm::dot(toTarget, toTarget);
if (dSq > 2.25f) {
// Place us 1.5 units from target (well within 8-unit melee range)
glm::vec3 snapPos = targetRender - toTarget * (1.5f * glm::inversesqrt(dSq));
renderer->getCharacterPosition() = snapPos;
glm::vec3 snapCanonical = core::coords::renderToCanonical(snapPos);
gameHandler->setPosition(snapCanonical.x, snapCanonical.y, snapCanonical.z);
if (renderer->getCameraController()) {
glm::vec3* ft = renderer->getCameraController()->getFollowTargetMutable();
if (ft) *ft = snapPos;
}
}
}
gameHandler->startAutoAttack(chargeTargetGuid_);
renderer->triggerMeleeSwing();
}
// Send movement heartbeat so server knows our new position
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
}
} else {
glm::vec3 renderPos = renderer->getCharacterPosition();
// M2 transport riding: resolve in canonical space and lock once per frame.
// This avoids visible jitter from mixed render/canonical delta application.
if (isM2Transport && gameHandler->getTransportManager()) {
auto* tr = gameHandler->getTransportManager()->getTransport(
gameHandler->getPlayerTransportGuid());
if (tr) {
// Keep passenger locked to elevator vertical motion while grounded.
// Without this, floor clamping can hold world-Z static unless the
// player is jumping, which makes lifts appear to not move vertically.
glm::vec3 tentativeCanonical = core::coords::renderToCanonical(renderPos);
glm::vec3 localOffset = gameHandler->getPlayerTransportOffset();
localOffset.x = tentativeCanonical.x - tr->position.x;
localOffset.y = tentativeCanonical.y - tr->position.y;
if (renderer->getCameraController() &&
!renderer->getCameraController()->isGrounded()) {
// While airborne (jump/fall), allow local Z offset to change.
localOffset.z = tentativeCanonical.z - tr->position.z;
}
gameHandler->setPlayerTransportOffset(localOffset);
glm::vec3 lockedCanonical = tr->position + localOffset;
renderPos = core::coords::canonicalToRender(lockedCanonical);
renderer->getCharacterPosition() = renderPos;
}
}
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
// Sync orientation: camera yaw (degrees) → WoW orientation (radians)
float yawDeg = renderer->getCharacterYaw();
// Keep all game-side orientation in canonical space.
// We historically sent serverYaw = radians(yawDeg - 90). With the new
// canonical<->server mapping (serverYaw = PI/2 - canonicalYaw), the
// equivalent canonical yaw is radians(180 - yawDeg).
float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg));
gameHandler->setOrientation(canonicalYaw);
// Send MSG_MOVE_SET_FACING when the player changes facing direction
// (e.g. via mouse-look). Without this, the server predicts movement in
// the old facing and position-corrects on the next heartbeat — the
// micro-teleporting the GM observed.
// Skip while keyboard-turning: the server tracks that via TURN_LEFT/RIGHT flags.
facingSendCooldown_ -= deltaTime;
const auto& mi = gameHandler->getMovementInfo();
constexpr uint32_t kTurnFlags =
static_cast<uint32_t>(game::MovementFlags::TURN_LEFT) |
static_cast<uint32_t>(game::MovementFlags::TURN_RIGHT);
bool keyboardTurning = (mi.flags & kTurnFlags) != 0;
if (!keyboardTurning && facingSendCooldown_ <= 0.0f) {
float yawDiff = core::coords::normalizeAngleRad(canonicalYaw - lastSentCanonicalYaw_);
if (std::abs(yawDiff) > glm::radians(3.0f)) {
gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING);
lastSentCanonicalYaw_ = canonicalYaw;
facingSendCooldown_ = 0.1f; // max 10 Hz
}
}
// Client-side transport boarding detection (for M2 transports like trams
// and lifts where the server doesn't send transport attachment data).
// Thunder Bluff elevators use model origins that can be far from the deck
// the player stands on, so they need wider attachment bounds.
if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) {
auto* tm = gameHandler->getTransportManager();
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f;
constexpr float kM2BoardVertDist = 15.0f;
constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f;
constexpr float kTbLiftBoardVertDist = 14.0f;
uint64_t bestGuid = 0;
float bestScore = 1e30f;
for (auto& [guid, transport] : tm->getTransports()) {
if (!transport.isM2) continue;
const bool isThunderBluffLift =
(transport.entry >= 20649u && transport.entry <= 20657u);
const float maxHorizDistSq = isThunderBluffLift
? kTbLiftBoardHorizDistSq
: kM2BoardHorizDistSq;
const float maxVertDist = isThunderBluffLift
? kTbLiftBoardVertDist
: kM2BoardVertDist;
glm::vec3 diff = playerCanonical - transport.position;
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
float vertDist = std::abs(diff.z);
if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) {
float score = horizDistSq + vertDist * vertDist;
if (score < bestScore) {
bestScore = score;
bestGuid = guid;
}
}
}
if (bestGuid != 0) {
auto* tr = tm->getTransport(bestGuid);
if (tr) {
gameHandler->setPlayerOnTransport(bestGuid, playerCanonical - tr->position);
LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, bestGuid, std::dec);
}
}
}
// M2 transport disembark: player walked far enough from transport center
if (isM2Transport && gameHandler->getTransportManager()) {
auto* tm = gameHandler->getTransportManager();
auto* tr = tm->getTransport(gameHandler->getPlayerTransportGuid());
if (tr) {
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
glm::vec3 diff = playerCanonical - tr->position;
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
const bool isThunderBluffLift =
(tr->entry >= 20649u && tr->entry <= 20657u);
constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f;
constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f;
constexpr float kM2DisembarkVertDist = 18.0f;
constexpr float kTbLiftDisembarkVertDist = 16.0f;
const float disembarkHorizDistSq = isThunderBluffLift
? kTbLiftDisembarkHorizDistSq
: kM2DisembarkHorizDistSq;
const float disembarkVertDist = isThunderBluffLift
? kTbLiftDisembarkVertDist
: kM2DisembarkVertDist;
if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) {
gameHandler->clearPlayerTransport();
LOG_DEBUG("M2 transport disembark");
}
}
}
}
}
});
// Keep creature render instances aligned with authoritative entity positions.
// This prevents desync where target circles move with server entities but
// creature models remain at stale spawn positions.
inGameStep = "creature render sync";
updateCheckpoint = "in_game: creature render sync";
auto creatureSyncStart = std::chrono::steady_clock::now();
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
auto* charRenderer = renderer->getCharacterRenderer();
static float npcWeaponRetryTimer = 0.0f;
npcWeaponRetryTimer += deltaTime;
const bool npcWeaponRetryTick = (npcWeaponRetryTimer >= 1.0f);
if (npcWeaponRetryTick) npcWeaponRetryTimer = 0.0f;
int weaponAttachesThisTick = 0;
glm::vec3 playerPos(0.0f);
glm::vec3 playerRenderPos(0.0f);
bool havePlayerPos = false;
float playerCollisionRadius = 0.65f;
if (auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
playerRenderPos = core::coords::canonicalToRender(playerPos);
havePlayerPos = true;
glm::vec3 pc;
float pr = 0.0f;
if (getRenderBoundsForGuid(gameHandler->getPlayerGuid(), pc, pr)) {
playerCollisionRadius = std::clamp(pr * 0.35f, 0.45f, 1.1f);
}
}
const float syncRadiusSq = 320.0f * 320.0f;
auto& _creatureInstances = entitySpawner_->getCreatureInstances();
auto& _creatureWeaponsAttached = entitySpawner_->getCreatureWeaponsAttached();
auto& _creatureWeaponAttachAttempts = entitySpawner_->getCreatureWeaponAttachAttempts();
auto& _creatureModelIds = entitySpawner_->getCreatureModelIds();
auto& _modelIdIsWolfLike = entitySpawner_->getModelIdIsWolfLike();
auto& _creatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache();
auto& _creatureSwimmingState = entitySpawner_->getCreatureSwimmingState();
auto& _creatureWalkingState = entitySpawner_->getCreatureWalkingState();
auto& _creatureFlyingState = entitySpawner_->getCreatureFlyingState();
auto& _creatureWasMoving = entitySpawner_->getCreatureWasMoving();
auto& _creatureWasSwimming = entitySpawner_->getCreatureWasSwimming();
auto& _creatureWasFlying = entitySpawner_->getCreatureWasFlying();
auto& _creatureWasWalking = entitySpawner_->getCreatureWasWalking();
for (const auto& [guid, instanceId] : _creatureInstances) {
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity || entity->getType() != game::ObjectType::UNIT) continue;
if (npcWeaponRetryTick &&
weaponAttachesThisTick < EntitySpawner::MAX_WEAPON_ATTACHES_PER_TICK &&
!_creatureWeaponsAttached.count(guid)) {
uint8_t attempts = 0;
auto itAttempts = _creatureWeaponAttachAttempts.find(guid);
if (itAttempts != _creatureWeaponAttachAttempts.end()) attempts = itAttempts->second;
if (attempts < 30) {
weaponAttachesThisTick++;
if (entitySpawner_->tryAttachCreatureVirtualWeapons(guid, instanceId)) {
_creatureWeaponsAttached.insert(guid);
_creatureWeaponAttachAttempts.erase(guid);
} else {
_creatureWeaponAttachAttempts[guid] = static_cast<uint8_t>(attempts + 1);
}
}
}
// Distance check uses getLatestX/Y/Z (server-authoritative destination) to
// avoid false-culling entities that moved while getX/Y/Z was stale.
// Position sync still uses getX/Y/Z to preserve smooth interpolation for
// nearby entities; distant entities (> 150u) have planarDist≈0 anyway
// so the renderer remains driven correctly by creatureMoveCallback_.
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
float canonDistSq = 0.0f;
if (havePlayerPos) {
glm::vec3 d = latestCanonical - playerPos;
canonDistSq = glm::dot(d, d);
if (canonDistSq > syncRadiusSq) continue;
}
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
// Visual collision guard: keep hostile melee units from rendering inside the
// player's model while attacking. This is client-side only (no server position change).
// Only check for creatures within 8 units (melee range) — saves expensive
// getRenderBoundsForGuid/getModelData calls for distant creatures.
bool clipGuardEligible = false;
bool isCombatTarget = false;
if (havePlayerPos && canonDistSq < 64.0f) { // 8² = melee range
auto unit = std::static_pointer_cast<game::Unit>(entity);
const uint64_t currentTargetGuid = gameHandler->hasTarget() ? gameHandler->getTargetGuid() : 0;
const uint64_t autoAttackGuid = gameHandler->getAutoAttackTargetGuid();
isCombatTarget = (guid == currentTargetGuid || guid == autoAttackGuid);
clipGuardEligible = unit->getHealth() > 0 &&
(unit->isHostile() ||
gameHandler->isAggressiveTowardPlayer(guid) ||
isCombatTarget);
}
if (clipGuardEligible) {
float creatureCollisionRadius = 0.8f;
glm::vec3 cc;
float cr = 0.0f;
if (getRenderBoundsForGuid(guid, cc, cr)) {
creatureCollisionRadius = std::clamp(cr * 0.45f, 0.65f, 1.9f);
}
float minSep = std::max(playerCollisionRadius + creatureCollisionRadius, 1.9f);
if (isCombatTarget) {
// Stronger spacing for the actively engaged attacker to avoid bite-overlap.
minSep = std::max(minSep, 2.2f);
}
// Species/model-specific spacing for wolf-like creatures (their lunge anims
// often put head/torso inside the player capsule).
auto mit = _creatureModelIds.find(guid);
if (mit != _creatureModelIds.end()) {
uint32_t mid = mit->second;
auto wolfIt = _modelIdIsWolfLike.find(mid);
if (wolfIt == _modelIdIsWolfLike.end()) {
bool isWolf = false;
if (const auto* md = charRenderer->getModelData(mid)) {
std::string modelName = md->name;
std::transform(modelName.begin(), modelName.end(), modelName.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
isWolf = (modelName.find("wolf") != std::string::npos ||
modelName.find("worg") != std::string::npos);
}
wolfIt = _modelIdIsWolfLike.emplace(mid, isWolf).first;
}
if (wolfIt->second) {
minSep = std::max(minSep, 2.45f);
}
}
glm::vec2 d2(renderPos.x - playerRenderPos.x, renderPos.y - playerRenderPos.y);
float distSq2 = glm::dot(d2, d2);
if (distSq2 < (minSep * minSep)) {
glm::vec2 dir2(1.0f, 0.0f);
if (distSq2 > 1e-6f) {
dir2 = d2 * (1.0f / std::sqrt(distSq2));
}
glm::vec2 clamped2 = glm::vec2(playerRenderPos.x, playerRenderPos.y) + dir2 * minSep;
renderPos.x = clamped2.x;
renderPos.y = clamped2.y;
}
}
auto posIt = _creatureRenderPosCache.find(guid);
if (posIt == _creatureRenderPosCache.end()) {
charRenderer->setInstancePosition(instanceId, renderPos);
_creatureRenderPosCache[guid] = renderPos;
} else {
const glm::vec3 prevPos = posIt->second;
float ddx2 = renderPos.x - prevPos.x;
float ddy2 = renderPos.y - prevPos.y;
float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2;
float dz = std::abs(renderPos.z - prevPos.z);
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
const bool deadOrCorpse = unitPtr->getHealth() == 0;
const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f);
// isEntityMoving() reflects server-authoritative move state set by
// startMoveTo() in handleMonsterMove, regardless of distance-cull.
// This correctly detects movement for distant creatures (> 150u)
// where updateMovement() is not called and getX/Y/Z() stays stale.
// Use isActivelyMoving() (not isEntityMoving()) so the
// Run/Walk animation stops when the creature reaches its
// destination, rather than persisting through the dead-
// reckoning overrun window.
const bool entityIsMoving = entity->isActivelyMoving();
constexpr float kMoveThreshSq = 0.03f * 0.03f;
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
} else if (planarDistSq > kMoveThreshSq || dz > 0.08f) {
// Position changed in entity coords → drive renderer toward it.
float planarDist = std::sqrt(planarDistSq);
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
}
// When entity is moving but getX/Y/Z is stale (distance-culled),
// don't call moveInstanceTo — creatureMoveCallback_ already drove
// the renderer to the correct destination via the spline packet.
posIt->second = renderPos;
// Drive movement animation: Walk/Run/Swim (4/5/42) when moving,
// Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set.
// WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim.
// Only switch on transitions to avoid resetting animation time.
// Don't override Death (1) animation.
const bool isSwimmingNow = _creatureSwimmingState.count(guid) > 0;
const bool isWalkingNow = _creatureWalkingState.count(guid) > 0;
const bool isFlyingNow = _creatureFlyingState.count(guid) > 0;
bool prevMoving = _creatureWasMoving[guid];
bool prevSwimming = _creatureWasSwimming[guid];
bool prevFlying = _creatureWasFlying[guid];
bool prevWalking = _creatureWasWalking[guid];
// Trigger animation update on any locomotion-state transition, not just
// moving/idle — e.g. creature lands while still moving → FlyForward→Run,
// or server changes WALKING flag while creature is already running → Walk.
const bool stateChanged = (isMovingNow != prevMoving) ||
(isSwimmingNow != prevSwimming) ||
(isFlyingNow != prevFlying) ||
(isWalkingNow != prevWalking && isMovingNow);
if (stateChanged) {
_creatureWasMoving[guid] = isMovingNow;
_creatureWasSwimming[guid] = isSwimmingNow;
_creatureWasFlying[guid] = isFlyingNow;
_creatureWasWalking[guid] = isWalkingNow;
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) {
uint32_t targetAnim;
if (isMovingNow) {
if (isFlyingNow) targetAnim = 159u; // FlyForward
else if (isSwimmingNow) targetAnim = 42u; // Swim
else if (isWalkingNow) targetAnim = 4u; // Walk
else targetAnim = 5u; // Run
} else {
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
else targetAnim = 0u; // Stand
}
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
}
}
}
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
}
}
{
float csMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - creatureSyncStart).count();
if (csMs > 5.0f) {
LOG_WARNING("SLOW update stage 'creature render sync': ", csMs, "ms (",
entitySpawner_->getCreatureInstances().size(), " creatures)");
}
}
// --- Online player render sync (position, orientation, animation) ---
// Mirrors the creature sync loop above but without collision guard or
// weapon-attach logic. Without this, online players never transition
// back to Stand after movement stops ("run in place" bug).
auto playerSyncStart = std::chrono::steady_clock::now();
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
auto* charRenderer = renderer->getCharacterRenderer();
glm::vec3 pPos(0.0f);
bool havePPos = false;
if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ());
havePPos = true;
}
const float pSyncRadiusSq = 320.0f * 320.0f;
auto& _playerInstances = entitySpawner_->getPlayerInstances();
auto& _pCreatureWasMoving = entitySpawner_->getCreatureWasMoving();
auto& _pCreatureWasSwimming = entitySpawner_->getCreatureWasSwimming();
auto& _pCreatureWasFlying = entitySpawner_->getCreatureWasFlying();
auto& _pCreatureWasWalking = entitySpawner_->getCreatureWasWalking();
auto& _pCreatureSwimmingState = entitySpawner_->getCreatureSwimmingState();
auto& _pCreatureWalkingState = entitySpawner_->getCreatureWalkingState();
auto& _pCreatureFlyingState = entitySpawner_->getCreatureFlyingState();
auto& _pCreatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache();
for (const auto& [guid, instanceId] : _playerInstances) {
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity || entity->getType() != game::ObjectType::PLAYER) continue;
// Distance cull
if (havePPos) {
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
glm::vec3 d = latestCanonical - pPos;
if (glm::dot(d, d) > pSyncRadiusSq) continue;
}
// Position sync
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
auto posIt = _pCreatureRenderPosCache.find(guid);
if (posIt == _pCreatureRenderPosCache.end()) {
charRenderer->setInstancePosition(instanceId, renderPos);
_pCreatureRenderPosCache[guid] = renderPos;
} else {
const glm::vec3 prevPos = posIt->second;
float ddx2 = renderPos.x - prevPos.x;
float ddy2 = renderPos.y - prevPos.y;
float planarDistSq = ddx2 * ddx2 + ddy2 * ddy2;
float dz = std::abs(renderPos.z - prevPos.z);
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
const bool deadOrCorpse = unitPtr->getHealth() == 0;
const bool largeCorrection = (planarDistSq > 36.0f) || (dz > 3.0f);
const bool entityIsMoving = entity->isActivelyMoving();
constexpr float kMoveThreshSq2 = 0.03f * 0.03f;
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDistSq > kMoveThreshSq2 || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
} else if (planarDistSq > kMoveThreshSq2 || dz > 0.08f) {
float planarDist = std::sqrt(planarDistSq);
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
}
posIt->second = renderPos;
// Drive movement animation (same logic as creatures)
const bool isSwimmingNow = _pCreatureSwimmingState.count(guid) > 0;
const bool isWalkingNow = _pCreatureWalkingState.count(guid) > 0;
const bool isFlyingNow = _pCreatureFlyingState.count(guid) > 0;
bool prevMoving = _pCreatureWasMoving[guid];
bool prevSwimming = _pCreatureWasSwimming[guid];
bool prevFlying = _pCreatureWasFlying[guid];
bool prevWalking = _pCreatureWasWalking[guid];
const bool stateChanged = (isMovingNow != prevMoving) ||
(isSwimmingNow != prevSwimming) ||
(isFlyingNow != prevFlying) ||
(isWalkingNow != prevWalking && isMovingNow);
if (stateChanged) {
_pCreatureWasMoving[guid] = isMovingNow;
_pCreatureWasSwimming[guid] = isSwimmingNow;
_pCreatureWasFlying[guid] = isFlyingNow;
_pCreatureWasWalking[guid] = isWalkingNow;
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) {
uint32_t targetAnim;
if (isMovingNow) {
if (isFlyingNow) targetAnim = 159u; // FlyForward
else if (isSwimmingNow) targetAnim = 42u; // Swim
else if (isWalkingNow) targetAnim = 4u; // Walk
else targetAnim = 5u; // Run
} else {
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
else targetAnim = 0u; // Stand
}
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
}
}
}
// Orientation sync
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
}
}
{
float psMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - playerSyncStart).count();
if (psMs > 5.0f) {
LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (",
entitySpawner_->getPlayerInstances().size(), " players)");
}
}
// Movement heartbeat is sent from GameHandler::update() to avoid
// duplicate packets from multiple update loops.
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM inside AppState::IN_GAME at step '", inGameStep, "': ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception inside AppState::IN_GAME at step '", inGameStep, "': ", e.what());
throw;
}
break;
}
case AppState::DISCONNECTED:
// Handle disconnection
break;
}
// Process any pending world entry request via WorldLoader
if (worldLoader_ && state != AppState::DISCONNECTED) {
worldLoader_->processPendingEntry();
}
// Update renderer (camera, etc.) only when in-game
updateCheckpoint = "renderer update";
if (renderer && state == AppState::IN_GAME) {
auto rendererUpdateStart = std::chrono::steady_clock::now();
try {
renderer->update(deltaTime);
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::update stage 'renderer->update': ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::update stage 'renderer->update': ", e.what());
throw;
}
float ruMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - rendererUpdateStart).count();
if (ruMs > 50.0f) {
LOG_WARNING("SLOW update stage 'renderer->update': ", ruMs, "ms");
}
}
// Update UI
updateCheckpoint = "ui update";
if (uiManager) {
try {
uiManager->update(deltaTime);
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::update stage 'uiManager->update': ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::update stage 'uiManager->update': ", e.what());
throw;
}
}
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM in Application::update checkpoint '", updateCheckpoint, "': ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception in Application::update checkpoint '", updateCheckpoint, "': ", e.what());
throw;
}
}
void Application::render() {
if (!renderer) {
return;
}
renderer->beginFrame();
// Only render 3D world when in-game
if (state == AppState::IN_GAME) {
if (world) {
renderer->renderWorld(world.get(), gameHandler.get());
} else {
renderer->renderWorld(nullptr, gameHandler.get());
}
}
// Render performance HUD (within ImGui frame, before UI ends the frame)
if (renderer) {
renderer->renderHUD();
}
// Render UI on top (ends ImGui frame with ImGui::Render())
if (uiManager) {
uiManager->render(state, authHandler.get(), gameHandler.get());
}
renderer->endFrame();
}
void Application::setupUICallbacks() {
// Authentication screen callback
uiManager->getAuthScreen().setOnSuccess([this]() {
LOG_INFO("Authentication successful, transitioning to realm selection");
setState(AppState::REALM_SELECTION);
});
// Realm selection callback
uiManager->getRealmScreen().setOnRealmSelected([this](const std::string& realmName, const std::string& realmAddress) {
LOG_INFO("Realm selected: ", realmName, " (", realmAddress, ")");
// Parse realm address (format: "hostname:port")
std::string host = realmAddress;
uint16_t port = 8085; // Default world server port
size_t colonPos = realmAddress.find(':');
if (colonPos != std::string::npos) {
host = realmAddress.substr(0, colonPos);
try { port = static_cast<uint16_t>(std::stoi(realmAddress.substr(colonPos + 1))); }
catch (...) { LOG_WARNING("Invalid port in realm address: ", realmAddress); }
}
// Connect to world server
const auto& sessionKey = authHandler->getSessionKey();
std::string accountName = authHandler->getUsername();
if (accountName.empty()) {
LOG_WARNING("Auth username missing; falling back to TESTACCOUNT");
accountName = "TESTACCOUNT";
}
uint32_t realmId = 0;
uint16_t realmBuild = 0;
{
// WotLK AUTH_SESSION includes a RealmID field; some servers reject if it's wrong/zero.
const auto& realms = authHandler->getRealms();
for (const auto& r : realms) {
if (r.name == realmName && r.address == realmAddress) {
realmId = r.id;
realmBuild = r.build;
break;
}
}
LOG_INFO("Selected realmId=", realmId, " realmBuild=", realmBuild);
}
uint32_t clientBuild = 12340; // default WotLK
if (expansionRegistry_) {
auto* profile = expansionRegistry_->getActive();
if (profile) clientBuild = profile->worldBuild;
}
// Prefer realm-reported build when available (e.g. vanilla servers
// that report build 5875 in the realm list)
if (realmBuild != 0) {
clientBuild = realmBuild;
LOG_INFO("Using realm-reported build: ", clientBuild);
}
if (gameHandler->connect(host, port, sessionKey, accountName, clientBuild, realmId)) {
LOG_INFO("Connected to world server, transitioning to character selection");
setState(AppState::CHARACTER_SELECTION);
} else {
LOG_ERROR("Failed to connect to world server");
}
});
// Realm screen back button - return to login
uiManager->getRealmScreen().setOnBack([this]() {
if (authHandler) {
authHandler->disconnect();
}
uiManager->getRealmScreen().reset();
setState(AppState::AUTHENTICATION);
});
// Character selection callback
uiManager->getCharacterScreen().setOnCharacterSelected([this](uint64_t characterGuid) {
LOG_INFO("Character selected: GUID=0x", std::hex, characterGuid, std::dec);
// Always set the active character GUID
if (gameHandler) {
gameHandler->setActiveCharacterGuid(characterGuid);
}
// Keep CHARACTER_SELECTION active until world entry is fully loaded.
// This avoids exposing pre-load hitching before the loading screen/intro.
});
// Character create screen callbacks
uiManager->getCharacterCreateScreen().setOnCreate([this](const game::CharCreateData& data) {
pendingCreatedCharacterName_ = data.name; // Store name for auto-selection
gameHandler->createCharacter(data);
});
uiManager->getCharacterCreateScreen().setOnCancel([this]() {
setState(AppState::CHARACTER_SELECTION);
});
// Character create result callback
gameHandler->setCharCreateCallback([this](bool success, const std::string& msg) {
if (success) {
// Auto-select the newly created character
if (!pendingCreatedCharacterName_.empty()) {
uiManager->getCharacterScreen().selectCharacterByName(pendingCreatedCharacterName_);
pendingCreatedCharacterName_.clear();
}
setState(AppState::CHARACTER_SELECTION);
} else {
uiManager->getCharacterCreateScreen().setStatus(msg, true);
pendingCreatedCharacterName_.clear();
}
});
// Character login failure callback
gameHandler->setCharLoginFailCallback([this](const std::string& reason) {
LOG_WARNING("Character login failed: ", reason);
setState(AppState::CHARACTER_SELECTION);
uiManager->getCharacterScreen().setStatus("Login failed: " + reason, true);
});
// World entry callback (online mode) - load terrain when entering world
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) {
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"
" initial=", isInitialEntry);
if (renderer) {
renderer->resetCombatVisualState();
}
// Reconnect to the same map: terrain stays loaded but all online entities are stale.
// Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world.
uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF;
if (entitySpawner_ && mapId == currentLoadedMap && renderer && renderer->getTerrainManager() && isInitialEntry) {
LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)");
// Pending spawn queues and failure caches — clear so previously-failed GUIDs can retry.
// Dead creature guids will be re-populated from fresh server state.
entitySpawner_->clearAllQueues();
// Properly despawn all tracked instances from the renderer
entitySpawner_->despawnAllCreatures();
entitySpawner_->despawnAllPlayers();
entitySpawner_->despawnAllGameObjects();
// Update player position and re-queue nearby tiles (same logic as teleport)
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->getCharacterPosition() = renderPos;
if (renderer->getCameraController()) {
auto* ft = renderer->getCameraController()->getFollowTargetMutable();
if (ft) *ft = renderPos;
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(1.0f);
renderer->getCameraController()->suspendGravityFor(10.0f);
}
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
renderer->getTerrainManager()->processReadyTiles();
{
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> nearbyTiles;
nearbyTiles.reserve(289);
for (int dy = -8; dy <= 8; dy++)
for (int dx = -8; dx <= 8; dx++)
nearbyTiles.push_back({tileX + dx, tileY + dy});
renderer->getTerrainManager()->precacheTiles(nearbyTiles);
}
return;
}
// Same-map teleport (taxi landing, GM teleport, hearthstone on same continent):
if (mapId == currentLoadedMap && renderer && renderer->getTerrainManager()) {
// Check if teleport is far enough to need terrain loading (>500 render units)
glm::vec3 oldPos = renderer->getCharacterPosition();
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
float teleportDistSq = glm::dot(renderPos - oldPos, renderPos - oldPos);
bool farTeleport = (teleportDistSq > 500.0f * 500.0f);
if (farTeleport) {
// Far same-map teleport (hearthstone, etc.): defer full world reload
// to next frame to avoid blocking the packet handler for 20+ seconds.
LOG_WARNING("Far same-map teleport (dist=", std::sqrt(teleportDistSq),
"), deferring world reload to next frame");
// Update position immediately so the player doesn't keep moving at old location
renderer->getCharacterPosition() = renderPos;
if (renderer->getCameraController()) {
auto* ft = renderer->getCameraController()->getFollowTargetMutable();
if (ft) *ft = renderPos;
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(1.0f);
renderer->getCameraController()->suspendGravityFor(10.0f);
}
if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z);
return;
}
LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload");
// canonical and renderPos already computed above for distance check
renderer->getCharacterPosition() = renderPos;
if (renderer->getCameraController()) {
auto* ft = renderer->getCameraController()->getFollowTargetMutable();
if (ft) *ft = renderPos;
}
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
// Stop any movement that was active before the teleport
if (renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(0.5f);
}
// Kick off async upload for any tiles that finished background
// parsing. Use the bounded processReadyTiles() instead of
// processAllReadyTiles() to avoid multi-second main-thread stalls
// when many tiles are ready (the rest will finalize over subsequent
// frames via the normal terrain update loop).
renderer->getTerrainManager()->processReadyTiles();
// Queue all remaining tiles within the load radius (8 tiles = 17x17)
// at the new position. precacheTiles skips already-loaded/pending tiles,
// so this only enqueues tiles that aren't yet in the pipeline.
// This ensures background workers immediately start loading everything
// visible from the new position (hearthstone may land far from old location).
{
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> nearbyTiles;
nearbyTiles.reserve(289);
for (int dy = -8; dy <= 8; dy++)
for (int dx = -8; dx <= 8; dx++)
nearbyTiles.push_back({tileX + dx, tileY + dy});
renderer->getTerrainManager()->precacheTiles(nearbyTiles);
}
return;
}
// If a world load is already in progress (re-entrant call from
// gameHandler->update() processing SMSG_NEW_WORLD during warmup),
// defer this entry. The current load will pick it up when it finishes.
if (worldLoader_ && worldLoader_->isLoadingWorld()) {
LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)");
worldLoader_->setPendingEntry(mapId, x, y, z);
return;
}
// Full world loads are expensive and `loadOnlineWorldTerrain()` itself
// drives `gameHandler->update()` during warmup. Queue the load here so
// it runs after the current packet handler returns instead of recursing
// from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`.
LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")");
if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z);
});
auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional<float> {
std::optional<float> terrainFloor;
std::optional<float> wmoFloor;
std::optional<float> m2Floor;
if (renderer && renderer->getTerrainManager()) {
terrainFloor = renderer->getTerrainManager()->getHeightAt(x, y);
}
if (renderer && renderer->getWMORenderer()) {
wmoFloor = renderer->getWMORenderer()->getFloorHeight(x, y, probeZ);
}
if (renderer && renderer->getM2Renderer()) {
m2Floor = renderer->getM2Renderer()->getFloorHeight(x, y, probeZ);
}
std::optional<float> best;
if (terrainFloor) best = terrainFloor;
if (wmoFloor && (!best || *wmoFloor > *best)) best = wmoFloor;
if (m2Floor && (!best || *m2Floor > *best)) best = m2Floor;
return best;
};
auto clearStuckMovement = [this]() {
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
}
if (gameHandler) {
gameHandler->forceClearTaxiAndMovementState();
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_TURN);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_SWIM);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
}
};
auto syncTeleportedPositionToServer = [this](const glm::vec3& renderPos) {
if (!gameHandler) return;
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_STRAFE);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_STOP_TURN);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_HEARTBEAT);
};
auto forceServerTeleportCommand = [this](const glm::vec3& renderPos) {
if (!gameHandler) return;
// Server-authoritative reset first, then teleport.
gameHandler->sendChatMessage(game::ChatType::SAY, ".revive", "");
gameHandler->sendChatMessage(game::ChatType::SAY, ".dismount", "");
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
glm::vec3 serverPos = core::coords::canonicalToServer(canonical);
std::ostringstream cmd;
cmd.setf(std::ios::fixed);
cmd.precision(3);
cmd << ".go xyz "
<< serverPos.x << " "
<< serverPos.y << " "
<< serverPos.z << " "
<< gameHandler->getCurrentMapId() << " "
<< gameHandler->getMovementInfo().orientation;
gameHandler->sendChatMessage(game::ChatType::SAY, cmd.str(), "");
};
// /unstuck — nudge player forward and snap to floor at destination.
gameHandler->setUnstuckCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() {
if (!renderer || !renderer->getCameraController()) return;
worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f);
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
clearStuckMovement();
auto* cc = renderer->getCameraController();
auto* ft = cc->getFollowTargetMutable();
if (!ft) return;
glm::vec3 pos = *ft;
// Always nudge forward first to escape stuck geometry (M2 models, collision seams).
if (gameHandler) {
float renderYaw = gameHandler->getMovementInfo().orientation + glm::radians(90.0f);
pos.x += std::cos(renderYaw) * 5.0f;
pos.y += std::sin(renderYaw) * 5.0f;
}
// Sample floor at the DESTINATION position (after nudge).
// Pick the highest floor so we snap up to WMO floors when fallen below.
bool foundFloor = false;
if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) {
pos.z = *floor + 0.2f;
foundFloor = true;
}
cc->teleportTo(pos);
if (!foundFloor) {
cc->setGrounded(false); // Let gravity pull player down to a surface
}
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: nudged forward and snapped to floor");
});
// /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback.
gameHandler->setUnstuckGyCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() {
if (!renderer || !renderer->getCameraController()) return;
worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f);
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
clearStuckMovement();
auto* cc = renderer->getCameraController();
auto* ft = cc->getFollowTargetMutable();
if (!ft) return;
// Try last safe position first (nearby, terrain already loaded)
if (cc->hasLastSafePosition()) {
glm::vec3 safePos = cc->getLastSafePosition();
safePos.z += 5.0f;
cc->teleportTo(safePos);
syncTeleportedPositionToServer(safePos);
forceServerTeleportCommand(safePos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to last safe position");
return;
}
uint32_t bindMap = 0;
glm::vec3 bindPos(0.0f);
if (gameHandler && gameHandler->getHomeBind(bindMap, bindPos) &&
bindMap == gameHandler->getCurrentMapId()) {
bindPos.z += 2.0f;
cc->teleportTo(bindPos);
syncTeleportedPositionToServer(bindPos);
forceServerTeleportCommand(bindPos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to home bind position");
return;
}
// No safe/bind position — try current XY with a high floor probe.
glm::vec3 pos = *ft;
if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 120.0f)) {
pos.z = *floor + 0.5f;
cc->teleportTo(pos);
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to sampled floor");
return;
}
// Last fallback: high snap to clear deeply bad geometry.
pos.z += 60.0f;
cc->teleportTo(pos);
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: high fallback snap");
});
// /unstuckhearth — teleport to hearthstone bind point (server-synced).
// Freezes player until terrain loads at destination to prevent falling through world.
gameHandler->setUnstuckHearthCallback([this, clearStuckMovement, forceServerTeleportCommand]() {
if (!renderer || !renderer->getCameraController() || !gameHandler) return;
uint32_t bindMap = 0;
glm::vec3 bindPos(0.0f);
if (!gameHandler->getHomeBind(bindMap, bindPos)) {
LOG_WARNING("Unstuck hearth: no bind point available");
return;
}
worldEntryMovementGraceTimer_ = 10.0f; // long grace — terrain load check will clear it
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
clearStuckMovement();
auto* cc = renderer->getCameraController();
glm::vec3 renderPos = core::coords::canonicalToRender(bindPos);
renderPos.z += 2.0f;
// Freeze player in place (no gravity/movement) until terrain loads
cc->teleportTo(renderPos);
cc->setExternalFollow(true);
forceServerTeleportCommand(renderPos);
clearStuckMovement();
// Set pending state — update loop will unfreeze once terrain is loaded
hearthTeleportPending_ = true;
hearthTeleportPos_ = renderPos;
hearthTeleportTimer_ = 15.0f; // 15s safety timeout
LOG_INFO("Unstuck hearth: teleporting to bind point, waiting for terrain...");
});
// Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry
if (renderer->getCameraController()) {
renderer->getCameraController()->setAutoUnstuckCallback([this, forceServerTeleportCommand]() {
if (!renderer || !renderer->getCameraController()) return;
auto* cc = renderer->getCameraController();
// Last resort: teleport to map entry point (terrain guaranteed loaded here)
glm::vec3 spawnPos = cc->getDefaultPosition();
spawnPos.z += 5.0f;
cc->teleportTo(spawnPos);
forceServerTeleportCommand(spawnPos);
LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)");
});
}
// Bind point update (innkeeper) — position stored in gameHandler->getHomeBind()
gameHandler->setBindPointCallback([this](uint32_t mapId, float x, float y, float z) {
LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")");
});
// Hearthstone preload callback: begin loading terrain at the bind point as soon as
// the player starts casting Hearthstone. The ~10 s cast gives enough time for
// the background streaming workers to bring tiles into the cache so the player
// lands on solid ground instead of falling through un-loaded terrain.
gameHandler->setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) {
if (!renderer || !assetManager) return;
auto* terrainMgr = renderer->getTerrainManager();
if (!terrainMgr) return;
// Resolve map name from the cached Map.dbc table
std::string mapName;
if (worldLoader_) {
mapName = worldLoader_->getMapNameById(mapId);
}
if (mapName.empty()) {
mapName = WorldLoader::mapIdToName(mapId);
}
if (mapName.empty()) mapName = "Azeroth";
uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF;
if (mapId == currentLoadedMap) {
// Same map: pre-enqueue tiles around the bind point so workers start
// loading them now. Uses render-space coords (canonicalToRender).
// Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time
// for workers to parse most of these before the player arrives.
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> tiles;
tiles.reserve(81);
for (int dy = -4; dy <= 4; dy++)
for (int dx = -4; dx <= 4; dx++)
tiles.push_back({tileX + dx, tileY + dy});
terrainMgr->precacheTiles(tiles);
LOG_INFO("Hearthstone preload: enqueued ", tiles.size(),
" tiles around bind point (same map) tile=[", tileX, ",", tileY, "]");
} else {
// Different map: warm the file cache so ADT parsing is fast when
// loadOnlineWorldTerrain runs its blocking load loop.
// homeBindPos_ is canonical; startWorldPreload expects server coords.
glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z));
if (worldLoader_) {
worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y);
}
LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName,
"' (id=", mapId, ")");
}
});
// Faction hostility map is built in buildFactionHostilityMap() when character enters world
// Creature spawn callback (online mode) - spawn creature models
gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
if (!entitySpawner_) return;
// Queue spawns to avoid hanging when many creatures appear at once.
// Deduplicate so repeated updates don't flood pending queue.
if (entitySpawner_->isCreatureSpawned(guid)) return;
if (entitySpawner_->isCreaturePending(guid)) return;
entitySpawner_->queueCreatureSpawn(guid, displayId, x, y, z, orientation, scale);
});
// Player spawn callback (online mode) - spawn player models with correct textures
gameHandler->setPlayerSpawnCallback([this](uint64_t guid,
uint32_t /*displayId*/,
uint8_t raceId,
uint8_t genderId,
uint32_t appearanceBytes,
uint8_t facialFeatures,
float x, float y, float z, float orientation) {
if (!entitySpawner_) return;
LOG_WARNING("playerSpawnCallback: guid=0x", std::hex, guid, std::dec,
" race=", static_cast<int>(raceId), " gender=", static_cast<int>(genderId),
" pos=(", x, ",", y, ",", z, ")");
// Skip local player — already spawned as the main character
uint64_t localGuid = gameHandler ? gameHandler->getPlayerGuid() : 0;
uint64_t activeGuid = gameHandler ? gameHandler->getActiveCharacterGuid() : 0;
if ((localGuid != 0 && guid == localGuid) ||
(activeGuid != 0 && guid == activeGuid) ||
(spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) {
return;
}
if (entitySpawner_->isPlayerSpawned(guid)) return;
if (entitySpawner_->isPlayerPending(guid)) return;
entitySpawner_->queuePlayerSpawn(guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation);
});
// Online player equipment callback - apply armor geosets/skin overlays per player instance.
gameHandler->setPlayerEquipmentCallback([this](uint64_t guid,
const std::array<uint32_t, 19>& displayInfoIds,
const std::array<uint8_t, 19>& inventoryTypes) {
if (!entitySpawner_) return;
// Queue equipment compositing instead of doing it immediately —
// compositeWithRegions is expensive (file I/O + CPU blit + GPU upload)
// and causes frame stutters if multiple players update at once.
entitySpawner_->queuePlayerEquipment(guid, displayInfoIds, inventoryTypes);
});
// Creature despawn callback (online mode) - remove creature models
gameHandler->setCreatureDespawnCallback([this](uint64_t guid) {
if (!entitySpawner_) return;
entitySpawner_->despawnCreature(guid);
});
gameHandler->setPlayerDespawnCallback([this](uint64_t guid) {
if (!entitySpawner_) return;
entitySpawner_->despawnPlayer(guid);
});
// GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.)
gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
if (!entitySpawner_) return;
entitySpawner_->queueGameObjectSpawn(guid, entry, displayId, x, y, z, orientation, scale);
});
// GameObject despawn callback (online mode) - remove static models
gameHandler->setGameObjectDespawnCallback([this](uint64_t guid) {
if (!entitySpawner_) return;
entitySpawner_->despawnGameObject(guid);
});
// GameObject custom animation callback (e.g. chest opening)
gameHandler->setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t /*animId*/) {
if (!entitySpawner_) return;
auto& goInstances = entitySpawner_->getGameObjectInstances();
auto it = goInstances.find(guid);
if (it == goInstances.end() || !renderer) return;
auto& info = it->second;
if (!info.isWmo) {
if (auto* m2r = renderer->getM2Renderer()) {
m2r->setInstanceAnimationFrozen(info.instanceId, false);
}
}
});
// Charge callback — warrior rushes toward target
gameHandler->setChargeCallback([this](uint64_t targetGuid, float tx, float ty, float tz) {
if (!renderer || !renderer->getCameraController() || !gameHandler) return;
// Get current player position in render coords
glm::vec3 startRender = renderer->getCharacterPosition();
// Convert target from canonical to render
glm::vec3 targetRender = core::coords::canonicalToRender(glm::vec3(tx, ty, tz));
// Compute direction and stop 2.0 units short (melee reach)
glm::vec3 dir = targetRender - startRender;
float distSq = glm::dot(dir, dir);
if (distSq < 9.0f) return; // Too close, nothing to do
float invDist = glm::inversesqrt(distSq);
glm::vec3 dirNorm = dir * invDist;
glm::vec3 endRender = targetRender - dirNorm * 2.0f;
// Face toward target BEFORE starting charge
float yawRad = std::atan2(dirNorm.x, dirNorm.y);
float yawDeg = glm::degrees(yawRad);
renderer->setCharacterYaw(yawDeg);
// Sync canonical orientation to server so it knows we turned
float canonicalYaw = core::coords::normalizeAngleRad(glm::radians(180.0f - yawDeg));
gameHandler->setOrientation(canonicalYaw);
gameHandler->sendMovement(game::Opcode::MSG_MOVE_SET_FACING);
// Set charge state
chargeActive_ = true;
chargeTimer_ = 0.0f;
chargeDuration_ = std::max(std::sqrt(distSq) / 25.0f, 0.3f); // ~25 units/sec
chargeStartPos_ = startRender;
chargeEndPos_ = endRender;
chargeTargetGuid_ = targetGuid;
// Disable player input, play charge animation
renderer->getCameraController()->setExternalFollow(true);
renderer->getCameraController()->clearMovementInputs();
renderer->setCharging(true);
// Start charge visual effect (red haze + dust)
glm::vec3 chargeDir = glm::normalize(endRender - startRender);
renderer->startChargeEffect(startRender, chargeDir);
// Play charge whoosh sound (try multiple paths)
auto& audio = audio::AudioEngine::instance();
if (!audio.playSound2D("Sound\\Spells\\Charge.wav", 0.8f)) {
if (!audio.playSound2D("Sound\\Spells\\charge.wav", 0.8f)) {
if (!audio.playSound2D("Sound\\Spells\\SpellCharge.wav", 0.8f)) {
// Fallback: weapon whoosh
audio.playSound2D("Sound\\Item\\Weapons\\WeaponSwings\\mWooshLarge1.wav", 0.9f);
}
}
}
});
// Level-up callback — play sound, cheer emote, and trigger UI ding overlay + 3D effect
gameHandler->setLevelUpCallback([this](uint32_t newLevel) {
if (uiManager) {
uiManager->getGameScreen().toastManager().triggerDing(newLevel);
}
if (renderer) {
renderer->triggerLevelUpEffect(renderer->getCharacterPosition());
}
});
// Achievement earned callback — show toast banner
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) {
if (uiManager) {
uiManager->getGameScreen().toastManager().triggerAchievementToast(achievementId, name);
}
});
// Server-triggered music callback (SMSG_PLAY_MUSIC)
// Resolves soundId → SoundEntries.dbc → MPQ path → MusicManager.
gameHandler->setPlayMusicCallback([this](uint32_t soundId) {
if (!assetManager || !renderer) return;
auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr;
if (!music) return;
auto dbc = assetManager->loadDBC("SoundEntries.dbc");
if (!dbc || !dbc->isLoaded()) return;
int32_t idx = dbc->findRecordById(soundId);
if (idx < 0) return;
// SoundEntries.dbc (WotLK): field 2 = Name (label), fields 3-12 = File[0..9], field 23 = DirectoryBase
const uint32_t row = static_cast<uint32_t>(idx);
std::string dir = dbc->getString(row, 23);
for (uint32_t f = 3; f <= 12; ++f) {
std::string name = dbc->getString(row, f);
if (name.empty()) continue;
std::string path = dir.empty() ? name : dir + "\\" + name;
music->playMusic(path, /*loop=*/false);
return;
}
});
// SMSG_PLAY_SOUND: look up SoundEntries.dbc and play 2-D sound effect
gameHandler->setPlaySoundCallback([this](uint32_t soundId) {
if (!assetManager) return;
auto dbc = assetManager->loadDBC("SoundEntries.dbc");
if (!dbc || !dbc->isLoaded()) return;
int32_t idx = dbc->findRecordById(soundId);
if (idx < 0) return;
const uint32_t row = static_cast<uint32_t>(idx);
std::string dir = dbc->getString(row, 23);
for (uint32_t f = 3; f <= 12; ++f) {
std::string name = dbc->getString(row, f);
if (name.empty()) continue;
std::string path = dir.empty() ? name : dir + "\\" + name;
audio::AudioEngine::instance().playSound2D(path);
return;
}
});
// SMSG_PLAY_OBJECT_SOUND / SMSG_PLAY_SPELL_IMPACT: play as 3D positional sound at source entity
gameHandler->setPlayPositionalSoundCallback([this](uint32_t soundId, uint64_t sourceGuid) {
if (!assetManager || !gameHandler) return;
auto dbc = assetManager->loadDBC("SoundEntries.dbc");
if (!dbc || !dbc->isLoaded()) return;
int32_t idx = dbc->findRecordById(soundId);
if (idx < 0) return;
const uint32_t row = static_cast<uint32_t>(idx);
std::string dir = dbc->getString(row, 23);
for (uint32_t f = 3; f <= 12; ++f) {
std::string name = dbc->getString(row, f);
if (name.empty()) continue;
std::string path = dir.empty() ? name : dir + "\\" + name;
// Play as 3D sound if source entity position is available.
// Entity stores canonical coords; listener uses render coords (camera).
auto entity = gameHandler->getEntityManager().getEntity(sourceGuid);
if (entity) {
glm::vec3 canonical{entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()};
glm::vec3 pos = core::coords::canonicalToRender(canonical);
audio::AudioEngine::instance().playSound3D(path, pos);
} else {
audio::AudioEngine::instance().playSound2D(path);
}
return;
}
});
// Other player level-up callback — trigger 3D effect + chat notification
gameHandler->setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) {
if (!gameHandler || !renderer) return;
// Trigger 3D effect at the other player's position
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (entity) {
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->triggerLevelUpEffect(renderPos);
}
// Show chat message if in group
if (gameHandler->isInGroup()) {
std::string name = gameHandler->getCachedPlayerName(guid);
if (name.empty()) name = "A party member";
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = name + " has reached level " + std::to_string(newLevel) + "!";
gameHandler->addLocalChatMessage(msg);
}
});
// Mount callback (online mode) - defer heavy model load to next frame
gameHandler->setMountCallback([this](uint32_t mountDisplayId) {
if (mountDisplayId == 0) {
// Dismount is instant (no loading needed)
if (renderer && renderer->getCharacterRenderer() && entitySpawner_->getMountInstanceId() != 0) {
renderer->getCharacterRenderer()->removeInstance(entitySpawner_->getMountInstanceId());
entitySpawner_->clearMountState();
}
entitySpawner_->setMountDisplayId(0);
if (renderer) renderer->clearMount();
LOG_INFO("Dismounted");
return;
}
// Queue the mount for processing in the next update() frame
entitySpawner_->setMountDisplayId(mountDisplayId);
});
// Taxi precache callback - preload terrain tiles along flight path
gameHandler->setTaxiPrecacheCallback([this](const std::vector<glm::vec3>& path) {
if (!renderer || !renderer->getTerrainManager()) return;
std::set<std::pair<int, int>> uniqueTiles;
// Sample waypoints along path and gather tiles.
// Denser sampling + neighbor coverage reduces in-flight stream spikes.
const size_t stride = 2;
for (size_t i = 0; i < path.size(); i += stride) {
const auto& waypoint = path[i];
glm::vec3 renderPos = core::coords::canonicalToRender(waypoint);
int tileX = static_cast<int>(32 - (renderPos.x / 533.33333f));
int tileY = static_cast<int>(32 - (renderPos.y / 533.33333f));
if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) {
for (int dx = -1; dx <= 1; ++dx) {
for (int dy = -1; dy <= 1; ++dy) {
int nx = tileX + dx;
int ny = tileY + dy;
if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) {
uniqueTiles.insert({nx, ny});
}
}
}
}
}
// Ensure final destination tile is included.
if (!path.empty()) {
glm::vec3 renderPos = core::coords::canonicalToRender(path.back());
int tileX = static_cast<int>(32 - (renderPos.x / 533.33333f));
int tileY = static_cast<int>(32 - (renderPos.y / 533.33333f));
if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) {
for (int dx = -1; dx <= 1; ++dx) {
for (int dy = -1; dy <= 1; ++dy) {
int nx = tileX + dx;
int ny = tileY + dy;
if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) {
uniqueTiles.insert({nx, ny});
}
}
}
}
}
std::vector<std::pair<int, int>> tilesToLoad(uniqueTiles.begin(), uniqueTiles.end());
if (tilesToLoad.size() > 512) {
tilesToLoad.resize(512);
}
LOG_INFO("Precaching ", tilesToLoad.size(), " tiles for taxi route");
renderer->getTerrainManager()->precacheTiles(tilesToLoad);
});
// Taxi orientation callback - update mount rotation during flight
gameHandler->setTaxiOrientationCallback([this](float yaw, float pitch, float roll) {
if (renderer && renderer->getCameraController()) {
// Taxi callback now provides render-space yaw directly.
float yawDegrees = glm::degrees(yaw);
renderer->getCameraController()->setFacingYaw(yawDegrees);
renderer->setCharacterYaw(yawDegrees);
// Set mount pitch and roll for realistic flight animation
renderer->setMountPitchRoll(pitch, roll);
}
});
// Taxi flight start callback - keep non-blocking to avoid hitching at takeoff.
gameHandler->setTaxiFlightStartCallback([this]() {
if (renderer && renderer->getTerrainManager() && renderer->getM2Renderer()) {
LOG_INFO("Taxi flight start: incremental terrain/M2 streaming active");
uint32_t m2Count = renderer->getM2Renderer()->getModelCount();
uint32_t instCount = renderer->getM2Renderer()->getInstanceCount();
LOG_INFO("Current M2 VRAM state: ", m2Count, " models (", instCount, " instances)");
}
});
// Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER
gameHandler->setOpenLfgCallback([this]() {
if (uiManager) uiManager->getGameScreen().openDungeonFinder();
});
// Creature move callback (online mode) - update creature positions
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
if (!entitySpawner_) return;
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
bool isPlayer = false;
instanceId = entitySpawner_->getPlayerInstanceId(guid);
if (instanceId != 0) { isPlayer = true; }
else {
instanceId = entitySpawner_->getCreatureInstanceId(guid);
}
if (instanceId != 0) {
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
float durationSec = static_cast<float>(durationMs) / 1000.0f;
renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec);
// Play Run animation (anim 5) for the duration of the spline move.
// WoW M2 animation IDs: 4=Walk, 5=Run.
// Don't override Death animation (1). The per-frame sync loop will return to
// Stand when movement stops.
if (durationMs > 0) {
// Player animation is managed by the local renderer state machine —
// don't reset it here or every server movement packet restarts the
// run cycle from frame 0, causing visible stutter.
if (!isPlayer) {
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
auto* cr = renderer->getCharacterRenderer();
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur);
// Only start Run if not already running and not in Death animation.
if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) {
cr->playAnimation(instanceId, 5u, /*loop=*/true);
}
entitySpawner_->getCreatureWasMoving()[guid] = true;
}
}
}
});
gameHandler->setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) {
if (!entitySpawner_) return;
auto& goInstMap = entitySpawner_->getGameObjectInstances();
auto it = goInstMap.find(guid);
if (it == goInstMap.end() || !renderer) {
return;
}
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
auto& info = it->second;
if (info.isWmo) {
if (auto* wr = renderer->getWMORenderer()) {
glm::mat4 transform(1.0f);
transform = glm::translate(transform, renderPos);
transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1));
wr->setInstanceTransform(info.instanceId, transform);
}
} else {
if (auto* mr = renderer->getM2Renderer()) {
glm::mat4 transform(1.0f);
transform = glm::translate(transform, renderPos);
mr->setInstanceTransform(info.instanceId, transform);
}
}
});
// Transport spawn callback (online mode) - register transports with TransportManager
gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
if (!entitySpawner_) return;
if (!renderer) return;
// Get the GameObject instance now so late queue processing can rely on stable IDs.
auto& goInstances2 = entitySpawner_->getGameObjectInstances();
auto it = goInstances2.find(guid);
if (it == goInstances2.end()) {
LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec);
return;
}
auto pendingIt = entitySpawner_->hasTransportRegistrationPending(guid);
if (pendingIt) {
entitySpawner_->updateTransportRegistration(guid, displayId, x, y, z, orientation);
} else {
entitySpawner_->queueTransportRegistration(guid, entry, displayId, x, y, z, orientation);
}
});
// Transport move callback (online mode) - update transport gameobject positions
gameHandler->setTransportMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) {
if (!entitySpawner_) return;
LOG_DEBUG("Transport move callback: GUID=0x", std::hex, guid, std::dec,
" pos=(", x, ", ", y, ", ", z, ") orientation=", orientation);
auto* transportManager = gameHandler->getTransportManager();
if (!transportManager) {
LOG_WARNING("Transport move callback: TransportManager is null!");
return;
}
if (entitySpawner_->hasTransportRegistrationPending(guid)) {
entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation);
LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec);
return;
}
// Check if transport exists - if not, treat this as a late spawn (reconnection/server restart)
if (!transportManager->getTransport(guid)) {
LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec,
" - auto-spawning from position update");
// Get transport info from entity manager
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (entity && entity->getType() == game::ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<game::GameObject>(entity);
uint32_t entry = go->getEntry();
uint32_t displayId = go->getDisplayId();
// Find the WMO instance for this transport (should exist from earlier GameObject spawn)
auto& goInstances3 = entitySpawner_->getGameObjectInstances();
auto it = goInstances3.find(guid);
if (it != goInstances3.end()) {
uint32_t wmoInstanceId = it->second.instanceId;
// TransportAnimation.dbc is indexed by GameObject entry
uint32_t pathId = entry;
const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid);
// Coordinates are already canonical (converted in game_handler.cpp)
glm::vec3 canonicalSpawnPos(x, y, z);
// Check if we have a real usable path, otherwise remap/infer/fall back to stationary.
const bool shipOrZeppelinDisplay =
(displayId == 3015 || displayId == 3031 || displayId == 7546 ||
displayId == 7446 || displayId == 1587 || displayId == 2454 ||
displayId == 807 || displayId == 808);
bool hasUsablePath = transportManager->hasPathForEntry(entry);
if (shipOrZeppelinDisplay) {
hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f);
}
if (preferServerData) {
// Strict server-authoritative mode: no inferred/remapped fallback routes.
if (!hasUsablePath) {
std::vector<glm::vec3> path = { canonicalSpawnPos };
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
LOG_INFO("Auto-spawned transport in strict server-first mode (stationary fallback): entry=", entry,
" displayId=", displayId, " wmoInstance=", wmoInstanceId);
} else {
LOG_INFO("Auto-spawned transport in server-first mode with entry DBC path: entry=", entry,
" displayId=", displayId, " wmoInstance=", wmoInstanceId);
}
} else if (!hasUsablePath) {
bool allowZOnly = (displayId == 455 || displayId == 462);
uint32_t inferredPath = transportManager->inferDbcPathForSpawn(
canonicalSpawnPos, 1200.0f, allowZOnly);
if (inferredPath != 0) {
pathId = inferredPath;
LOG_INFO("Auto-spawned transport with inferred path: entry=", entry,
" inferredPath=", pathId, " displayId=", displayId,
" wmoInstance=", wmoInstanceId);
} else {
uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId);
if (remappedPath != 0) {
pathId = remappedPath;
LOG_INFO("Auto-spawned transport with remapped fallback path: entry=", entry,
" remappedPath=", pathId, " displayId=", displayId,
" wmoInstance=", wmoInstanceId);
} else {
std::vector<glm::vec3> path = { canonicalSpawnPos };
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
LOG_INFO("Auto-spawned transport with stationary path: entry=", entry,
" displayId=", displayId, " wmoInstance=", wmoInstanceId);
}
}
} else {
LOG_INFO("Auto-spawned transport with real path: entry=", entry,
" displayId=", displayId, " wmoInstance=", wmoInstanceId);
}
transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry);
// Keep type in sync with the spawned instance; needed for M2 lift boarding/motion.
if (!it->second.isWmo) {
if (auto* tr = transportManager->getTransport(guid)) {
tr->isM2 = true;
}
}
} else {
entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation);
LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec,
" - WMO instance not found yet (queued move for replay)");
return;
}
} else {
entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation);
LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec,
" - entity not found in EntityManager (queued move for replay)");
return;
}
}
// Update TransportManager's internal state (position, rotation, transform matrices)
// This also updates the WMO renderer automatically
// Coordinates are already canonical (converted in game_handler.cpp when entity was created)
glm::vec3 canonicalPos(x, y, z);
transportManager->updateServerTransport(guid, canonicalPos, orientation);
// Move player with transport if riding it
if (gameHandler && gameHandler->isOnTransport() && gameHandler->getPlayerTransportGuid() == guid && renderer) {
auto* cc = renderer->getCameraController();
if (cc) {
glm::vec3* ft = cc->getFollowTargetMutable();
if (ft) {
// Get player world position from TransportManager (handles transform properly)
glm::vec3 offset = gameHandler->getPlayerTransportOffset();
glm::vec3 worldPos = transportManager->getPlayerWorldPosition(guid, offset);
*ft = worldPos;
}
}
}
});
// NPC/player death callback (online mode) - play death animation
gameHandler->setNpcDeathCallback([this](uint64_t guid) {
if (!entitySpawner_) return;
entitySpawner_->markCreatureDead(guid);
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid);
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death
}
});
// NPC/player respawn callback (online mode) - reset to idle animation
gameHandler->setNpcRespawnCallback([this](uint64_t guid) {
if (!entitySpawner_) return;
entitySpawner_->unmarkCreatureDead(guid);
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid);
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle
}
});
// NPC/player swing callback (online mode) - play attack animation
gameHandler->setNpcSwingCallback([this](uint64_t guid) {
if (!entitySpawner_) return;
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid);
if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid);
if (instanceId != 0) {
auto* cr = renderer->getCharacterRenderer();
// Try weapon-appropriate attack anim: 17=1H, 18=2H, 16=unarmed fallback
static const uint32_t attackAnims[] = {17, 18, 16};
bool played = false;
for (uint32_t anim : attackAnims) {
if (cr->hasAnimation(instanceId, anim)) {
cr->playAnimation(instanceId, anim, false);
played = true;
break;
}
}
if (!played) cr->playAnimation(instanceId, 16, false);
}
});
// Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs.
// Swim/walking state is now authoritative from the move-flags callback below.
// animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync.
gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) {
if (!entitySpawner_) return;
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
uint32_t instanceId = entitySpawner_->getPlayerInstanceId(guid);
if (instanceId == 0) instanceId = entitySpawner_->getCreatureInstanceId(guid);
if (instanceId == 0) return;
// Don't override Death animation (1)
uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f;
if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) return;
cr->playAnimation(instanceId, animId, /*loop=*/true);
});
// Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet.
// This is more reliable than opcode-based hints for cold joins and heartbeats:
// a player already swimming when we join will have SWIMMING set on the first heartbeat.
// Walking(4) vs Running(5) is also driven here from the WALKING flag.
gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) {
if (!entitySpawner_) return;
const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
const bool isFlying = (moveFlags & static_cast<uint32_t>(game::MovementFlags::FLYING)) != 0;
auto& swimState = entitySpawner_->getCreatureSwimmingState();
auto& walkState = entitySpawner_->getCreatureWalkingState();
auto& flyState = entitySpawner_->getCreatureFlyingState();
if (isSwimming) swimState[guid] = true;
else swimState.erase(guid);
if (isWalking) walkState[guid] = true;
else walkState.erase(guid);
if (isFlying) flyState[guid] = true;
else flyState.erase(guid);
});
// Emote animation callback — play server-driven emote animations on NPCs and other players
gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
if (!entitySpawner_) return;
if (!renderer || emoteAnim == 0) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
// Look up creature instance first, then online players
uint32_t emoteInstanceId = entitySpawner_->getCreatureInstanceId(guid);
if (emoteInstanceId != 0) {
cr->playAnimation(emoteInstanceId, emoteAnim, false);
return;
}
emoteInstanceId = entitySpawner_->getPlayerInstanceId(guid);
if (emoteInstanceId != 0) {
cr->playAnimation(emoteInstanceId, emoteAnim, false);
}
});
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) {
if (!entitySpawner_) return;
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
// Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer)
const uint32_t castAnim = 3;
// Check player character
{
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) {
if (start) cr->playAnimation(charInstId, castAnim, false);
// On finish: playAnimation(castAnim, loop=false) will auto-return to Stand
return;
}
}
// Check creatures and other online players
{
uint32_t cInst = entitySpawner_->getCreatureInstanceId(guid);
if (cInst != 0) {
if (start) cr->playAnimation(cInst, castAnim, false);
return;
}
}
{
uint32_t pInst = entitySpawner_->getPlayerInstanceId(guid);
if (pInst != 0) {
if (start) cr->playAnimation(pInst, castAnim, false);
}
}
});
// Ghost state callback — make player semi-transparent when in spirit form
gameHandler->setGhostStateCallback([this](bool isGhost) {
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId == 0) return;
cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f);
});
// Stand state animation callback — map server stand state to M2 animation on player
// and sync camera sit flag so movement is blocked while sitting
gameHandler->setStandStateCallback([this](uint8_t standState) {
if (!renderer) return;
// Sync camera controller sitting flag: block movement while sitting/kneeling
if (auto* cc = renderer->getCameraController()) {
cc->setSitting(standState >= 1 && standState <= 8 && standState != 7);
}
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId == 0) return;
// WoW stand state → M2 animation ID mapping
// 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72
// Do not force Stand(0) here: locomotion state machine already owns standing/running.
// Forcing Stand on packet timing causes visible run-cycle hitching while steering.
uint32_t animId = 0;
if (standState == 0) {
return;
} else if (standState >= 1 && standState <= 6) {
animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height)
} else if (standState == 7) {
animId = 1; // Death
} else if (standState == 8) {
animId = 72; // Kneel
}
// Loop sit/kneel (not death) so the held-pose frame stays visible
const bool loop = (animId != 1);
cr->playAnimation(charInstId, animId, loop);
});
// NPC greeting callback - play voice line
gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) {
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
// Convert canonical to render coords for 3D audio
glm::vec3 renderPos = core::coords::canonicalToRender(position);
// Detect voice type from NPC display ID
audio::VoiceType voiceType = audio::VoiceType::GENERIC;
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (entity && entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
uint32_t displayId = unit->getDisplayId();
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
}
audioCoordinator_->getNpcVoiceManager()->playGreeting(guid, voiceType, renderPos);
}
});
// NPC farewell callback - play farewell voice line
gameHandler->setNpcFarewellCallback([this](uint64_t guid, const glm::vec3& position) {
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
glm::vec3 renderPos = core::coords::canonicalToRender(position);
audio::VoiceType voiceType = audio::VoiceType::GENERIC;
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (entity && entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
uint32_t displayId = unit->getDisplayId();
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
}
audioCoordinator_->getNpcVoiceManager()->playFarewell(guid, voiceType, renderPos);
}
});
// NPC vendor callback - play vendor voice line
gameHandler->setNpcVendorCallback([this](uint64_t guid, const glm::vec3& position) {
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
glm::vec3 renderPos = core::coords::canonicalToRender(position);
audio::VoiceType voiceType = audio::VoiceType::GENERIC;
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (entity && entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
uint32_t displayId = unit->getDisplayId();
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
}
audioCoordinator_->getNpcVoiceManager()->playVendor(guid, voiceType, renderPos);
}
});
// NPC aggro callback - play combat start voice line
gameHandler->setNpcAggroCallback([this](uint64_t guid, const glm::vec3& position) {
if (audioCoordinator_ && audioCoordinator_->getNpcVoiceManager()) {
glm::vec3 renderPos = core::coords::canonicalToRender(position);
audio::VoiceType voiceType = audio::VoiceType::GENERIC;
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (entity && entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
uint32_t displayId = unit->getDisplayId();
voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId);
}
audioCoordinator_->getNpcVoiceManager()->playAggro(guid, voiceType, renderPos);
}
});
// "Create Character" button on character screen
uiManager->getCharacterScreen().setOnCreateCharacter([this]() {
uiManager->getCharacterCreateScreen().reset();
// Apply expansion race/class constraints before showing the screen
if (expansionRegistry_ && expansionRegistry_->getActive()) {
auto* profile = expansionRegistry_->getActive();
uiManager->getCharacterCreateScreen().setExpansionConstraints(
profile->races, profile->classes);
}
uiManager->getCharacterCreateScreen().initializePreview(assetManager.get());
setState(AppState::CHARACTER_CREATION);
});
// "Back" button on character screen
uiManager->getCharacterScreen().setOnBack([this]() {
// Disconnect from world server and reset UI state for fresh realm selection
if (gameHandler) {
gameHandler->disconnect();
}
uiManager->getRealmScreen().reset();
uiManager->getCharacterScreen().reset();
setState(AppState::REALM_SELECTION);
});
// "Delete Character" button on character screen
uiManager->getCharacterScreen().setOnDeleteCharacter([this](uint64_t guid) {
if (gameHandler) {
gameHandler->deleteCharacter(guid);
}
});
// Character delete result callback
gameHandler->setCharDeleteCallback([this](bool success) {
if (success) {
uiManager->getCharacterScreen().setStatus("Character deleted.");
// Refresh character list
gameHandler->requestCharacterList();
} else {
uint8_t code = gameHandler ? gameHandler->getLastCharDeleteResult() : 0xFF;
uiManager->getCharacterScreen().setStatus(
"Delete failed (code " + std::to_string(static_cast<int>(code)) + ").", true);
}
});
}
void Application::spawnPlayerCharacter() {
if (playerCharacterSpawned) return;
if (!renderer || !renderer->getCharacterRenderer() || !renderer->getCamera()) return;
auto* charRenderer = renderer->getCharacterRenderer();
auto* camera = renderer->getCamera();
bool loaded = false;
std::string m2Path = appearanceComposer_->getPlayerModelPath(playerRace_, playerGender_);
std::string modelDir;
std::string baseName;
{
size_t slash = m2Path.rfind('\\');
if (slash != std::string::npos) {
modelDir = m2Path.substr(0, slash + 1);
baseName = m2Path.substr(slash + 1);
} else {
baseName = m2Path;
}
size_t dot = baseName.rfind('.');
if (dot != std::string::npos) {
baseName = baseName.substr(0, dot);
}
}
// Try loading selected character model from MPQ
if (assetManager && assetManager->isInitialized()) {
auto m2Data = assetManager->readFile(m2Path);
if (!m2Data.empty()) {
auto model = pipeline::M2Loader::load(m2Data);
// Load skin file for submesh/batch data
std::string skinPath = modelDir + baseName + "00.skin";
auto skinData = assetManager->readFile(skinPath);
if (!skinData.empty() && model.version >= 264) {
pipeline::M2Loader::loadSkin(skinData, model);
}
if (model.isValid()) {
// Log texture slots
for (size_t ti = 0; ti < model.textures.size(); ti++) {
auto& tex = model.textures[ti];
LOG_INFO(" Texture ", ti, ": type=", tex.type, " name='", tex.filename, "'");
}
// Resolve textures from CharSections.dbc via AppearanceComposer
PlayerTextureInfo texInfo;
bool useCharSections = true;
if (appearanceComposer_) {
uint32_t appearanceBytes = 0;
if (gameHandler) {
const game::Character* activeChar = gameHandler->getActiveCharacter();
if (activeChar) {
appearanceBytes = activeChar->appearanceBytes;
}
}
texInfo = appearanceComposer_->resolvePlayerTextures(model, playerRace_, playerGender_, appearanceBytes);
}
// Load external .anim files for sequences with external data.
// Sequences WITH flag 0x20 have their animation data inline in the M2 file.
// Sequences WITHOUT flag 0x20 store data in external .anim files.
for (uint32_t si = 0; si < model.sequences.size(); si++) {
if (!(model.sequences[si].flags & 0x20)) {
// File naming: <ModelPath><AnimId>-<VariationIndex>.anim
// e.g. Character\Human\Male\HumanMale0097-00.anim
char animFileName[256];
snprintf(animFileName, sizeof(animFileName),
"%s%s%04u-%02u.anim",
modelDir.c_str(),
baseName.c_str(),
model.sequences[si].id,
model.sequences[si].variationIndex);
auto animFileData = assetManager->readFileOptional(animFileName);
if (!animFileData.empty()) {
pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model);
}
}
}
charRenderer->loadModel(model, 1);
// Apply composited textures via AppearanceComposer (saves skin state for re-compositing)
if (useCharSections && appearanceComposer_) {
appearanceComposer_->compositePlayerSkin(1, texInfo);
}
loaded = true;
LOG_INFO("Loaded character model: ", m2Path, " (", model.vertices.size(), " verts, ",
model.bones.size(), " bones, ", model.sequences.size(), " anims, ",
model.indices.size(), " indices, ", model.batches.size(), " batches");
// Log all animation sequence IDs
for (size_t i = 0; i < model.sequences.size(); i++) {
}
}
}
}
// Fallback: create a simple cube if MPQ not available
if (!loaded) {
pipeline::M2Model testModel;
float size = 2.0f;
glm::vec3 cubePos[] = {
{-size, -size, -size}, { size, -size, -size},
{ size, size, -size}, {-size, size, -size},
{-size, -size, size}, { size, -size, size},
{ size, size, size}, {-size, size, size}
};
for (const auto& pos : cubePos) {
pipeline::M2Vertex v;
v.position = pos;
v.normal = glm::normalize(pos);
v.texCoords[0] = glm::vec2(0.0f);
v.boneWeights[0] = 255;
v.boneWeights[1] = v.boneWeights[2] = v.boneWeights[3] = 0;
v.boneIndices[0] = 0;
v.boneIndices[1] = v.boneIndices[2] = v.boneIndices[3] = 0;
testModel.vertices.push_back(v);
}
uint16_t cubeIndices[] = {
0,1,2, 0,2,3, 4,6,5, 4,7,6,
0,4,5, 0,5,1, 2,6,7, 2,7,3,
0,3,7, 0,7,4, 1,5,6, 1,6,2
};
for (uint16_t idx : cubeIndices)
testModel.indices.push_back(idx);
pipeline::M2Bone bone;
bone.keyBoneId = -1;
bone.flags = 0;
bone.parentBone = -1;
bone.submeshId = 0;
bone.pivot = glm::vec3(0.0f);
testModel.bones.push_back(bone);
pipeline::M2Sequence seq{};
seq.id = 0;
seq.duration = 1000;
testModel.sequences.push_back(seq);
testModel.name = "TestCube";
testModel.globalFlags = 0;
charRenderer->loadModel(testModel, 1);
LOG_INFO("Loaded fallback cube model (no MPQ data)");
}
// Spawn character at the camera controller's default position (matches hearthstone).
// Most presets snap to floor; explicit WMO-floor presets keep their authored Z.
auto* camCtrl = renderer->getCameraController();
glm::vec3 spawnPos = camCtrl ? camCtrl->getDefaultPosition()
: (camera->getPosition() - glm::vec3(0.0f, 0.0f, 5.0f));
if (spawnSnapToGround && renderer->getTerrainManager()) {
auto terrainH = renderer->getTerrainManager()->getHeightAt(spawnPos.x, spawnPos.y);
if (terrainH) {
spawnPos.z = *terrainH + 0.1f;
}
}
uint32_t instanceId = charRenderer->createInstance(1, spawnPos,
glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size
if (instanceId > 0) {
// Set up third-person follow
renderer->getCharacterPosition() = spawnPos;
renderer->setCharacterFollow(instanceId);
// Build default geosets for the active character via AppearanceComposer
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (gameHandler) {
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
hairStyleId = static_cast<uint8_t>((ch->appearanceBytes >> 16) & 0xFF);
facialId = ch->facialFeatures;
}
}
auto activeGeosets = appearanceComposer_
? appearanceComposer_->buildDefaultPlayerGeosets(hairStyleId, facialId)
: std::unordered_set<uint16_t>{};
charRenderer->setActiveGeosets(instanceId, activeGeosets);
// Play idle animation (Stand = animation ID 0)
charRenderer->playAnimation(instanceId, 0, true);
LOG_INFO("Spawned player character at (",
static_cast<int>(spawnPos.x), ", ",
static_cast<int>(spawnPos.y), ", ",
static_cast<int>(spawnPos.z), ")");
playerCharacterSpawned = true;
// Set voice profile to match character race/gender
if (auto* asm_ = audioCoordinator_ ? audioCoordinator_->getActivitySoundManager() : nullptr) {
const char* raceFolder = "Human";
const char* raceBase = "Human";
switch (playerRace_) {
case game::Race::HUMAN: raceFolder = "Human"; raceBase = "Human"; break;
case game::Race::ORC: raceFolder = "Orc"; raceBase = "Orc"; break;
case game::Race::DWARF: raceFolder = "Dwarf"; raceBase = "Dwarf"; break;
case game::Race::NIGHT_ELF: raceFolder = "NightElf"; raceBase = "NightElf"; break;
case game::Race::UNDEAD: raceFolder = "Scourge"; raceBase = "Scourge"; break;
case game::Race::TAUREN: raceFolder = "Tauren"; raceBase = "Tauren"; break;
case game::Race::GNOME: raceFolder = "Gnome"; raceBase = "Gnome"; break;
case game::Race::TROLL: raceFolder = "Troll"; raceBase = "Troll"; break;
case game::Race::BLOOD_ELF: raceFolder = "BloodElf"; raceBase = "BloodElf"; break;
case game::Race::DRAENEI: raceFolder = "Draenei"; raceBase = "Draenei"; break;
default: break;
}
bool useFemaleVoice = (playerGender_ == game::Gender::FEMALE);
if (playerGender_ == game::Gender::NONBINARY && gameHandler) {
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
useFemaleVoice = ch->useFemaleModel;
}
}
asm_->setCharacterVoiceProfile(std::string(raceFolder), std::string(raceBase), !useFemaleVoice);
}
// Track which character's appearance this instance represents so we can
// respawn if the user logs into a different character without restarting.
spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
if (gameHandler) {
if (const game::Character* ch = gameHandler->getActiveCharacter()) {
spawnedAppearanceBytes_ = ch->appearanceBytes;
spawnedFacialFeatures_ = ch->facialFeatures;
}
}
// Set up camera controller for first-person player hiding
if (renderer->getCameraController()) {
renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId);
}
// Load equipped weapons (sword + shield)
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
}
}
void Application::buildFactionHostilityMap(uint8_t playerRace) {
if (!assetManager || !assetManager->isInitialized() || !gameHandler) return;
auto ftDbc = assetManager->loadDBC("FactionTemplate.dbc");
auto fDbc = assetManager->loadDBC("Faction.dbc");
if (!ftDbc || !ftDbc->isLoaded()) return;
// Race enum → race mask bit: race 1=0x1, 2=0x2, 3=0x4, 4=0x8, 5=0x10, 6=0x20, 7=0x40, 8=0x80, 10=0x200, 11=0x400
uint32_t playerRaceMask = 0;
if (playerRace >= 1 && playerRace <= 8) {
playerRaceMask = 1u << (playerRace - 1);
} else if (playerRace == 10) {
playerRaceMask = 0x200; // Blood Elf
} else if (playerRace == 11) {
playerRaceMask = 0x400; // Draenei
}
// Race → player faction template ID
// Human=1, Orc=2, Dwarf=3, NightElf=4, Undead=5, Tauren=6, Gnome=115, Troll=116, BloodElf=1610, Draenei=1629
uint32_t playerFtId = 0;
switch (playerRace) {
case 1: playerFtId = 1; break; // Human
case 2: playerFtId = 2; break; // Orc
case 3: playerFtId = 3; break; // Dwarf
case 4: playerFtId = 4; break; // Night Elf
case 5: playerFtId = 5; break; // Undead
case 6: playerFtId = 6; break; // Tauren
case 7: playerFtId = 115; break; // Gnome
case 8: playerFtId = 116; break; // Troll
case 10: playerFtId = 1610; break; // Blood Elf
case 11: playerFtId = 1629; break; // Draenei
default: playerFtId = 1; break;
}
// Build set of hostile parent faction IDs from Faction.dbc base reputation
const auto* facL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr;
const auto* ftL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("FactionTemplate") : nullptr;
std::unordered_set<uint32_t> hostileParentFactions;
if (fDbc && fDbc->isLoaded()) {
const uint32_t facID = facL ? (*facL)["ID"] : 0;
const uint32_t facRaceMask0 = facL ? (*facL)["ReputationRaceMask0"] : 2;
const uint32_t facBase0 = facL ? (*facL)["ReputationBase0"] : 10;
for (uint32_t i = 0; i < fDbc->getRecordCount(); i++) {
uint32_t factionId = fDbc->getUInt32(i, facID);
for (int slot = 0; slot < 4; slot++) {
uint32_t raceMask = fDbc->getUInt32(i, facRaceMask0 + slot);
if (raceMask & playerRaceMask) {
int32_t baseRep = fDbc->getInt32(i, facBase0 + slot);
if (baseRep < 0) {
hostileParentFactions.insert(factionId);
}
break;
}
}
}
LOG_INFO("Faction.dbc: ", hostileParentFactions.size(), " factions hostile to race ", static_cast<int>(playerRace));
}
// Get player faction template data
const uint32_t ftID = ftL ? (*ftL)["ID"] : 0;
const uint32_t ftFaction = ftL ? (*ftL)["Faction"] : 1;
const uint32_t ftFG = ftL ? (*ftL)["FactionGroup"] : 3;
const uint32_t ftFriend = ftL ? (*ftL)["FriendGroup"] : 4;
const uint32_t ftEnemy = ftL ? (*ftL)["EnemyGroup"] : 5;
const uint32_t ftEnemy0 = ftL ? (*ftL)["Enemy0"] : 6;
uint32_t playerFriendGroup = 0;
uint32_t playerEnemyGroup = 0;
uint32_t playerFactionId = 0;
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
if (ftDbc->getUInt32(i, ftID) == playerFtId) {
playerFriendGroup = ftDbc->getUInt32(i, ftFriend) | ftDbc->getUInt32(i, ftFG);
playerEnemyGroup = ftDbc->getUInt32(i, ftEnemy);
playerFactionId = ftDbc->getUInt32(i, ftFaction);
break;
}
}
// Build hostility map for each faction template
std::unordered_map<uint32_t, bool> factionMap;
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
uint32_t id = ftDbc->getUInt32(i, ftID);
uint32_t parentFaction = ftDbc->getUInt32(i, ftFaction);
uint32_t factionGroup = ftDbc->getUInt32(i, ftFG);
uint32_t friendGroup = ftDbc->getUInt32(i, ftFriend);
uint32_t enemyGroup = ftDbc->getUInt32(i, ftEnemy);
// 1. Symmetric group check
bool hostile = (enemyGroup & playerFriendGroup) != 0
|| (factionGroup & playerEnemyGroup) != 0;
// 2. Monster factionGroup bit (8)
if (!hostile && (factionGroup & 8) != 0) {
hostile = true;
}
// 3. Individual enemy faction IDs
if (!hostile && playerFactionId > 0) {
for (uint32_t e = ftEnemy0; e <= ftEnemy0 + 3; e++) {
if (ftDbc->getUInt32(i, e) == playerFactionId) {
hostile = true;
break;
}
}
}
// 4. Parent faction base reputation check (Faction.dbc)
if (!hostile && parentFaction > 0) {
if (hostileParentFactions.count(parentFaction)) {
hostile = true;
}
}
// 5. If explicitly friendly (friendGroup includes player), override to non-hostile
if (hostile && (friendGroup & playerFriendGroup) != 0) {
hostile = false;
}
factionMap[id] = hostile;
}
uint32_t hostileCount = 0;
for (const auto& [fid, h] : factionMap) { if (h) hostileCount++; }
gameHandler->setFactionHostileMap(std::move(factionMap));
LOG_INFO("Faction hostility for race ", static_cast<int>(playerRace), " (FT ", playerFtId, "): ",
hostileCount, "/", ftDbc->getRecordCount(),
" hostile (friendGroup=0x", std::hex, playerFriendGroup, ", enemyGroup=0x", playerEnemyGroup, std::dec, ")");
}
// Render bounds/position queries — delegates to EntitySpawner
bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const {
if (entitySpawner_) return entitySpawner_->getRenderBoundsForGuid(guid, outCenter, outRadius);
return false;
}
bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const {
if (entitySpawner_) return entitySpawner_->getRenderFootZForGuid(guid, outFootZ);
return false;
}
bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const {
if (entitySpawner_) return entitySpawner_->getRenderPositionForGuid(guid, outPos);
return false;
}
void Application::loadQuestMarkerModels() {
if (!assetManager || !renderer) return;
// Quest markers are billboard sprites; the renderer's QuestMarkerRenderer handles
// texture loading and pipeline setup during world initialization.
// Calling initialize() here is a no-op if already done; harmless if called early.
if (auto* qmr = renderer->getQuestMarkerRenderer()) {
if (auto* vkCtx = renderer->getVkContext()) {
VkDescriptorSetLayout pfl = renderer->getPerFrameSetLayout();
if (pfl != VK_NULL_HANDLE) {
qmr->initialize(vkCtx, pfl, assetManager.get());
}
}
}
}
void Application::updateQuestMarkers() {
if (!gameHandler || !renderer) {
return;
}
auto* questMarkerRenderer = renderer->getQuestMarkerRenderer();
if (!questMarkerRenderer) {
static bool logged = false;
if (!logged) {
LOG_WARNING("QuestMarkerRenderer not available!");
logged = true;
}
return;
}
const auto& questStatuses = gameHandler->getNpcQuestStatuses();
// Clear all markers (we'll re-add active ones)
questMarkerRenderer->clear();
static bool firstRun = true;
int markersAdded = 0;
// Add markers for NPCs with quest status
for (const auto& [guid, status] : questStatuses) {
// Determine marker type
int markerType = -1; // -1 = no marker
using game::QuestGiverStatus;
float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests)
switch (status) {
case QuestGiverStatus::AVAILABLE:
markerType = 0; // Yellow !
break;
case QuestGiverStatus::AVAILABLE_LOW:
markerType = 0; // Grey ! (same texture, desaturated in shader)
markerGrayscale = 1.0f;
break;
case QuestGiverStatus::REWARD:
case QuestGiverStatus::REWARD_REP:
markerType = 1; // Yellow ?
break;
case QuestGiverStatus::INCOMPLETE:
markerType = 2; // Grey ?
break;
default:
break;
}
if (markerType < 0) continue;
// Get NPC entity position
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity) continue;
if (entity->getType() == game::ObjectType::UNIT) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
std::string name = unit->getName();
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
if (name.find("spirit healer") != std::string::npos ||
name.find("spirit guide") != std::string::npos) {
continue; // Spirit healers/guides use their own white visual cue.
}
}
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = coords::canonicalToRender(canonical);
// Get NPC bounding height for proper marker positioning
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
float boundingHeight = 2.0f; // Default
if (getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
boundingHeight = boundsRadius * 2.0f;
}
// Set the marker (renderer will handle positioning, bob, glow, etc.)
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale);
markersAdded++;
}
if (firstRun && markersAdded > 0) {
LOG_DEBUG("Quest markers: Added ", markersAdded, " markers on first run");
firstRun = false;
}
}
void Application::setupTestTransport() {
if (!entitySpawner_) return;
if (entitySpawner_->isTestTransportSetup()) return;
if (!gameHandler || !renderer || !assetManager) return;
auto* transportManager = gameHandler->getTransportManager();
auto* wmoRenderer = renderer->getWMORenderer();
if (!transportManager || !wmoRenderer) return;
LOG_INFO("========================================");
LOG_INFO(" SETTING UP TEST TRANSPORT");
LOG_INFO("========================================");
// Connect transport manager to WMO renderer
transportManager->setWMORenderer(wmoRenderer);
// Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents)
if (renderer->getM2Renderer()) {
wmoRenderer->setM2Renderer(renderer->getM2Renderer());
LOG_INFO("WMORenderer connected to M2Renderer for test transport doodad transforms");
}
// Define a simple circular path around Stormwind harbor (canonical coordinates)
// These coordinates are approximate - adjust based on actual harbor layout
std::vector<glm::vec3> harborPath = {
{-8833.0f, 628.0f, 94.0f}, // Start point (Stormwind harbor)
{-8900.0f, 650.0f, 94.0f}, // Move west
{-8950.0f, 700.0f, 94.0f}, // Northwest
{-8950.0f, 780.0f, 94.0f}, // North
{-8900.0f, 830.0f, 94.0f}, // Northeast
{-8833.0f, 850.0f, 94.0f}, // East
{-8766.0f, 830.0f, 94.0f}, // Southeast
{-8716.0f, 780.0f, 94.0f}, // South
{-8716.0f, 700.0f, 94.0f}, // Southwest
{-8766.0f, 650.0f, 94.0f}, // Back to start direction
};
// Register the path with transport manager
uint32_t pathId = 1;
float speed = 12.0f; // 12 units/sec (slower than taxi for a leisurely boat ride)
transportManager->loadPathFromNodes(pathId, harborPath, true, speed);
LOG_INFO("Registered transport path ", pathId, " with ", harborPath.size(), " waypoints, speed=", speed);
// Try transport WMOs in manifest-backed paths first.
std::vector<std::string> transportCandidates = {
"World\\wmo\\transports\\transport_ship\\transportship.wmo",
"World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo",
"World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo",
"World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo",
// Legacy fallbacks
"Transports\\Transportship\\Transportship.wmo",
"Transports\\Boat\\Boat.wmo",
};
std::string transportWmoPath;
std::vector<uint8_t> wmoData;
for (const auto& candidate : transportCandidates) {
wmoData = assetManager->readFile(candidate);
if (!wmoData.empty()) {
transportWmoPath = candidate;
break;
}
}
if (wmoData.empty()) {
LOG_WARNING("No transport WMO found - test transport disabled");
LOG_INFO("Expected under World\\wmo\\transports\\...");
return;
}
LOG_INFO("Using transport WMO: ", transportWmoPath);
// Load WMO model
pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData);
LOG_INFO("Transport WMO root loaded: ", transportWmoPath, " nGroups=", wmoModel.nGroups);
// Load WMO groups
int loadedGroups = 0;
if (wmoModel.nGroups > 0) {
std::string basePath = transportWmoPath.substr(0, transportWmoPath.size() - 4);
for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) {
char groupSuffix[16];
snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi);
std::string groupPath = basePath + groupSuffix;
std::vector<uint8_t> groupData = assetManager->readFile(groupPath);
if (!groupData.empty()) {
pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi);
loadedGroups++;
} else {
LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath);
}
}
}
if (loadedGroups == 0 && wmoModel.nGroups > 0) {
LOG_WARNING("Failed to load any WMO groups for transport");
return;
}
// Load WMO into renderer
uint32_t wmoModelId = 99999; // Use high ID to avoid conflicts
if (!wmoRenderer->loadModel(wmoModel, wmoModelId)) {
LOG_WARNING("Failed to load transport WMO model into renderer");
return;
}
// Create WMO instance at first waypoint (convert canonical to render coords)
glm::vec3 startCanonical = harborPath[0];
glm::vec3 startRender = core::coords::canonicalToRender(startCanonical);
uint32_t wmoInstanceId = wmoRenderer->createInstance(wmoModelId, startRender,
glm::vec3(0.0f, 0.0f, 0.0f), 1.0f);
if (wmoInstanceId == 0) {
LOG_WARNING("Failed to create transport WMO instance");
return;
}
// Register transport with transport manager
uint64_t transportGuid = 0x1000000000000001ULL; // Fake GUID for test
transportManager->registerTransport(transportGuid, wmoInstanceId, pathId, startCanonical);
// Optional: Set deck bounds (rough estimate for a ship deck)
transportManager->setDeckBounds(transportGuid,
glm::vec3(-15.0f, -30.0f, 0.0f),
glm::vec3(15.0f, 30.0f, 10.0f));
entitySpawner_->setTestTransportSetup(true);
LOG_INFO("========================================");
LOG_INFO("Test transport registered:");
LOG_INFO(" GUID: 0x", std::hex, transportGuid, std::dec);
LOG_INFO(" WMO Instance: ", wmoInstanceId);
LOG_INFO(" Path: ", pathId, " (", harborPath.size(), " waypoints)");
LOG_INFO(" Speed: ", speed, " units/sec");
LOG_INFO("========================================");
LOG_INFO("");
LOG_INFO("To board the transport, use console command:");
LOG_INFO(" /transport board");
LOG_INFO("To disembark:");
LOG_INFO(" /transport leave");
LOG_INFO("========================================");
}
} // namespace core
} // namespace wowee