Kelsidavis-WoWee/src/core/world_loader.cpp

1240 lines
58 KiB
C++
Raw Normal View History

// 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"
feat(animation): decompose AnimationController into FSM-based architecture Replace the 2,200-line monolithic AnimationController (goto-driven, single class, untestable) with a composed FSM architecture per refactor.md. New subsystem (src/rendering/animation/ — 16 headers, 10 sources): - CharacterAnimator: FSM composer implementing ICharacterAnimator - LocomotionFSM: idle/walk/run/sprint/jump/swim/strafe - CombatFSM: melee/ranged/spell cast/stun/hit reaction/charge - ActivityFSM: emote/loot/sit-down/sitting/sit-up - MountFSM: idle/run/flight/taxi/fidget/rear-up (per-instance RNG) - AnimCapabilitySet + AnimCapabilityProbe: probe once at model load, eliminate per-frame hasAnimation() linear search - AnimationManager: registry of CharacterAnimator by GUID - EmoteRegistry: DBC-backed emote command → animId singleton - FootstepDriver, SfxStateDriver: extracted from AnimationController animation_ids.hpp/.cpp moved to animation/ subdirectory (452 named constants); all include paths updated. AnimationController retained as thin adapter (~400 LOC): collects FrameInput, delegates to CharacterAnimator, applies AnimOutput. Priority order: Mount > Stun > HitReaction > Spell > Charge > Melee/Ranged > CombatIdle > Emote > Loot > Sit > Locomotion. STAY_IN_STATE policy when all FSMs return valid=false. Bugs fixed: - Remove static mt19937 in mount fidget (shared state across all mounted units) — replaced with per-instance seeded RNG - Remove goto from mounted animation branch (skipped init) - Remove per-frame hasAnimation() calls (now one probe at load) - Fix VK_INDEX_TYPE_UINT16 → UINT32 in shadow pass Tests (4 new suites, all ASAN+UBSan clean): - test_locomotion_fsm: 167 assertions - test_combat_fsm: 125 cases - test_activity_fsm: 112 cases - test_anim_capability: 56 cases docs/ANIMATION_SYSTEM.md added (architecture reference).
2026-04-05 12:27:35 +03:00
#include "rendering/animation/animation_ids.hpp"
#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/animation_controller.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_DEBUG("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);
}
// 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_DEBUG("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();
}
if (auto* ac = renderer_->getAnimationController()) ac->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);
if (auto* ac = renderer_->getAnimationController()) ac->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_DEBUG("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_DEBUG("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);
// 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_DEBUG("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);
}
});
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);
}
});
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
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);
}
});
}
// 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_DEBUG("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_DEBUG("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_DEBUG("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);
}
// 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