2026-04-01 20:06:26 +03:00
|
|
|
// WorldLoader — terrain streaming, map transitions, world preloading
|
|
|
|
|
// Extracted from Application as part of god-class decomposition (Section 3.3)
|
|
|
|
|
|
|
|
|
|
#include "core/world_loader.hpp"
|
|
|
|
|
#include "core/application.hpp"
|
refactor(core): decompose Application::setupUICallbacks() into 7 domain handlers
Extract ~1,700 lines / 60+ inline [this]-capturing lambdas from the monolithic
Application::setupUICallbacks() into 7 focused callback handler classes following
the ToastManager/ChatPanel::setupCallbacks() pattern already in the codebase.
New handlers (include/core/ + src/core/):
- NPCInteractionCallbackHandler NPC greeting/farewell/vendor/aggro voice
- AudioCallbackHandler Music, positional sound, level-up, achievement, LFG
- EntitySpawnCallbackHandler Creature/player/GO spawn, despawn, move, state
- AnimationCallbackHandler Death, respawn, combat, emotes, charge, sprint, vehicle
- TransportCallbackHandler Mount, taxi, transport spawn/move
- WorldEntryCallbackHandler World entry, unstuck, hearthstone, bind point
- UIScreenCallbackHandler Auth, realm selection, char selection/creation/deletion
application.cpp: 4,462 → 2,791 lines (−1,671)
setupUICallbacks: ~1,700 → ~50 lines (thin orchestrator)
Deduplication:
resolveSoundEntryPath() — was 3× copy-paste of SoundEntries.dbc lookup
resolveNpcVoiceType() — was 4× copy-paste of display-ID→voice detection
precacheNearbyTiles() — was 3× copy-paste of 17×17 tile loop
4 helper lambdas — promoted to private methods on WorldEntryCallbackHandler
State migration out of Application:
charge* (6 vars) → AnimationCallbackHandler
hearth*/worldEntry*/taxi* → WorldEntryCallbackHandler
pendingCreatedCharacterName_ → UIScreenCallbackHandler
Bug fixes:
- Duplicate `namespace core {` in application.hpp caused wowee::std pollution
- AppState forward decl in ui_screen_callback_handler.hpp was at wrong scope
- world_loader.cpp accessed moved member vars directly via friend; now uses handler API
2026-04-05 16:48:17 +03:00
|
|
|
#include "core/world_entry_callback_handler.hpp"
|
2026-04-05 12:27:35 +03:00
|
|
|
#include "rendering/animation/animation_ids.hpp"
|
2026-04-01 20:06:26 +03:00
|
|
|
#include "core/entity_spawner.hpp"
|
|
|
|
|
#include "core/appearance_composer.hpp"
|
|
|
|
|
#include "core/window.hpp"
|
|
|
|
|
#include "core/coordinates.hpp"
|
|
|
|
|
#include "core/logger.hpp"
|
|
|
|
|
#include "rendering/renderer.hpp"
|
|
|
|
|
#include "rendering/vk_context.hpp"
|
|
|
|
|
#include "rendering/camera.hpp"
|
|
|
|
|
#include "rendering/camera_controller.hpp"
|
|
|
|
|
#include "rendering/terrain_manager.hpp"
|
|
|
|
|
#include "rendering/character_renderer.hpp"
|
|
|
|
|
#include "rendering/wmo_renderer.hpp"
|
|
|
|
|
#include "rendering/m2_renderer.hpp"
|
|
|
|
|
#include "rendering/quest_marker_renderer.hpp"
|
|
|
|
|
#include "rendering/loading_screen.hpp"
|
|
|
|
|
#include "addons/addon_manager.hpp"
|
|
|
|
|
#include "pipeline/asset_manager.hpp"
|
|
|
|
|
#include "pipeline/dbc_layout.hpp"
|
|
|
|
|
#include "pipeline/m2_loader.hpp"
|
|
|
|
|
#include "pipeline/wmo_loader.hpp"
|
|
|
|
|
#include "pipeline/wdt_loader.hpp"
|
|
|
|
|
#include "game/game_handler.hpp"
|
|
|
|
|
#include "game/transport_manager.hpp"
|
|
|
|
|
#include "game/world.hpp"
|
|
|
|
|
|
|
|
|
|
#include <SDL2/SDL.h>
|
|
|
|
|
#include <chrono>
|
|
|
|
|
#include <filesystem>
|
|
|
|
|
#include <fstream>
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <cctype>
|
|
|
|
|
#include <glm/gtc/quaternion.hpp>
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
namespace core {
|
|
|
|
|
|
|
|
|
|
WorldLoader::WorldLoader(Application& app,
|
|
|
|
|
rendering::Renderer* renderer,
|
|
|
|
|
pipeline::AssetManager* assetManager,
|
|
|
|
|
game::GameHandler* gameHandler,
|
|
|
|
|
EntitySpawner* entitySpawner,
|
|
|
|
|
AppearanceComposer* appearanceComposer,
|
|
|
|
|
Window* window,
|
|
|
|
|
game::World* world,
|
|
|
|
|
addons::AddonManager* addonManager)
|
|
|
|
|
: app_(app)
|
|
|
|
|
, renderer_(renderer)
|
|
|
|
|
, assetManager_(assetManager)
|
|
|
|
|
, gameHandler_(gameHandler)
|
|
|
|
|
, entitySpawner_(entitySpawner)
|
|
|
|
|
, appearanceComposer_(appearanceComposer)
|
|
|
|
|
, window_(window)
|
|
|
|
|
, world_(world)
|
|
|
|
|
, addonManager_(addonManager)
|
|
|
|
|
{}
|
|
|
|
|
|
|
|
|
|
WorldLoader::~WorldLoader() {
|
|
|
|
|
cancelWorldPreload();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const char* WorldLoader::mapDisplayName(uint32_t mapId) {
|
|
|
|
|
// Friendly display names for the loading screen
|
|
|
|
|
switch (mapId) {
|
|
|
|
|
case 0: return "Eastern Kingdoms";
|
|
|
|
|
case 1: return "Kalimdor";
|
|
|
|
|
case 530: return "Outland";
|
|
|
|
|
case 571: return "Northrend";
|
|
|
|
|
default: return nullptr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const char* WorldLoader::mapIdToName(uint32_t mapId) {
|
|
|
|
|
// Fallback when Map.dbc is unavailable. Names must match WDT directory names
|
|
|
|
|
// (case-insensitive — AssetManager lowercases all paths).
|
|
|
|
|
switch (mapId) {
|
|
|
|
|
// Continents
|
|
|
|
|
case 0: return "Azeroth";
|
|
|
|
|
case 1: return "Kalimdor";
|
|
|
|
|
case 530: return "Expansion01";
|
|
|
|
|
case 571: return "Northrend";
|
|
|
|
|
// Classic dungeons/raids
|
|
|
|
|
case 30: return "PVPZone01";
|
|
|
|
|
case 33: return "Shadowfang";
|
|
|
|
|
case 34: return "StormwindJail";
|
|
|
|
|
case 36: return "DeadminesInstance";
|
|
|
|
|
case 43: return "WailingCaverns";
|
|
|
|
|
case 47: return "RazserfenKraulInstance";
|
|
|
|
|
case 48: return "Blackfathom";
|
|
|
|
|
case 70: return "Uldaman";
|
|
|
|
|
case 90: return "GnomeragonInstance";
|
|
|
|
|
case 109: return "SunkenTemple";
|
|
|
|
|
case 129: return "RazorfenDowns";
|
|
|
|
|
case 189: return "MonasteryInstances";
|
|
|
|
|
case 209: return "TanarisInstance";
|
|
|
|
|
case 229: return "BlackRockSpire";
|
|
|
|
|
case 230: return "BlackrockDepths";
|
|
|
|
|
case 249: return "OnyxiaLairInstance";
|
|
|
|
|
case 289: return "ScholomanceInstance";
|
|
|
|
|
case 309: return "Zul'Gurub";
|
|
|
|
|
case 329: return "Stratholme";
|
|
|
|
|
case 349: return "Mauradon";
|
|
|
|
|
case 369: return "DeeprunTram";
|
|
|
|
|
case 389: return "OrgrimmarInstance";
|
|
|
|
|
case 409: return "MoltenCore";
|
|
|
|
|
case 429: return "DireMaul";
|
|
|
|
|
case 469: return "BlackwingLair";
|
|
|
|
|
case 489: return "PVPZone03";
|
|
|
|
|
case 509: return "AhnQiraj";
|
|
|
|
|
case 529: return "PVPZone04";
|
|
|
|
|
case 531: return "AhnQirajTemple";
|
|
|
|
|
case 533: return "Stratholme Raid";
|
|
|
|
|
// TBC
|
|
|
|
|
case 532: return "Karazahn";
|
|
|
|
|
case 534: return "HyjalPast";
|
|
|
|
|
case 540: return "HellfireMilitary";
|
|
|
|
|
case 542: return "HellfireDemon";
|
|
|
|
|
case 543: return "HellfireRampart";
|
|
|
|
|
case 544: return "HellfireRaid";
|
|
|
|
|
case 545: return "CoilfangPumping";
|
|
|
|
|
case 546: return "CoilfangMarsh";
|
|
|
|
|
case 547: return "CoilfangDraenei";
|
|
|
|
|
case 548: return "CoilfangRaid";
|
|
|
|
|
case 550: return "TempestKeepRaid";
|
|
|
|
|
case 552: return "TempestKeepArcane";
|
|
|
|
|
case 553: return "TempestKeepAtrium";
|
|
|
|
|
case 554: return "TempestKeepFactory";
|
|
|
|
|
case 555: return "AuchindounShadow";
|
|
|
|
|
case 556: return "AuchindounDraenei";
|
|
|
|
|
case 557: return "AuchindounEthereal";
|
|
|
|
|
case 558: return "AuchindounDemon";
|
|
|
|
|
case 560: return "HillsbradPast";
|
|
|
|
|
case 564: return "BlackTemple";
|
|
|
|
|
case 565: return "GruulsLair";
|
|
|
|
|
case 566: return "PVPZone05";
|
|
|
|
|
case 568: return "ZulAman";
|
|
|
|
|
case 580: return "SunwellPlateau";
|
|
|
|
|
case 585: return "Sunwell5ManFix";
|
|
|
|
|
// WotLK
|
|
|
|
|
case 574: return "Valgarde70";
|
|
|
|
|
case 575: return "UtgardePinnacle";
|
|
|
|
|
case 576: return "Nexus70";
|
|
|
|
|
case 578: return "Nexus80";
|
|
|
|
|
case 595: return "StratholmeCOT";
|
|
|
|
|
case 599: return "Ulduar70";
|
|
|
|
|
case 600: return "Ulduar80";
|
|
|
|
|
case 601: return "DrakTheronKeep";
|
|
|
|
|
case 602: return "GunDrak";
|
|
|
|
|
case 603: return "UlduarRaid";
|
|
|
|
|
case 608: return "DalaranPrison";
|
|
|
|
|
case 615: return "ChamberOfAspectsBlack";
|
|
|
|
|
case 617: return "DeathKnightStart";
|
|
|
|
|
case 619: return "Azjol_Uppercity";
|
|
|
|
|
case 624: return "WintergraspRaid";
|
|
|
|
|
case 631: return "IcecrownCitadel";
|
|
|
|
|
case 632: return "IcecrownCitadel5Man";
|
|
|
|
|
case 649: return "ArgentTournamentRaid";
|
|
|
|
|
case 650: return "ArgentTournamentDungeon";
|
|
|
|
|
case 658: return "QuarryOfTears";
|
|
|
|
|
case 668: return "HallsOfReflection";
|
|
|
|
|
case 724: return "ChamberOfAspectsRed";
|
|
|
|
|
default: return "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void WorldLoader::processPendingEntry() {
|
|
|
|
|
if (!pendingWorldEntry_ || loadingWorld_) return;
|
|
|
|
|
auto entry = *pendingWorldEntry_;
|
|
|
|
|
pendingWorldEntry_.reset();
|
|
|
|
|
LOG_WARNING("Processing deferred world entry: map ", entry.mapId);
|
refactor(core): decompose Application::setupUICallbacks() into 7 domain handlers
Extract ~1,700 lines / 60+ inline [this]-capturing lambdas from the monolithic
Application::setupUICallbacks() into 7 focused callback handler classes following
the ToastManager/ChatPanel::setupCallbacks() pattern already in the codebase.
New handlers (include/core/ + src/core/):
- NPCInteractionCallbackHandler NPC greeting/farewell/vendor/aggro voice
- AudioCallbackHandler Music, positional sound, level-up, achievement, LFG
- EntitySpawnCallbackHandler Creature/player/GO spawn, despawn, move, state
- AnimationCallbackHandler Death, respawn, combat, emotes, charge, sprint, vehicle
- TransportCallbackHandler Mount, taxi, transport spawn/move
- WorldEntryCallbackHandler World entry, unstuck, hearthstone, bind point
- UIScreenCallbackHandler Auth, realm selection, char selection/creation/deletion
application.cpp: 4,462 → 2,791 lines (−1,671)
setupUICallbacks: ~1,700 → ~50 lines (thin orchestrator)
Deduplication:
resolveSoundEntryPath() — was 3× copy-paste of SoundEntries.dbc lookup
resolveNpcVoiceType() — was 4× copy-paste of display-ID→voice detection
precacheNearbyTiles() — was 3× copy-paste of 17×17 tile loop
4 helper lambdas — promoted to private methods on WorldEntryCallbackHandler
State migration out of Application:
charge* (6 vars) → AnimationCallbackHandler
hearth*/worldEntry*/taxi* → WorldEntryCallbackHandler
pendingCreatedCharacterName_ → UIScreenCallbackHandler
Bug fixes:
- Duplicate `namespace core {` in application.hpp caused wowee::std pollution
- AppState forward decl in ui_screen_callback_handler.hpp was at wrong scope
- world_loader.cpp accessed moved member vars directly via friend; now uses handler API
2026-04-05 16:48:17 +03:00
|
|
|
if (app_.worldEntryCallbacks_) {
|
|
|
|
|
app_.worldEntryCallbacks_->setWorldEntryMovementGraceTimer(2.0f);
|
|
|
|
|
app_.worldEntryCallbacks_->setTaxiLandingClampTimer(0.0f);
|
|
|
|
|
app_.worldEntryCallbacks_->setLastTaxiFlight(false);
|
|
|
|
|
}
|
2026-04-01 20:06:26 +03:00
|
|
|
// Clear camera movement inputs before loading terrain
|
|
|
|
|
if (renderer_ && renderer_->getCameraController()) {
|
|
|
|
|
renderer_->getCameraController()->clearMovementInputs();
|
|
|
|
|
renderer_->getCameraController()->suppressMovementFor(1.0f);
|
|
|
|
|
renderer_->getCameraController()->suspendGravityFor(10.0f);
|
|
|
|
|
}
|
|
|
|
|
loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void WorldLoader::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z) {
|
|
|
|
|
if (!renderer_ || !assetManager_ || !assetManager_->isInitialized()) {
|
|
|
|
|
LOG_WARNING("Cannot load online terrain: renderer or assets not ready");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Guard against re-entrant calls. The worldEntryCallback defers new
|
|
|
|
|
// entries while this flag is set; we process them at the end.
|
|
|
|
|
loadingWorld_ = true;
|
|
|
|
|
pendingWorldEntry_.reset();
|
|
|
|
|
|
|
|
|
|
// --- Loading screen for online mode ---
|
|
|
|
|
rendering::LoadingScreen loadingScreen;
|
|
|
|
|
loadingScreen.setVkContext(window_->getVkContext());
|
|
|
|
|
loadingScreen.setSDLWindow(window_->getSDLWindow());
|
|
|
|
|
bool loadingScreenOk = loadingScreen.initialize();
|
|
|
|
|
|
|
|
|
|
auto showProgress = [&](const char* msg, float progress) {
|
|
|
|
|
SDL_Event event;
|
|
|
|
|
while (SDL_PollEvent(&event)) {
|
|
|
|
|
if (event.type == SDL_QUIT) {
|
|
|
|
|
window_->setShouldClose(true);
|
|
|
|
|
loadingScreen.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.type == SDL_WINDOWEVENT &&
|
|
|
|
|
event.window.event == SDL_WINDOWEVENT_RESIZED) {
|
|
|
|
|
int w = event.window.data1;
|
|
|
|
|
int h = event.window.data2;
|
|
|
|
|
window_->setSize(w, h);
|
|
|
|
|
// Vulkan viewport set in command buffer
|
|
|
|
|
if (renderer_ && renderer_->getCamera()) {
|
|
|
|
|
renderer_->getCamera()->setAspectRatio(static_cast<float>(w) / h);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!loadingScreenOk) return;
|
|
|
|
|
loadingScreen.setStatus(msg);
|
|
|
|
|
loadingScreen.setProgress(progress);
|
|
|
|
|
loadingScreen.render();
|
|
|
|
|
window_->swapBuffers();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Set zone name on loading screen — prefer friendly display name, then DBC
|
|
|
|
|
{
|
|
|
|
|
const char* friendly = mapDisplayName(mapId);
|
|
|
|
|
if (friendly) {
|
|
|
|
|
loadingScreen.setZoneName(friendly);
|
|
|
|
|
} else if (gameHandler_) {
|
|
|
|
|
std::string dbcName = gameHandler_->getMapName(mapId);
|
|
|
|
|
if (!dbcName.empty())
|
|
|
|
|
loadingScreen.setZoneName(dbcName);
|
|
|
|
|
else
|
|
|
|
|
loadingScreen.setZoneName("Loading...");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showProgress("Entering world...", 0.0f);
|
|
|
|
|
|
|
|
|
|
// --- Clean up previous map's state on map change ---
|
|
|
|
|
// (Same cleanup as logout, but preserves player identity and renderer objects.)
|
|
|
|
|
LOG_WARNING("loadOnlineWorldTerrain: mapId=", mapId, " loadedMapId_=", loadedMapId_);
|
|
|
|
|
bool hasRendererData = renderer_ && (renderer_->getWMORenderer() || renderer_->getM2Renderer());
|
|
|
|
|
if (loadedMapId_ != 0xFFFFFFFF || hasRendererData) {
|
|
|
|
|
LOG_WARNING("Map change: cleaning up old map ", loadedMapId_, " before loading map ", mapId);
|
|
|
|
|
|
|
|
|
|
// Clear pending queues first (these don't touch GPU resources)
|
|
|
|
|
entitySpawner_->clearAllQueues();
|
|
|
|
|
|
|
|
|
|
if (renderer_) {
|
|
|
|
|
// Clear all world geometry from old map (including textures/models).
|
|
|
|
|
// WMO clearAll and M2 clear both call vkDeviceWaitIdle internally,
|
|
|
|
|
// ensuring no GPU command buffers reference old resources.
|
|
|
|
|
if (auto* wmo = renderer_->getWMORenderer()) {
|
|
|
|
|
wmo->clearAll();
|
|
|
|
|
}
|
|
|
|
|
if (auto* m2 = renderer_->getM2Renderer()) {
|
|
|
|
|
m2->clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Full clear of character renderer: removes all instances, models,
|
|
|
|
|
// textures, and resets descriptor pools. This prevents stale GPU
|
|
|
|
|
// resources from accumulating across map changes (old creature
|
|
|
|
|
// models, bone buffers, texture descriptor sets) which can cause
|
|
|
|
|
// VK_ERROR_DEVICE_LOST on some drivers.
|
|
|
|
|
if (auto* cr = renderer_->getCharacterRenderer()) {
|
|
|
|
|
cr->clear();
|
|
|
|
|
renderer_->setCharacterFollow(0);
|
|
|
|
|
}
|
|
|
|
|
// Reset equipment dirty tracking so composited textures are rebuilt
|
|
|
|
|
// after spawnPlayerCharacter() recreates the character instance.
|
|
|
|
|
if (gameHandler_) {
|
|
|
|
|
gameHandler_->resetEquipmentDirtyTracking();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (auto* terrain = renderer_->getTerrainManager()) {
|
|
|
|
|
terrain->softReset();
|
|
|
|
|
terrain->setStreamingEnabled(true); // Re-enable in case previous map disabled it
|
|
|
|
|
}
|
|
|
|
|
if (auto* questMarkers = renderer_->getQuestMarkerRenderer()) {
|
|
|
|
|
questMarkers->clear();
|
|
|
|
|
}
|
|
|
|
|
renderer_->clearMount();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear application-level instance tracking (after renderer cleanup)
|
|
|
|
|
entitySpawner_->resetAllState();
|
|
|
|
|
|
|
|
|
|
// Force player character re-spawn on new map
|
|
|
|
|
app_.playerCharacterSpawned = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve map folder name from Map.dbc (authoritative for world/instance maps).
|
|
|
|
|
// This is required for instances like DeeprunTram (map 369) that are not Azeroth/Kalimdor.
|
|
|
|
|
if (!mapNameCacheLoaded_ && assetManager_) {
|
|
|
|
|
mapNameCacheLoaded_ = true;
|
|
|
|
|
if (auto mapDbc = assetManager_->loadDBC("Map.dbc"); mapDbc && mapDbc->isLoaded()) {
|
|
|
|
|
mapNameById_.reserve(mapDbc->getRecordCount());
|
|
|
|
|
const auto* mapL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Map") : nullptr;
|
|
|
|
|
for (uint32_t i = 0; i < mapDbc->getRecordCount(); i++) {
|
|
|
|
|
uint32_t id = mapDbc->getUInt32(i, mapL ? (*mapL)["ID"] : 0);
|
|
|
|
|
std::string internalName = mapDbc->getString(i, mapL ? (*mapL)["InternalName"] : 1);
|
|
|
|
|
if (!internalName.empty() && mapNameById_.find(id) == mapNameById_.end()) {
|
|
|
|
|
mapNameById_[id] = std::move(internalName);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
LOG_INFO("Loaded Map.dbc map-name cache: ", mapNameById_.size(), " entries");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("Map.dbc not available; using fallback map-id mapping");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string mapName;
|
|
|
|
|
if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) {
|
|
|
|
|
mapName = it->second;
|
|
|
|
|
} else {
|
|
|
|
|
mapName = mapIdToName(mapId);
|
|
|
|
|
}
|
|
|
|
|
if (mapName.empty()) {
|
|
|
|
|
LOG_WARNING("Unknown mapId ", mapId, " (no Map.dbc entry); falling back to Azeroth");
|
|
|
|
|
mapName = "Azeroth";
|
|
|
|
|
}
|
|
|
|
|
LOG_INFO("Loading online world terrain for map '", mapName, "' (ID ", mapId, ")");
|
|
|
|
|
|
|
|
|
|
// Cancel any stale preload (if it was for a different map, the file cache
|
|
|
|
|
// still retains whatever was loaded — it doesn't hurt).
|
|
|
|
|
if (worldPreload_) {
|
|
|
|
|
if (worldPreload_->mapId == mapId) {
|
|
|
|
|
LOG_INFO("World preload: cache-warm hit for map '", mapName, "'");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_INFO("World preload: map mismatch (preloaded ", worldPreload_->mapName,
|
|
|
|
|
", entering ", mapName, ")");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
cancelWorldPreload();
|
|
|
|
|
|
|
|
|
|
// Save this world info for next session's early preload
|
|
|
|
|
saveLastWorldInfo(mapId, mapName, x, y);
|
|
|
|
|
|
|
|
|
|
// Convert server coordinates to canonical WoW coordinates
|
|
|
|
|
// Server sends: X=West (canonical.Y), Y=North (canonical.X), Z=Up
|
|
|
|
|
glm::vec3 spawnCanonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
|
|
|
|
|
glm::vec3 spawnRender = core::coords::canonicalToRender(spawnCanonical);
|
|
|
|
|
|
|
|
|
|
// Set camera position and facing from server orientation
|
|
|
|
|
if (renderer_->getCameraController()) {
|
|
|
|
|
float yawDeg = 0.0f;
|
|
|
|
|
if (gameHandler_) {
|
|
|
|
|
float canonicalYaw = gameHandler_->getMovementInfo().orientation;
|
|
|
|
|
yawDeg = 180.0f - glm::degrees(canonicalYaw);
|
|
|
|
|
}
|
|
|
|
|
renderer_->getCameraController()->setOnlineMode(true);
|
|
|
|
|
renderer_->getCameraController()->setDefaultSpawn(spawnRender, yawDeg, -15.0f);
|
|
|
|
|
renderer_->getCameraController()->reset();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set map name for WMO renderer and reset instance mode
|
|
|
|
|
if (renderer_->getWMORenderer()) {
|
|
|
|
|
renderer_->getWMORenderer()->setMapName(mapName);
|
|
|
|
|
renderer_->getWMORenderer()->setWMOOnlyMap(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set map name for terrain manager
|
|
|
|
|
if (renderer_->getTerrainManager()) {
|
|
|
|
|
renderer_->getTerrainManager()->setMapName(mapName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NOTE: TransportManager renderer connection moved to after initializeRenderers (later in this function)
|
|
|
|
|
|
|
|
|
|
// Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents)
|
|
|
|
|
if (renderer_->getWMORenderer() && renderer_->getM2Renderer()) {
|
|
|
|
|
renderer_->getWMORenderer()->setM2Renderer(renderer_->getM2Renderer());
|
|
|
|
|
LOG_INFO("WMORenderer connected to M2Renderer for hierarchical doodad transforms");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showProgress("Loading character model...", 0.05f);
|
|
|
|
|
|
|
|
|
|
// Build faction hostility map for this character's race
|
|
|
|
|
if (gameHandler_) {
|
|
|
|
|
const game::Character* activeChar = gameHandler_->getActiveCharacter();
|
|
|
|
|
if (activeChar) {
|
|
|
|
|
app_.buildFactionHostilityMap(static_cast<uint8_t>(activeChar->race));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spawn player model for online mode (skip if already spawned, e.g. teleport)
|
|
|
|
|
if (gameHandler_) {
|
|
|
|
|
const game::Character* activeChar = gameHandler_->getActiveCharacter();
|
|
|
|
|
if (activeChar) {
|
|
|
|
|
const uint64_t activeGuid = gameHandler_->getActiveCharacterGuid();
|
|
|
|
|
const bool appearanceChanged =
|
|
|
|
|
(activeGuid != app_.spawnedPlayerGuid_) ||
|
|
|
|
|
(activeChar->appearanceBytes != app_.spawnedAppearanceBytes_) ||
|
|
|
|
|
(activeChar->facialFeatures != app_.spawnedFacialFeatures_) ||
|
|
|
|
|
(activeChar->race != app_.playerRace_) ||
|
|
|
|
|
(activeChar->gender != app_.playerGender_) ||
|
|
|
|
|
(activeChar->characterClass != app_.playerClass_);
|
|
|
|
|
|
|
|
|
|
if (!app_.playerCharacterSpawned || appearanceChanged) {
|
|
|
|
|
if (appearanceChanged) {
|
|
|
|
|
LOG_INFO("Respawning player model for new/changed character: guid=0x",
|
|
|
|
|
std::hex, activeGuid, std::dec);
|
|
|
|
|
}
|
|
|
|
|
// Remove old instance so we don't keep stale visuals.
|
|
|
|
|
if (renderer_ && renderer_->getCharacterRenderer()) {
|
|
|
|
|
uint32_t oldInst = renderer_->getCharacterInstanceId();
|
|
|
|
|
if (oldInst > 0) {
|
|
|
|
|
renderer_->setCharacterFollow(0);
|
|
|
|
|
renderer_->clearMount();
|
|
|
|
|
renderer_->getCharacterRenderer()->removeInstance(oldInst);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
app_.playerCharacterSpawned = false;
|
|
|
|
|
app_.spawnedPlayerGuid_ = 0;
|
|
|
|
|
app_.spawnedAppearanceBytes_ = 0;
|
|
|
|
|
app_.spawnedFacialFeatures_ = 0;
|
|
|
|
|
|
|
|
|
|
app_.playerRace_ = activeChar->race;
|
|
|
|
|
app_.playerGender_ = activeChar->gender;
|
|
|
|
|
app_.playerClass_ = activeChar->characterClass;
|
|
|
|
|
app_.spawnSnapToGround = false;
|
|
|
|
|
if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
|
|
|
|
|
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); // will no-op until instance exists
|
|
|
|
|
app_.spawnPlayerCharacter();
|
|
|
|
|
}
|
|
|
|
|
renderer_->getCharacterPosition() = spawnRender;
|
|
|
|
|
LOG_INFO("Online player at render pos (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("No active character found for player model spawning");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showProgress("Loading terrain...", 0.20f);
|
|
|
|
|
|
|
|
|
|
// Check WDT to detect WMO-only maps (dungeons, raids, BGs)
|
|
|
|
|
bool isWMOOnlyMap = false;
|
|
|
|
|
pipeline::WDTInfo wdtInfo;
|
|
|
|
|
{
|
|
|
|
|
std::string wdtPath = "World\\Maps\\" + mapName + "\\" + mapName + ".wdt";
|
|
|
|
|
LOG_WARNING("Reading WDT: ", wdtPath);
|
|
|
|
|
std::vector<uint8_t> wdtData = assetManager_->readFile(wdtPath);
|
|
|
|
|
if (!wdtData.empty()) {
|
|
|
|
|
wdtInfo = pipeline::parseWDT(wdtData);
|
|
|
|
|
isWMOOnlyMap = wdtInfo.isWMOOnly() && !wdtInfo.rootWMOPath.empty();
|
|
|
|
|
LOG_WARNING("WDT result: isWMOOnly=", isWMOOnlyMap, " rootWMO='", wdtInfo.rootWMOPath, "'");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("No WDT file found at ", wdtPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool terrainOk = false;
|
|
|
|
|
|
|
|
|
|
if (isWMOOnlyMap) {
|
|
|
|
|
// ---- WMO-only map (dungeon/raid/BG): load root WMO directly ----
|
|
|
|
|
LOG_WARNING("WMO-only map detected — loading root WMO: ", wdtInfo.rootWMOPath);
|
|
|
|
|
showProgress("Loading instance geometry...", 0.25f);
|
|
|
|
|
|
|
|
|
|
// Initialize renderers if they don't exist yet (first login to a WMO-only map).
|
|
|
|
|
// On map change, renderers already exist from the previous map.
|
|
|
|
|
if (!renderer_->getWMORenderer() || !renderer_->getTerrainManager()) {
|
|
|
|
|
renderer_->initializeRenderers(assetManager_, mapName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set map name on WMO renderer and disable terrain streaming (no ADT tiles for instances)
|
|
|
|
|
if (renderer_->getWMORenderer()) {
|
|
|
|
|
renderer_->getWMORenderer()->setMapName(mapName);
|
|
|
|
|
renderer_->getWMORenderer()->setWMOOnlyMap(true);
|
|
|
|
|
}
|
|
|
|
|
if (renderer_->getTerrainManager()) {
|
|
|
|
|
renderer_->getTerrainManager()->setStreamingEnabled(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spawn player character now that renderers are initialized
|
|
|
|
|
if (!app_.playerCharacterSpawned) {
|
|
|
|
|
app_.spawnPlayerCharacter();
|
|
|
|
|
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load the root WMO
|
|
|
|
|
auto* wmoRenderer = renderer_->getWMORenderer();
|
|
|
|
|
LOG_WARNING("WMO-only: wmoRenderer=", (wmoRenderer ? "valid" : "NULL"));
|
|
|
|
|
if (wmoRenderer) {
|
|
|
|
|
LOG_WARNING("WMO-only: reading root WMO file: ", wdtInfo.rootWMOPath);
|
|
|
|
|
std::vector<uint8_t> wmoData = assetManager_->readFile(wdtInfo.rootWMOPath);
|
|
|
|
|
LOG_WARNING("WMO-only: root WMO data size=", wmoData.size());
|
|
|
|
|
if (!wmoData.empty()) {
|
|
|
|
|
pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData);
|
|
|
|
|
LOG_WARNING("WMO-only: parsed WMO model, nGroups=", wmoModel.nGroups);
|
|
|
|
|
|
|
|
|
|
if (wmoModel.nGroups > 0) {
|
|
|
|
|
showProgress("Loading instance groups...", 0.35f);
|
|
|
|
|
std::string basePath = wdtInfo.rootWMOPath;
|
|
|
|
|
std::string extension;
|
|
|
|
|
if (basePath.size() > 4) {
|
|
|
|
|
extension = basePath.substr(basePath.size() - 4);
|
|
|
|
|
std::string extLower = extension;
|
|
|
|
|
for (char& c : extLower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
|
|
|
if (extLower == ".wmo") {
|
|
|
|
|
basePath = basePath.substr(0, basePath.size() - 4);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint32_t loadedGroups = 0;
|
|
|
|
|
for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) {
|
|
|
|
|
char groupSuffix[16];
|
|
|
|
|
snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str());
|
|
|
|
|
std::string groupPath = basePath + groupSuffix;
|
|
|
|
|
std::vector<uint8_t> groupData = assetManager_->readFile(groupPath);
|
|
|
|
|
if (groupData.empty()) {
|
|
|
|
|
snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi);
|
|
|
|
|
groupData = assetManager_->readFile(basePath + groupSuffix);
|
|
|
|
|
}
|
|
|
|
|
if (groupData.empty()) {
|
|
|
|
|
snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi);
|
|
|
|
|
groupData = assetManager_->readFile(basePath + groupSuffix);
|
|
|
|
|
}
|
|
|
|
|
if (!groupData.empty()) {
|
|
|
|
|
pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi);
|
|
|
|
|
loadedGroups++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update loading progress
|
|
|
|
|
if (wmoModel.nGroups > 1) {
|
|
|
|
|
float groupProgress = 0.35f + 0.30f * static_cast<float>(gi + 1) / wmoModel.nGroups;
|
|
|
|
|
char buf[128];
|
|
|
|
|
snprintf(buf, sizeof(buf), "Loading instance groups... %u / %u", gi + 1, wmoModel.nGroups);
|
|
|
|
|
showProgress(buf, groupProgress);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Loaded ", loadedGroups, " / ", wmoModel.nGroups, " WMO groups for instance");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WMO-only maps: MODF uses same format as ADT MODF.
|
|
|
|
|
// Apply the same rotation conversion that outdoor WMOs get
|
|
|
|
|
// (including the implicit +180° Z yaw), but skip the ZEROPOINT
|
|
|
|
|
// position offset for zero-position instances (server sends
|
|
|
|
|
// coordinates relative to the WMO, not relative to map corner).
|
|
|
|
|
glm::vec3 wmoPos(0.0f);
|
|
|
|
|
glm::vec3 wmoRot(
|
|
|
|
|
-wdtInfo.rotation[2] * 3.14159f / 180.0f,
|
|
|
|
|
-wdtInfo.rotation[0] * 3.14159f / 180.0f,
|
|
|
|
|
(wdtInfo.rotation[1] + 180.0f) * 3.14159f / 180.0f
|
|
|
|
|
);
|
|
|
|
|
if (wdtInfo.position[0] != 0.0f || wdtInfo.position[1] != 0.0f || wdtInfo.position[2] != 0.0f) {
|
|
|
|
|
wmoPos = core::coords::adtToWorld(
|
|
|
|
|
wdtInfo.position[0], wdtInfo.position[1], wdtInfo.position[2]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showProgress("Uploading instance geometry...", 0.70f);
|
|
|
|
|
uint32_t wmoModelId = 900000 + mapId; // Unique ID range for instance WMOs
|
|
|
|
|
if (wmoRenderer->loadModel(wmoModel, wmoModelId)) {
|
|
|
|
|
uint32_t instanceId = wmoRenderer->createInstance(wmoModelId, wmoPos, wmoRot, 1.0f);
|
|
|
|
|
if (instanceId > 0) {
|
|
|
|
|
LOG_WARNING("Instance WMO loaded: modelId=", wmoModelId,
|
|
|
|
|
" instanceId=", instanceId);
|
|
|
|
|
LOG_WARNING(" MOHD bbox local: (",
|
|
|
|
|
wmoModel.boundingBoxMin.x, ", ", wmoModel.boundingBoxMin.y, ", ", wmoModel.boundingBoxMin.z,
|
|
|
|
|
") to (", wmoModel.boundingBoxMax.x, ", ", wmoModel.boundingBoxMax.y, ", ", wmoModel.boundingBoxMax.z, ")");
|
|
|
|
|
LOG_WARNING(" WMO pos: (", wmoPos.x, ", ", wmoPos.y, ", ", wmoPos.z,
|
|
|
|
|
") rot: (", wmoRot.x, ", ", wmoRot.y, ", ", wmoRot.z, ")");
|
|
|
|
|
LOG_WARNING(" Player render pos: (", spawnRender.x, ", ", spawnRender.y, ", ", spawnRender.z, ")");
|
|
|
|
|
LOG_WARNING(" Player canonical: (", spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")");
|
|
|
|
|
// Show player position in WMO local space
|
|
|
|
|
{
|
|
|
|
|
glm::mat4 instMat(1.0f);
|
|
|
|
|
instMat = glm::translate(instMat, wmoPos);
|
|
|
|
|
instMat = glm::rotate(instMat, wmoRot.z, glm::vec3(0,0,1));
|
|
|
|
|
instMat = glm::rotate(instMat, wmoRot.y, glm::vec3(0,1,0));
|
|
|
|
|
instMat = glm::rotate(instMat, wmoRot.x, glm::vec3(1,0,0));
|
|
|
|
|
glm::mat4 invMat = glm::inverse(instMat);
|
|
|
|
|
glm::vec3 localPlayer = glm::vec3(invMat * glm::vec4(spawnRender, 1.0f));
|
|
|
|
|
LOG_WARNING(" Player in WMO local: (", localPlayer.x, ", ", localPlayer.y, ", ", localPlayer.z, ")");
|
|
|
|
|
bool inside = localPlayer.x >= wmoModel.boundingBoxMin.x && localPlayer.x <= wmoModel.boundingBoxMax.x &&
|
|
|
|
|
localPlayer.y >= wmoModel.boundingBoxMin.y && localPlayer.y <= wmoModel.boundingBoxMax.y &&
|
|
|
|
|
localPlayer.z >= wmoModel.boundingBoxMin.z && localPlayer.z <= wmoModel.boundingBoxMax.z;
|
|
|
|
|
LOG_WARNING(" Player inside MOHD bbox: ", inside ? "YES" : "NO");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load doodads from the specified doodad set
|
|
|
|
|
auto* m2Renderer = renderer_->getM2Renderer();
|
|
|
|
|
if (m2Renderer && !wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) {
|
|
|
|
|
uint32_t setIdx = std::min(static_cast<uint32_t>(wdtInfo.doodadSet),
|
|
|
|
|
static_cast<uint32_t>(wmoModel.doodadSets.size() - 1));
|
|
|
|
|
const auto& doodadSet = wmoModel.doodadSets[setIdx];
|
|
|
|
|
|
|
|
|
|
showProgress("Loading instance doodads...", 0.75f);
|
|
|
|
|
glm::mat4 wmoMatrix(1.0f);
|
|
|
|
|
wmoMatrix = glm::translate(wmoMatrix, wmoPos);
|
|
|
|
|
wmoMatrix = glm::rotate(wmoMatrix, wmoRot.z, glm::vec3(0, 0, 1));
|
|
|
|
|
wmoMatrix = glm::rotate(wmoMatrix, wmoRot.y, glm::vec3(0, 1, 0));
|
|
|
|
|
wmoMatrix = glm::rotate(wmoMatrix, wmoRot.x, glm::vec3(1, 0, 0));
|
|
|
|
|
|
|
|
|
|
uint32_t loadedDoodads = 0;
|
|
|
|
|
for (uint32_t di = 0; di < doodadSet.count; di++) {
|
|
|
|
|
uint32_t doodadIdx = doodadSet.startIndex + di;
|
|
|
|
|
if (doodadIdx >= wmoModel.doodads.size()) break;
|
|
|
|
|
|
|
|
|
|
const auto& doodad = wmoModel.doodads[doodadIdx];
|
|
|
|
|
auto nameIt = wmoModel.doodadNames.find(doodad.nameIndex);
|
|
|
|
|
if (nameIt == wmoModel.doodadNames.end()) continue;
|
|
|
|
|
|
|
|
|
|
std::string m2Path = nameIt->second;
|
|
|
|
|
if (m2Path.empty()) continue;
|
|
|
|
|
|
|
|
|
|
if (m2Path.size() > 4) {
|
|
|
|
|
std::string ext = m2Path.substr(m2Path.size() - 4);
|
|
|
|
|
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
|
|
|
if (ext == ".mdx" || ext == ".mdl") {
|
|
|
|
|
m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<uint8_t> m2Data = assetManager_->readFile(m2Path);
|
|
|
|
|
if (m2Data.empty()) continue;
|
|
|
|
|
|
|
|
|
|
pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data);
|
|
|
|
|
if (m2Model.name.empty()) m2Model.name = m2Path;
|
|
|
|
|
|
|
|
|
|
std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin";
|
|
|
|
|
std::vector<uint8_t> skinData = assetManager_->readFile(skinPath);
|
|
|
|
|
if (!skinData.empty() && m2Model.version >= 264) {
|
|
|
|
|
pipeline::M2Loader::loadSkin(skinData, m2Model);
|
|
|
|
|
}
|
|
|
|
|
if (!m2Model.isValid()) continue;
|
|
|
|
|
|
|
|
|
|
glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x,
|
|
|
|
|
doodad.rotation.y, doodad.rotation.z);
|
|
|
|
|
glm::mat4 doodadLocal(1.0f);
|
|
|
|
|
doodadLocal = glm::translate(doodadLocal, doodad.position);
|
|
|
|
|
doodadLocal *= glm::mat4_cast(fixedRotation);
|
|
|
|
|
doodadLocal = glm::scale(doodadLocal, glm::vec3(doodad.scale));
|
|
|
|
|
|
|
|
|
|
glm::mat4 worldMatrix = wmoMatrix * doodadLocal;
|
|
|
|
|
glm::vec3 worldPos = glm::vec3(worldMatrix[3]);
|
|
|
|
|
|
|
|
|
|
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(m2Path));
|
|
|
|
|
if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue;
|
|
|
|
|
uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos);
|
|
|
|
|
if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true);
|
|
|
|
|
loadedDoodads++;
|
|
|
|
|
}
|
|
|
|
|
LOG_INFO("Loaded ", loadedDoodads, " instance WMO doodads");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("Failed to create instance WMO instance");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("Failed to load instance WMO model");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("Failed to read root WMO file: ", wdtInfo.rootWMOPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build collision cache for the instance WMO
|
|
|
|
|
showProgress("Building collision cache...", 0.88f);
|
|
|
|
|
if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); }
|
|
|
|
|
wmoRenderer->loadFloorCache();
|
|
|
|
|
if (wmoRenderer->getFloorCacheSize() == 0) {
|
|
|
|
|
showProgress("Computing walkable surfaces...", 0.90f);
|
|
|
|
|
if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); }
|
|
|
|
|
wmoRenderer->precomputeFloorCache();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Snap player to WMO floor so they don't fall through on first frame
|
|
|
|
|
if (wmoRenderer && renderer_) {
|
|
|
|
|
glm::vec3 playerPos = renderer_->getCharacterPosition();
|
|
|
|
|
// Query floor with generous height margin above spawn point
|
|
|
|
|
auto floor = wmoRenderer->getFloorHeight(playerPos.x, playerPos.y, playerPos.z + 50.0f);
|
|
|
|
|
if (floor) {
|
|
|
|
|
playerPos.z = *floor + 0.1f; // Small offset above floor
|
|
|
|
|
renderer_->getCharacterPosition() = playerPos;
|
|
|
|
|
if (gameHandler_) {
|
|
|
|
|
glm::vec3 canonical = core::coords::renderToCanonical(playerPos);
|
|
|
|
|
gameHandler_->setPosition(canonical.x, canonical.y, canonical.z);
|
|
|
|
|
}
|
|
|
|
|
LOG_INFO("Snapped player to instance WMO floor: z=", *floor);
|
|
|
|
|
} else {
|
|
|
|
|
LOG_WARNING("Could not find WMO floor at player spawn (",
|
|
|
|
|
playerPos.x, ", ", playerPos.y, ", ", playerPos.z, ")");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Diagnostic: verify WMO renderer state after instance loading
|
|
|
|
|
LOG_WARNING("=== INSTANCE WMO LOAD COMPLETE ===");
|
|
|
|
|
LOG_WARNING(" wmoRenderer models loaded: ", wmoRenderer->getLoadedModelCount());
|
|
|
|
|
LOG_WARNING(" wmoRenderer instances: ", wmoRenderer->getInstanceCount());
|
|
|
|
|
LOG_WARNING(" wmoRenderer floor cache: ", wmoRenderer->getFloorCacheSize());
|
|
|
|
|
|
|
|
|
|
terrainOk = true; // Mark as OK so post-load setup runs
|
|
|
|
|
} else {
|
|
|
|
|
// ---- Normal ADT-based map ----
|
|
|
|
|
// Compute ADT tile from canonical coordinates
|
|
|
|
|
auto [tileX, tileY] = core::coords::canonicalToTile(spawnCanonical.x, spawnCanonical.y);
|
|
|
|
|
std::string adtPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" +
|
|
|
|
|
std::to_string(tileX) + "_" + std::to_string(tileY) + ".adt";
|
|
|
|
|
LOG_INFO("Loading ADT tile [", tileX, ",", tileY, "] from canonical (",
|
|
|
|
|
spawnCanonical.x, ", ", spawnCanonical.y, ", ", spawnCanonical.z, ")");
|
|
|
|
|
|
|
|
|
|
// Load the initial terrain tile
|
|
|
|
|
terrainOk = renderer_->loadTestTerrain(assetManager_, adtPath);
|
|
|
|
|
if (!terrainOk) {
|
|
|
|
|
LOG_WARNING("Could not load terrain for online world - atmospheric rendering only");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_INFO("Online world terrain loading initiated");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set map name on WMO renderer (initializeRenderers handles terrain/minimap/worldMap)
|
|
|
|
|
if (renderer_->getWMORenderer()) {
|
|
|
|
|
renderer_->getWMORenderer()->setMapName(mapName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Character renderer is created inside loadTestTerrain(), so spawn the
|
|
|
|
|
// player model now that the renderer actually exists.
|
|
|
|
|
if (!app_.playerCharacterSpawned) {
|
|
|
|
|
app_.spawnPlayerCharacter();
|
|
|
|
|
if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showProgress("Streaming terrain tiles...", 0.35f);
|
|
|
|
|
|
|
|
|
|
// Wait for surrounding terrain tiles to stream in
|
|
|
|
|
if (terrainOk && renderer_->getTerrainManager() && renderer_->getCamera()) {
|
|
|
|
|
auto* terrainMgr = renderer_->getTerrainManager();
|
|
|
|
|
auto* camera = renderer_->getCamera();
|
|
|
|
|
|
|
|
|
|
// Use a small radius for the initial load (just immediate tiles),
|
|
|
|
|
// then restore the full radius after entering the game.
|
|
|
|
|
// This matches WoW's behavior: load quickly, stream the rest in-game.
|
feat(rendering): GPU architecture + visual quality fixes
M2 GPU instancing
- M2InstanceGPU SSBO (96 B/entry, double-buffered, 16384 max)
- Group opaque instances by (modelId, LOD); single vkCmdDrawIndexed per group
- boneBase field indexes into mega bone SSBO via gl_InstanceIndex
Indirect terrain drawing
- 24 MB mega index buffer (6M uint32) + 64 MB mega vertex buffer
- CPU builds VkDrawIndexedIndirectCommand per visible chunk
- Single VB/IB bind per frame; shadow pass reuses mega buffers
- Replaced vkCmdDrawIndexedIndirect with direct vkCmdDrawIndexed to fix
host-mapped buffer race condition that caused terrain flickering
GPU frustum culling (compute shader)
- m2_cull.comp.glsl: 64-thread workgroups, sphere-vs-6-planes + distance cull
- CullInstanceGPU SSBO input, uint visibility[] output, double-buffered
- dispatchCullCompute() runs before main pass via render graph node
Consolidated bone matrix SSBOs
- 16 MB double-buffered mega bone SSBO (2048 instances × 128 bones)
- Eliminated per-instance descriptor sets; one megaBoneSet_ per frame
- prepareRender() packs bone matrices consecutively into current frame slot
Render graph / frame graph
- RenderGraph: RGResource handles, RGPass nodes, Kahn topological sort
- Automatic VkImageMemoryBarrier/VkBufferMemoryBarrier between passes
- Passes: minimap_composite, worldmap_composite, preview_composite,
shadow_pass, reflection_pass, compute_cull
- beginFrame() uses buildFrameGraph() + renderGraph_->execute(cmd)
Pipeline derivatives
- PipelineBuilder::setFlags/setBasePipeline for VK_PIPELINE_CREATE_DERIVATIVE_BIT
- M2 opaque = base; alphaTest/alpha/additive are derivatives
- Applied to terrain (wireframe) and WMO (alpha-test) renderers
Rendering bug fixes:
- fix(shadow): compute lightSpaceMatrix before updatePerFrameUBO to eliminate
one-frame lag that caused shadow trails and flicker on moving objects
- fix(shadow): scale depth bias with shadowDistance_ instead of hardcoded 0.8f
to prevent acne at close range and gaps at far range
- fix(visibility): WMO group distance threshold 500u → 1200u to match terrain
view distance; buildings were disappearing on the horizon
- fix(precision): camera near plane 0.05 → 0.5 (ratio 600K:1 → 60K:1),
eliminating Z-fighting and improving frustum plane extraction stability
- fix(streaming): terrain load radius 4 → 6 tiles (~2133u → ~3200u) to exceed
M2 render distance (2800u) and eliminate pop-in when camera turns;
unload radius 7 → 9; spawn radius 3 → 4
- fix(visibility): ground-detail M2 distance multiplier 0.75 → 0.9 to reduce
early pop of grass and debris
2026-04-04 13:43:16 +03:00
|
|
|
const int savedLoadRadius = 6;
|
|
|
|
|
terrainMgr->setLoadRadius(4); // 9x9=81 tiles — prevents hitches on spawn
|
|
|
|
|
terrainMgr->setUnloadRadius(9);
|
2026-04-01 20:06:26 +03:00
|
|
|
|
|
|
|
|
// Trigger tile streaming for surrounding area
|
|
|
|
|
terrainMgr->update(*camera, 1.0f);
|
|
|
|
|
|
|
|
|
|
auto startTime = std::chrono::high_resolution_clock::now();
|
|
|
|
|
auto lastProgressTime = startTime;
|
|
|
|
|
const float maxWaitSeconds = 60.0f;
|
|
|
|
|
const float stallSeconds = 10.0f;
|
|
|
|
|
int initialRemaining = terrainMgr->getRemainingTileCount();
|
|
|
|
|
if (initialRemaining < 1) initialRemaining = 1;
|
|
|
|
|
int lastRemaining = initialRemaining;
|
|
|
|
|
|
|
|
|
|
// Wait until all pending + ready-queue tiles are finalized
|
|
|
|
|
while (terrainMgr->getRemainingTileCount() > 0) {
|
|
|
|
|
SDL_Event event;
|
|
|
|
|
while (SDL_PollEvent(&event)) {
|
|
|
|
|
if (event.type == SDL_QUIT) {
|
|
|
|
|
window_->setShouldClose(true);
|
|
|
|
|
loadingScreen.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.type == SDL_WINDOWEVENT &&
|
|
|
|
|
event.window.event == SDL_WINDOWEVENT_RESIZED) {
|
|
|
|
|
int w = event.window.data1;
|
|
|
|
|
int h = event.window.data2;
|
|
|
|
|
window_->setSize(w, h);
|
|
|
|
|
// Vulkan viewport set in command buffer
|
|
|
|
|
if (renderer_->getCamera()) {
|
|
|
|
|
renderer_->getCamera()->setAspectRatio(static_cast<float>(w) / h);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Trigger new streaming — enqueue tiles for background workers
|
|
|
|
|
terrainMgr->update(*camera, 0.016f);
|
|
|
|
|
|
|
|
|
|
// Process ONE tile per iteration so the progress bar updates
|
|
|
|
|
// smoothly between tiles instead of stalling on large batches.
|
|
|
|
|
terrainMgr->processOneReadyTile();
|
|
|
|
|
|
|
|
|
|
int remaining = terrainMgr->getRemainingTileCount();
|
|
|
|
|
int loaded = terrainMgr->getLoadedTileCount();
|
|
|
|
|
int total = loaded + remaining;
|
|
|
|
|
if (total < 1) total = 1;
|
|
|
|
|
float tileProgress = static_cast<float>(loaded) / static_cast<float>(total);
|
|
|
|
|
float progress = 0.35f + tileProgress * 0.50f;
|
|
|
|
|
|
|
|
|
|
auto now = std::chrono::high_resolution_clock::now();
|
|
|
|
|
float elapsedSec = std::chrono::duration<float>(now - startTime).count();
|
|
|
|
|
|
|
|
|
|
char buf[192];
|
|
|
|
|
if (loaded > 0 && remaining > 0) {
|
|
|
|
|
float tilesPerSec = static_cast<float>(loaded) / std::max(elapsedSec, 0.1f);
|
|
|
|
|
float etaSec = static_cast<float>(remaining) / std::max(tilesPerSec, 0.1f);
|
|
|
|
|
snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles (%.0f tiles/s, ~%.0fs remaining)",
|
|
|
|
|
loaded, total, tilesPerSec, etaSec);
|
|
|
|
|
} else {
|
|
|
|
|
snprintf(buf, sizeof(buf), "Loading terrain... %d / %d tiles",
|
|
|
|
|
loaded, total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loadingScreenOk) {
|
|
|
|
|
loadingScreen.setStatus(buf);
|
|
|
|
|
loadingScreen.setProgress(progress);
|
|
|
|
|
loadingScreen.render();
|
|
|
|
|
window_->swapBuffers();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (remaining != lastRemaining) {
|
|
|
|
|
lastRemaining = remaining;
|
|
|
|
|
lastProgressTime = now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto elapsed = std::chrono::high_resolution_clock::now() - startTime;
|
|
|
|
|
if (std::chrono::duration<float>(elapsed).count() > maxWaitSeconds) {
|
|
|
|
|
LOG_WARNING("Online terrain streaming timeout after ", maxWaitSeconds, "s");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
auto stalledFor = std::chrono::high_resolution_clock::now() - lastProgressTime;
|
|
|
|
|
if (std::chrono::duration<float>(stalledFor).count() > stallSeconds) {
|
|
|
|
|
LOG_WARNING("Online terrain streaming stalled for ", stallSeconds,
|
|
|
|
|
"s (remaining=", lastRemaining, "), continuing without full preload");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Don't sleep if there are more tiles to finalize — keep processing
|
|
|
|
|
if (remaining > 0 && terrainMgr->getReadyQueueCount() == 0) {
|
|
|
|
|
SDL_Delay(16);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Online terrain streaming complete: ", terrainMgr->getLoadedTileCount(), " tiles loaded");
|
|
|
|
|
|
|
|
|
|
// Restore full load radius — remaining tiles stream in-game
|
|
|
|
|
terrainMgr->setLoadRadius(savedLoadRadius);
|
|
|
|
|
|
|
|
|
|
// Load/precompute collision cache
|
|
|
|
|
if (renderer_->getWMORenderer()) {
|
|
|
|
|
showProgress("Building collision cache...", 0.88f);
|
|
|
|
|
if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); }
|
|
|
|
|
renderer_->getWMORenderer()->loadFloorCache();
|
|
|
|
|
if (renderer_->getWMORenderer()->getFloorCacheSize() == 0) {
|
|
|
|
|
showProgress("Computing walkable surfaces...", 0.90f);
|
|
|
|
|
if (loadingScreenOk) { loadingScreen.render(); window_->swapBuffers(); }
|
|
|
|
|
renderer_->getWMORenderer()->precomputeFloorCache();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Snap player to loaded terrain so they don't spawn underground
|
|
|
|
|
if (renderer_->getCameraController()) {
|
|
|
|
|
renderer_->getCameraController()->reset();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Test transport disabled — real transports come from server via UPDATEFLAG_TRANSPORT
|
|
|
|
|
showProgress("Finalizing world...", 0.94f);
|
|
|
|
|
// setupTestTransport();
|
|
|
|
|
|
|
|
|
|
// Connect TransportManager to renderers (must happen AFTER initializeRenderers)
|
|
|
|
|
if (gameHandler_ && gameHandler_->getTransportManager()) {
|
|
|
|
|
auto* tm = gameHandler_->getTransportManager();
|
|
|
|
|
if (renderer_->getWMORenderer()) tm->setWMORenderer(renderer_->getWMORenderer());
|
|
|
|
|
if (renderer_->getM2Renderer()) tm->setM2Renderer(renderer_->getM2Renderer());
|
|
|
|
|
LOG_WARNING("TransportManager connected: wmoR=", (renderer_->getWMORenderer() ? "yes" : "NULL"),
|
|
|
|
|
" m2R=", (renderer_->getM2Renderer() ? "yes" : "NULL"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set up NPC animation callbacks (for online creatures)
|
|
|
|
|
showProgress("Preparing creatures...", 0.97f);
|
|
|
|
|
if (gameHandler_ && renderer_ && renderer_->getCharacterRenderer()) {
|
|
|
|
|
auto* cr = renderer_->getCharacterRenderer();
|
|
|
|
|
auto* spawner = entitySpawner_;
|
|
|
|
|
|
|
|
|
|
gameHandler_->setNpcDeathCallback([cr, spawner](uint64_t guid) {
|
|
|
|
|
spawner->markCreatureDead(guid);
|
|
|
|
|
uint32_t instanceId = spawner->getCreatureInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid);
|
|
|
|
|
if (instanceId != 0 && cr) {
|
feat(animation): 452 named constants, 30-phase character animation state machine
Add animation_ids.hpp/cpp with all 452 WoW animation ID constants (anim::STAND,
anim::RUN, anim::FIRE_BOW, ... anim::FLY_BACKWARDS, etc.), nameFromId() O(1)
lookup, and flyVariant() compact 218-element ground→FLY_* resolver.
Expand AnimationController into a full state machine with 20+ named states:
spell cast (directed→omni→cast fallback chain, instant one-shot release),
hit reactions (WOUND/CRIT/DODGE/BLOCK/SHIELD_BLOCK), stun, wounded idle,
stealth animation substitution, loot, fishing channel, sit/sleep/kneel
down→loop→up transitions, sheathe/unsheathe combat enter/exit, ranged weapons
(BOW/GUN/CROSSBOW/THROWN with reload states), game object OPEN/CLOSE/DESTROY,
vehicle enter/exit, mount flight directionals (FLY_LEFT/RIGHT/UP/DOWN/BACKWARDS),
emote state variants, off-hand/pierce/dual-wield alternation, NPC
birth/spawn/drown/rise, sprint aura override, totem idle, NPC greeting/farewell.
Add spell_defines.hpp with SpellEffect (~45 constants) and SpellMissInfo
(12 constants) namespaces; replace all magic numbers in spell_handler.cpp.
Add GAMEOBJECT_BYTES_1 to update field table (all 4 expansion JSONs) and wire
GameObjectStateCallback. Add DBC cross-validation on world entry.
Expand tools/_ANIM_NAMES from ~35 to 452 entries in m2_viewer.py and
asset_pipeline_gui.py. Add tests/test_animation_ids.cpp.
Bug fixes included:
- Stand state 1 was animating READY_2H(27) — fixed to SITTING(97)
- Spell casts ended freeze-frame — add one-shot release animation
- NPC 2H swing probe chain missing ATTACK_2H_LOOSE (polearm/staff)
- Chair sits (states 2/4/5/6) incorrectly played floor-sit transition
- STOP(3) used for all spell casts — replaced with model-aware chain
2026-04-04 23:02:53 +03:00
|
|
|
cr->playAnimation(instanceId, rendering::anim::DEATH, false);
|
2026-04-01 20:06:26 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
gameHandler_->setNpcRespawnCallback([cr, spawner](uint64_t guid) {
|
|
|
|
|
spawner->unmarkCreatureDead(guid);
|
|
|
|
|
uint32_t instanceId = spawner->getCreatureInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid);
|
|
|
|
|
if (instanceId != 0 && cr) {
|
feat(animation): 452 named constants, 30-phase character animation state machine
Add animation_ids.hpp/cpp with all 452 WoW animation ID constants (anim::STAND,
anim::RUN, anim::FIRE_BOW, ... anim::FLY_BACKWARDS, etc.), nameFromId() O(1)
lookup, and flyVariant() compact 218-element ground→FLY_* resolver.
Expand AnimationController into a full state machine with 20+ named states:
spell cast (directed→omni→cast fallback chain, instant one-shot release),
hit reactions (WOUND/CRIT/DODGE/BLOCK/SHIELD_BLOCK), stun, wounded idle,
stealth animation substitution, loot, fishing channel, sit/sleep/kneel
down→loop→up transitions, sheathe/unsheathe combat enter/exit, ranged weapons
(BOW/GUN/CROSSBOW/THROWN with reload states), game object OPEN/CLOSE/DESTROY,
vehicle enter/exit, mount flight directionals (FLY_LEFT/RIGHT/UP/DOWN/BACKWARDS),
emote state variants, off-hand/pierce/dual-wield alternation, NPC
birth/spawn/drown/rise, sprint aura override, totem idle, NPC greeting/farewell.
Add spell_defines.hpp with SpellEffect (~45 constants) and SpellMissInfo
(12 constants) namespaces; replace all magic numbers in spell_handler.cpp.
Add GAMEOBJECT_BYTES_1 to update field table (all 4 expansion JSONs) and wire
GameObjectStateCallback. Add DBC cross-validation on world entry.
Expand tools/_ANIM_NAMES from ~35 to 452 entries in m2_viewer.py and
asset_pipeline_gui.py. Add tests/test_animation_ids.cpp.
Bug fixes included:
- Stand state 1 was animating READY_2H(27) — fixed to SITTING(97)
- Spell casts ended freeze-frame — add one-shot release animation
- NPC 2H swing probe chain missing ATTACK_2H_LOOSE (polearm/staff)
- Chair sits (states 2/4/5/6) incorrectly played floor-sit transition
- STOP(3) used for all spell casts — replaced with model-aware chain
2026-04-04 23:02:53 +03:00
|
|
|
cr->playAnimation(instanceId, rendering::anim::STAND, true);
|
2026-04-01 20:06:26 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
feat(animation): 452 named constants, 30-phase character animation state machine
Add animation_ids.hpp/cpp with all 452 WoW animation ID constants (anim::STAND,
anim::RUN, anim::FIRE_BOW, ... anim::FLY_BACKWARDS, etc.), nameFromId() O(1)
lookup, and flyVariant() compact 218-element ground→FLY_* resolver.
Expand AnimationController into a full state machine with 20+ named states:
spell cast (directed→omni→cast fallback chain, instant one-shot release),
hit reactions (WOUND/CRIT/DODGE/BLOCK/SHIELD_BLOCK), stun, wounded idle,
stealth animation substitution, loot, fishing channel, sit/sleep/kneel
down→loop→up transitions, sheathe/unsheathe combat enter/exit, ranged weapons
(BOW/GUN/CROSSBOW/THROWN with reload states), game object OPEN/CLOSE/DESTROY,
vehicle enter/exit, mount flight directionals (FLY_LEFT/RIGHT/UP/DOWN/BACKWARDS),
emote state variants, off-hand/pierce/dual-wield alternation, NPC
birth/spawn/drown/rise, sprint aura override, totem idle, NPC greeting/farewell.
Add spell_defines.hpp with SpellEffect (~45 constants) and SpellMissInfo
(12 constants) namespaces; replace all magic numbers in spell_handler.cpp.
Add GAMEOBJECT_BYTES_1 to update field table (all 4 expansion JSONs) and wire
GameObjectStateCallback. Add DBC cross-validation on world entry.
Expand tools/_ANIM_NAMES from ~35 to 452 entries in m2_viewer.py and
asset_pipeline_gui.py. Add tests/test_animation_ids.cpp.
Bug fixes included:
- Stand state 1 was animating READY_2H(27) — fixed to SITTING(97)
- Spell casts ended freeze-frame — add one-shot release animation
- NPC 2H swing probe chain missing ATTACK_2H_LOOSE (polearm/staff)
- Chair sits (states 2/4/5/6) incorrectly played floor-sit transition
- STOP(3) used for all spell casts — replaced with model-aware chain
2026-04-04 23:02:53 +03:00
|
|
|
// Probe the creature model for the best available attack animation
|
2026-04-01 20:06:26 +03:00
|
|
|
gameHandler_->setNpcSwingCallback([cr, spawner](uint64_t guid) {
|
|
|
|
|
uint32_t instanceId = spawner->getCreatureInstanceId(guid);
|
|
|
|
|
if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid);
|
|
|
|
|
if (instanceId != 0 && cr) {
|
feat(animation): 452 named constants, 30-phase character animation state machine
Add animation_ids.hpp/cpp with all 452 WoW animation ID constants (anim::STAND,
anim::RUN, anim::FIRE_BOW, ... anim::FLY_BACKWARDS, etc.), nameFromId() O(1)
lookup, and flyVariant() compact 218-element ground→FLY_* resolver.
Expand AnimationController into a full state machine with 20+ named states:
spell cast (directed→omni→cast fallback chain, instant one-shot release),
hit reactions (WOUND/CRIT/DODGE/BLOCK/SHIELD_BLOCK), stun, wounded idle,
stealth animation substitution, loot, fishing channel, sit/sleep/kneel
down→loop→up transitions, sheathe/unsheathe combat enter/exit, ranged weapons
(BOW/GUN/CROSSBOW/THROWN with reload states), game object OPEN/CLOSE/DESTROY,
vehicle enter/exit, mount flight directionals (FLY_LEFT/RIGHT/UP/DOWN/BACKWARDS),
emote state variants, off-hand/pierce/dual-wield alternation, NPC
birth/spawn/drown/rise, sprint aura override, totem idle, NPC greeting/farewell.
Add spell_defines.hpp with SpellEffect (~45 constants) and SpellMissInfo
(12 constants) namespaces; replace all magic numbers in spell_handler.cpp.
Add GAMEOBJECT_BYTES_1 to update field table (all 4 expansion JSONs) and wire
GameObjectStateCallback. Add DBC cross-validation on world entry.
Expand tools/_ANIM_NAMES from ~35 to 452 entries in m2_viewer.py and
asset_pipeline_gui.py. Add tests/test_animation_ids.cpp.
Bug fixes included:
- Stand state 1 was animating READY_2H(27) — fixed to SITTING(97)
- Spell casts ended freeze-frame — add one-shot release animation
- NPC 2H swing probe chain missing ATTACK_2H_LOOSE (polearm/staff)
- Chair sits (states 2/4/5/6) incorrectly played floor-sit transition
- STOP(3) used for all spell casts — replaced with model-aware chain
2026-04-04 23:02:53 +03:00
|
|
|
static const uint32_t attackAnims[] = {
|
|
|
|
|
rendering::anim::ATTACK_1H,
|
|
|
|
|
rendering::anim::ATTACK_2H,
|
|
|
|
|
rendering::anim::ATTACK_2H_LOOSE,
|
|
|
|
|
rendering::anim::ATTACK_UNARMED
|
|
|
|
|
};
|
|
|
|
|
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, rendering::anim::ATTACK_UNARMED, false);
|
2026-04-01 20:06:26 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep the loading screen visible until all spawn/equipment/gameobject queues
|
|
|
|
|
// are fully drained. This ensures the player sees a fully populated world
|
|
|
|
|
// (character clothed, NPCs placed, game objects loaded) when the screen drops.
|
|
|
|
|
{
|
|
|
|
|
const float kMinWarmupSeconds = 2.0f; // minimum time to drain network packets
|
|
|
|
|
const float kMaxWarmupSeconds = 25.0f; // hard cap to avoid infinite stall
|
|
|
|
|
const auto warmupStart = std::chrono::high_resolution_clock::now();
|
|
|
|
|
// Track consecutive idle iterations (all queues empty) to detect convergence
|
|
|
|
|
int idleIterations = 0;
|
|
|
|
|
const int kIdleThreshold = 5; // require 5 consecutive empty loops (~80ms)
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
SDL_Event event;
|
|
|
|
|
while (SDL_PollEvent(&event)) {
|
|
|
|
|
if (event.type == SDL_QUIT) {
|
|
|
|
|
window_->setShouldClose(true);
|
|
|
|
|
if (loadingScreenOk) loadingScreen.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.type == SDL_WINDOWEVENT &&
|
|
|
|
|
event.window.event == SDL_WINDOWEVENT_RESIZED) {
|
|
|
|
|
int w = event.window.data1;
|
|
|
|
|
int h = event.window.data2;
|
|
|
|
|
window_->setSize(w, h);
|
|
|
|
|
if (renderer_ && renderer_->getCamera()) {
|
|
|
|
|
renderer_->getCamera()->setAspectRatio(static_cast<float>(w) / h);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drain network and process deferred spawn/composite queues while hidden.
|
|
|
|
|
if (gameHandler_) gameHandler_->update(1.0f / 60.0f);
|
|
|
|
|
|
|
|
|
|
// If a new world entry was deferred during packet processing,
|
|
|
|
|
// stop warming up this map — we'll load the new one after cleanup.
|
|
|
|
|
if (pendingWorldEntry_) {
|
|
|
|
|
LOG_WARNING("loadOnlineWorldTerrain(map ", mapId,
|
|
|
|
|
") — deferred world entry pending, stopping warmup");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (world_) world_->update(1.0f / 60.0f);
|
|
|
|
|
|
|
|
|
|
// Process all spawn/equipment/transport queues during warmup
|
|
|
|
|
entitySpawner_->update();
|
|
|
|
|
if (auto* cr = renderer_ ? renderer_->getCharacterRenderer() : nullptr) {
|
|
|
|
|
cr->processPendingNormalMaps(4);
|
|
|
|
|
}
|
|
|
|
|
app_.updateQuestMarkers();
|
|
|
|
|
|
|
|
|
|
// Update renderer (terrain streaming, animations)
|
|
|
|
|
if (renderer_) {
|
|
|
|
|
renderer_->update(1.0f / 60.0f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto now = std::chrono::high_resolution_clock::now();
|
|
|
|
|
const float elapsed = std::chrono::duration<float>(now - warmupStart).count();
|
|
|
|
|
|
|
|
|
|
// Check if all queues are drained
|
|
|
|
|
bool queuesEmpty = !entitySpawner_->hasWorkPending();
|
|
|
|
|
|
|
|
|
|
if (queuesEmpty) {
|
|
|
|
|
idleIterations++;
|
|
|
|
|
} else {
|
|
|
|
|
idleIterations = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Don't exit warmup until the ground under the player exists.
|
|
|
|
|
// In cities like Stormwind, players stand on WMO floors, not terrain.
|
|
|
|
|
// Check BOTH terrain AND WMO floor — require at least one to be valid.
|
|
|
|
|
bool groundReady = false;
|
|
|
|
|
if (renderer_) {
|
|
|
|
|
glm::vec3 renderSpawn = core::coords::canonicalToRender(
|
|
|
|
|
glm::vec3(x, y, z));
|
|
|
|
|
float rx = renderSpawn.x, ry = renderSpawn.y, rz = renderSpawn.z;
|
|
|
|
|
|
|
|
|
|
// Check WMO floor FIRST (cities like Stormwind stand on WMO floors).
|
|
|
|
|
// Terrain exists below WMOs but at the wrong height.
|
|
|
|
|
if (auto* wmo = renderer_->getWMORenderer()) {
|
|
|
|
|
auto wmoH = wmo->getFloorHeight(rx, ry, rz + 5.0f);
|
|
|
|
|
if (wmoH.has_value() && std::abs(*wmoH - rz) < 15.0f) {
|
|
|
|
|
groundReady = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Check terrain — but only if it's close to spawn Z (within 15 units).
|
|
|
|
|
// Terrain far below a WMO city doesn't count as ground.
|
|
|
|
|
if (!groundReady) {
|
|
|
|
|
if (auto* tm = renderer_->getTerrainManager()) {
|
|
|
|
|
auto tH = tm->getHeightAt(rx, ry);
|
|
|
|
|
if (tH.has_value() && std::abs(*tH - rz) < 15.0f) {
|
|
|
|
|
groundReady = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// After 5s with enough tiles loaded, accept terrain as ready even if
|
|
|
|
|
// the height sample doesn't match spawn Z exactly. This handles cases
|
|
|
|
|
// where getHeightAt returns a slightly different value than the server's
|
|
|
|
|
// spawn Z (e.g. terrain LOD, MCNK chunk boundaries, or spawn inside a
|
|
|
|
|
// building where floor height differs from terrain below).
|
|
|
|
|
if (!groundReady && elapsed >= 5.0f) {
|
|
|
|
|
if (auto* tm = renderer_->getTerrainManager()) {
|
|
|
|
|
if (tm->getLoadedTileCount() >= 4) {
|
|
|
|
|
groundReady = true;
|
|
|
|
|
LOG_WARNING("Warmup: using tile-count fallback (", tm->getLoadedTileCount(), " tiles) after ", elapsed, "s");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!groundReady && elapsed > 5.0f && static_cast<int>(elapsed * 2) % 3 == 0) {
|
|
|
|
|
LOG_WARNING("Warmup: ground not ready at spawn (", rx, ",", ry, ",", rz,
|
|
|
|
|
") after ", elapsed, "s");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Exit when: (min time passed AND queues drained AND ground ready) OR hard cap
|
|
|
|
|
bool readyToExit = (elapsed >= kMinWarmupSeconds && idleIterations >= kIdleThreshold && groundReady);
|
|
|
|
|
if (readyToExit || elapsed >= kMaxWarmupSeconds) {
|
|
|
|
|
if (elapsed >= kMaxWarmupSeconds && !groundReady) {
|
|
|
|
|
LOG_WARNING("Warmup hit hard cap (", kMaxWarmupSeconds, "s), ground NOT ready — may fall through world");
|
|
|
|
|
} else if (elapsed >= kMaxWarmupSeconds) {
|
|
|
|
|
LOG_WARNING("Warmup hit hard cap (", kMaxWarmupSeconds, "s), entering world with pending work");
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const float t = std::clamp(elapsed / kMaxWarmupSeconds, 0.0f, 1.0f);
|
|
|
|
|
showProgress("Finalizing world sync...", 0.97f + t * 0.025f);
|
|
|
|
|
SDL_Delay(16);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start intro pan right before entering gameplay so it's visible after loading.
|
|
|
|
|
if (renderer_->getCameraController()) {
|
|
|
|
|
renderer_->getCameraController()->startIntroPan(2.8f, 140.0f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showProgress("Entering world...", 1.0f);
|
|
|
|
|
|
|
|
|
|
// Ensure all GPU resources (textures, buffers, pipelines) created during
|
|
|
|
|
// world load are fully flushed before the first render frame. Without this,
|
|
|
|
|
// vkCmdBeginRenderPass can crash on NVIDIA 590.x when resources from async
|
|
|
|
|
// uploads haven't completed their queue operations.
|
|
|
|
|
if (renderer_ && renderer_->getVkContext()) {
|
|
|
|
|
vkDeviceWaitIdle(renderer_->getVkContext()->getDevice());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loadingScreenOk) {
|
|
|
|
|
loadingScreen.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track which map we actually loaded (used by same-map teleport check).
|
|
|
|
|
loadedMapId_ = mapId;
|
|
|
|
|
|
|
|
|
|
// Clear loading flag and process any deferred world entry.
|
|
|
|
|
// A deferred entry occurs when SMSG_NEW_WORLD arrived during our warmup
|
|
|
|
|
// (e.g., an area trigger in a dungeon immediately teleporting the player out).
|
|
|
|
|
loadingWorld_ = false;
|
|
|
|
|
if (pendingWorldEntry_) {
|
|
|
|
|
auto entry = *pendingWorldEntry_;
|
|
|
|
|
pendingWorldEntry_.reset();
|
|
|
|
|
LOG_WARNING("Processing deferred world entry: map ", entry.mapId);
|
refactor(core): decompose Application::setupUICallbacks() into 7 domain handlers
Extract ~1,700 lines / 60+ inline [this]-capturing lambdas from the monolithic
Application::setupUICallbacks() into 7 focused callback handler classes following
the ToastManager/ChatPanel::setupCallbacks() pattern already in the codebase.
New handlers (include/core/ + src/core/):
- NPCInteractionCallbackHandler NPC greeting/farewell/vendor/aggro voice
- AudioCallbackHandler Music, positional sound, level-up, achievement, LFG
- EntitySpawnCallbackHandler Creature/player/GO spawn, despawn, move, state
- AnimationCallbackHandler Death, respawn, combat, emotes, charge, sprint, vehicle
- TransportCallbackHandler Mount, taxi, transport spawn/move
- WorldEntryCallbackHandler World entry, unstuck, hearthstone, bind point
- UIScreenCallbackHandler Auth, realm selection, char selection/creation/deletion
application.cpp: 4,462 → 2,791 lines (−1,671)
setupUICallbacks: ~1,700 → ~50 lines (thin orchestrator)
Deduplication:
resolveSoundEntryPath() — was 3× copy-paste of SoundEntries.dbc lookup
resolveNpcVoiceType() — was 4× copy-paste of display-ID→voice detection
precacheNearbyTiles() — was 3× copy-paste of 17×17 tile loop
4 helper lambdas — promoted to private methods on WorldEntryCallbackHandler
State migration out of Application:
charge* (6 vars) → AnimationCallbackHandler
hearth*/worldEntry*/taxi* → WorldEntryCallbackHandler
pendingCreatedCharacterName_ → UIScreenCallbackHandler
Bug fixes:
- Duplicate `namespace core {` in application.hpp caused wowee::std pollution
- AppState forward decl in ui_screen_callback_handler.hpp was at wrong scope
- world_loader.cpp accessed moved member vars directly via friend; now uses handler API
2026-04-05 16:48:17 +03:00
|
|
|
if (app_.worldEntryCallbacks_) {
|
|
|
|
|
app_.worldEntryCallbacks_->setWorldEntryMovementGraceTimer(2.0f);
|
|
|
|
|
app_.worldEntryCallbacks_->setTaxiLandingClampTimer(0.0f);
|
|
|
|
|
app_.worldEntryCallbacks_->setLastTaxiFlight(false);
|
|
|
|
|
}
|
2026-04-01 20:06:26 +03:00
|
|
|
// Recursive call — sets loadedMapId_ and IN_GAME state for the final map.
|
|
|
|
|
loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z);
|
|
|
|
|
return; // The recursive call handles setState(IN_GAME).
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only enter IN_GAME when this is the final map (no deferred entry pending).
|
|
|
|
|
app_.setState(AppState::IN_GAME);
|
|
|
|
|
|
|
|
|
|
// Load addons once per session on first world entry
|
|
|
|
|
if (addonManager_ && !app_.addonsLoaded_) {
|
|
|
|
|
// Set character name for per-character SavedVariables
|
|
|
|
|
if (gameHandler_) {
|
|
|
|
|
const std::string& charName = gameHandler_->lookupName(gameHandler_->getPlayerGuid());
|
|
|
|
|
if (!charName.empty()) {
|
|
|
|
|
addonManager_->setCharacterName(charName);
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback: find name from character list
|
|
|
|
|
for (const auto& c : gameHandler_->getCharacters()) {
|
|
|
|
|
if (c.guid == gameHandler_->getPlayerGuid()) {
|
|
|
|
|
addonManager_->setCharacterName(c.name);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
addonManager_->loadAllAddons();
|
|
|
|
|
app_.addonsLoaded_ = true;
|
|
|
|
|
addonManager_->fireEvent("VARIABLES_LOADED");
|
|
|
|
|
addonManager_->fireEvent("PLAYER_LOGIN");
|
|
|
|
|
addonManager_->fireEvent("PLAYER_ENTERING_WORLD");
|
|
|
|
|
} else if (addonManager_ && app_.addonsLoaded_) {
|
|
|
|
|
// Subsequent world entries (e.g. teleport, instance entry)
|
|
|
|
|
addonManager_->fireEvent("PLAYER_ENTERING_WORLD");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void WorldLoader::startWorldPreload(uint32_t mapId, const std::string& mapName,
|
|
|
|
|
float serverX, float serverY) {
|
|
|
|
|
cancelWorldPreload();
|
|
|
|
|
if (!assetManager_ || !assetManager_->isInitialized() || mapName.empty()) return;
|
|
|
|
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, 0.0f));
|
|
|
|
|
auto [tileX, tileY] = core::coords::canonicalToTile(canonical.x, canonical.y);
|
|
|
|
|
|
|
|
|
|
worldPreload_ = std::make_unique<WorldPreload>();
|
|
|
|
|
worldPreload_->mapId = mapId;
|
|
|
|
|
worldPreload_->mapName = mapName;
|
|
|
|
|
worldPreload_->centerTileX = tileX;
|
|
|
|
|
worldPreload_->centerTileY = tileY;
|
|
|
|
|
|
|
|
|
|
LOG_INFO("World preload: starting for map '", mapName, "' tile [", tileX, ",", tileY, "]");
|
|
|
|
|
|
|
|
|
|
// Build list of tiles to preload (radius 1 = 3x3 = 9 tiles, matching load screen)
|
|
|
|
|
struct TileJob { int x, y; };
|
|
|
|
|
auto jobs = std::make_shared<std::vector<TileJob>>();
|
|
|
|
|
// Center tile first (most important)
|
|
|
|
|
jobs->push_back({tileX, tileY});
|
|
|
|
|
for (int dx = -1; dx <= 1; dx++) {
|
|
|
|
|
for (int dy = -1; dy <= 1; dy++) {
|
|
|
|
|
if (dx == 0 && dy == 0) continue;
|
|
|
|
|
int tx = tileX + dx, ty = tileY + dy;
|
|
|
|
|
if (tx < 0 || tx > 63 || ty < 0 || ty > 63) continue;
|
|
|
|
|
jobs->push_back({tx, ty});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spawn worker threads (one per tile for maximum parallelism)
|
|
|
|
|
auto cancelFlag = &worldPreload_->cancel;
|
|
|
|
|
auto* am = assetManager_;
|
|
|
|
|
std::string mn = mapName;
|
|
|
|
|
|
|
|
|
|
int numWorkers = std::min(static_cast<int>(jobs->size()), 4);
|
|
|
|
|
auto nextJob = std::make_shared<std::atomic<int>>(0);
|
|
|
|
|
|
|
|
|
|
for (int w = 0; w < numWorkers; w++) {
|
|
|
|
|
worldPreload_->workers.emplace_back([am, mn, jobs, nextJob, cancelFlag]() {
|
|
|
|
|
while (!cancelFlag->load(std::memory_order_relaxed)) {
|
|
|
|
|
int idx = nextJob->fetch_add(1, std::memory_order_relaxed);
|
|
|
|
|
if (idx >= static_cast<int>(jobs->size())) break;
|
|
|
|
|
|
|
|
|
|
int tx = (*jobs)[idx].x;
|
|
|
|
|
int ty = (*jobs)[idx].y;
|
|
|
|
|
|
|
|
|
|
// Read ADT file (warms file cache)
|
|
|
|
|
std::string adtPath = "World\\Maps\\" + mn + "\\" + mn + "_" +
|
|
|
|
|
std::to_string(tx) + "_" + std::to_string(ty) + ".adt";
|
|
|
|
|
am->readFile(adtPath);
|
|
|
|
|
if (cancelFlag->load(std::memory_order_relaxed)) break;
|
|
|
|
|
|
|
|
|
|
// Read obj0 variant
|
|
|
|
|
std::string objPath = "World\\Maps\\" + mn + "\\" + mn + "_" +
|
|
|
|
|
std::to_string(tx) + "_" + std::to_string(ty) + "_obj0.adt";
|
|
|
|
|
am->readFile(objPath);
|
|
|
|
|
}
|
|
|
|
|
LOG_DEBUG("World preload worker finished");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void WorldLoader::cancelWorldPreload() {
|
|
|
|
|
if (!worldPreload_) return;
|
|
|
|
|
worldPreload_->cancel.store(true, std::memory_order_relaxed);
|
|
|
|
|
for (auto& t : worldPreload_->workers) {
|
|
|
|
|
if (t.joinable()) t.join();
|
|
|
|
|
}
|
|
|
|
|
LOG_INFO("World preload: cancelled (map=", worldPreload_->mapName,
|
|
|
|
|
" tile=[", worldPreload_->centerTileX, ",", worldPreload_->centerTileY, "])");
|
|
|
|
|
worldPreload_.reset();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void WorldLoader::saveLastWorldInfo(uint32_t mapId, const std::string& mapName,
|
|
|
|
|
float serverX, float serverY) {
|
|
|
|
|
#ifdef _WIN32
|
|
|
|
|
const char* base = std::getenv("APPDATA");
|
|
|
|
|
std::string dir = base ? std::string(base) + "\\wowee" : ".";
|
|
|
|
|
#else
|
|
|
|
|
const char* home = std::getenv("HOME");
|
|
|
|
|
std::string dir = home ? std::string(home) + "/.wowee" : ".";
|
|
|
|
|
#endif
|
|
|
|
|
std::filesystem::create_directories(dir);
|
|
|
|
|
std::ofstream f(dir + "/last_world.cfg");
|
|
|
|
|
if (f) {
|
|
|
|
|
f << mapId << "\n" << mapName << "\n" << serverX << "\n" << serverY << "\n";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
WorldLoader::LastWorldInfo WorldLoader::loadLastWorldInfo() const {
|
|
|
|
|
#ifdef _WIN32
|
|
|
|
|
const char* base = std::getenv("APPDATA");
|
|
|
|
|
std::string dir = base ? std::string(base) + "\\wowee" : ".";
|
|
|
|
|
#else
|
|
|
|
|
const char* home = std::getenv("HOME");
|
|
|
|
|
std::string dir = home ? std::string(home) + "/.wowee" : ".";
|
|
|
|
|
#endif
|
|
|
|
|
LastWorldInfo info;
|
|
|
|
|
std::ifstream f(dir + "/last_world.cfg");
|
|
|
|
|
if (!f) return info;
|
|
|
|
|
std::string line;
|
|
|
|
|
try {
|
|
|
|
|
if (std::getline(f, line)) info.mapId = static_cast<uint32_t>(std::stoul(line));
|
|
|
|
|
if (std::getline(f, line)) info.mapName = line;
|
|
|
|
|
if (std::getline(f, line)) info.x = std::stof(line);
|
|
|
|
|
if (std::getline(f, line)) info.y = std::stof(line);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
LOG_WARNING("Malformed last_world.cfg, ignoring saved position");
|
|
|
|
|
return info;
|
|
|
|
|
}
|
|
|
|
|
info.valid = !info.mapName.empty();
|
|
|
|
|
return info;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace core
|
|
|
|
|
} // namespace wowee
|