diff --git a/CMakeLists.txt b/CMakeLists.txt
index 432c0f65..dbb31af1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -487,6 +487,8 @@ set(WOWEE_SOURCES
# Core
src/core/application.cpp
src/core/entity_spawner.cpp
+ src/core/appearance_composer.cpp
+ src/core/world_loader.cpp
src/core/window.cpp
src/core/input.cpp
src/core/logger.cpp
@@ -542,6 +544,7 @@ set(WOWEE_SOURCES
# Audio
src/audio/audio_engine.cpp
+ src/audio/audio_coordinator.cpp
src/audio/music_manager.cpp
src/audio/footstep_manager.cpp
src/audio/activity_sound_manager.cpp
diff --git a/README.md b/README.md
index 4aeffc18..69e0a6dd 100644
--- a/README.md
+++ b/README.md
@@ -347,3 +347,13 @@ This project does not include any Blizzard Entertainment proprietary data, asset
## Known Issues
MANY issues this is actively under development
+
+## Star History
+
+
+
+
+
+
+
+
diff --git a/include/audio/audio_coordinator.hpp b/include/audio/audio_coordinator.hpp
new file mode 100644
index 00000000..f181164f
--- /dev/null
+++ b/include/audio/audio_coordinator.hpp
@@ -0,0 +1,66 @@
+#pragma once
+
+#include
+
+namespace wowee {
+namespace pipeline { class AssetManager; }
+namespace audio {
+
+class MusicManager;
+class FootstepManager;
+class ActivitySoundManager;
+class MountSoundManager;
+class NpcVoiceManager;
+class AmbientSoundManager;
+class UiSoundManager;
+class CombatSoundManager;
+class SpellSoundManager;
+class MovementSoundManager;
+
+/// Coordinates all audio subsystems.
+/// Extracted from Renderer to separate audio lifecycle from rendering.
+/// Owned by Application; Renderer and UI components access through Application.
+class AudioCoordinator {
+public:
+ AudioCoordinator();
+ ~AudioCoordinator();
+
+ /// Initialize the audio engine and all managers.
+ /// @return true if audio is available (engine initialized successfully)
+ bool initialize();
+
+ /// Initialize managers that need AssetManager (music lookups, sound banks).
+ void initializeWithAssets(pipeline::AssetManager* assetManager);
+
+ /// Shutdown all audio managers and engine.
+ void shutdown();
+
+ // Accessors for all audio managers (same interface as Renderer had)
+ MusicManager* getMusicManager() { return musicManager_.get(); }
+ FootstepManager* getFootstepManager() { return footstepManager_.get(); }
+ ActivitySoundManager* getActivitySoundManager() { return activitySoundManager_.get(); }
+ MountSoundManager* getMountSoundManager() { return mountSoundManager_.get(); }
+ NpcVoiceManager* getNpcVoiceManager() { return npcVoiceManager_.get(); }
+ AmbientSoundManager* getAmbientSoundManager() { return ambientSoundManager_.get(); }
+ UiSoundManager* getUiSoundManager() { return uiSoundManager_.get(); }
+ CombatSoundManager* getCombatSoundManager() { return combatSoundManager_.get(); }
+ SpellSoundManager* getSpellSoundManager() { return spellSoundManager_.get(); }
+ MovementSoundManager* getMovementSoundManager() { return movementSoundManager_.get(); }
+
+private:
+ std::unique_ptr musicManager_;
+ std::unique_ptr footstepManager_;
+ std::unique_ptr activitySoundManager_;
+ std::unique_ptr mountSoundManager_;
+ std::unique_ptr npcVoiceManager_;
+ std::unique_ptr ambientSoundManager_;
+ std::unique_ptr uiSoundManager_;
+ std::unique_ptr combatSoundManager_;
+ std::unique_ptr spellSoundManager_;
+ std::unique_ptr movementSoundManager_;
+
+ bool audioAvailable_ = false;
+};
+
+} // namespace audio
+} // namespace wowee
diff --git a/include/core/appearance_composer.hpp b/include/core/appearance_composer.hpp
new file mode 100644
index 00000000..138fd800
--- /dev/null
+++ b/include/core/appearance_composer.hpp
@@ -0,0 +1,101 @@
+#pragma once
+
+#include "game/character.hpp"
+#include
+#include
+#include
+#include
+
+namespace wowee {
+
+namespace rendering { class Renderer; }
+namespace pipeline { class AssetManager; class DBCLayout; struct M2Model; }
+namespace game { class GameHandler; }
+
+namespace core {
+
+class EntitySpawner;
+
+// Default (bare) geoset IDs per equipment group.
+// Each group's base is groupNumber * 100; variant 01 is typically bare/default.
+constexpr uint16_t kGeosetDefaultConnector = 101; // Group 1: default hair connector
+constexpr uint16_t kGeosetBareForearms = 401; // Group 4: no gloves
+constexpr uint16_t kGeosetBareShins = 503; // Group 5: no boots
+constexpr uint16_t kGeosetDefaultEars = 702; // Group 7: ears
+constexpr uint16_t kGeosetBareSleeves = 801; // Group 8: no chest armor sleeves
+constexpr uint16_t kGeosetDefaultKneepads = 902; // Group 9: kneepads
+constexpr uint16_t kGeosetDefaultTabard = 1201; // Group 12: tabard base
+constexpr uint16_t kGeosetBarePants = 1301; // Group 13: no leggings
+constexpr uint16_t kGeosetNoCape = 1501; // Group 15: no cape
+constexpr uint16_t kGeosetWithCape = 1502; // Group 15: with cape
+constexpr uint16_t kGeosetBareFeet = 2002; // Group 20: bare feet
+
+/// Resolved texture paths from CharSections.dbc for player character compositing.
+struct PlayerTextureInfo {
+ std::string bodySkinPath;
+ std::string faceLowerPath;
+ std::string faceUpperPath;
+ std::string hairTexturePath;
+ std::vector underwearPaths;
+};
+
+/// Handles player character visual appearance: skin compositing, geoset selection,
+/// texture path lookups, and equipment weapon rendering.
+class AppearanceComposer {
+public:
+ AppearanceComposer(rendering::Renderer* renderer,
+ pipeline::AssetManager* assetManager,
+ game::GameHandler* gameHandler,
+ pipeline::DBCLayout* dbcLayout,
+ EntitySpawner* entitySpawner);
+
+ // Player model path resolution
+ std::string getPlayerModelPath(game::Race race, game::Gender gender) const;
+
+ // Phase 1: Resolve texture paths from CharSections.dbc and fill model texture slots.
+ // Call BEFORE charRenderer->loadModel().
+ PlayerTextureInfo resolvePlayerTextures(pipeline::M2Model& model,
+ game::Race race, game::Gender gender,
+ uint32_t appearanceBytes);
+
+ // Phase 2: Apply composited textures to loaded model instance.
+ // Call AFTER charRenderer->loadModel(). Saves skin state for re-compositing.
+ void compositePlayerSkin(uint32_t modelSlotId, const PlayerTextureInfo& texInfo);
+
+ // Build default active geosets for player character
+ std::unordered_set buildDefaultPlayerGeosets(uint8_t hairStyleId, uint8_t facialId);
+
+ // Equipment weapon loading (reads inventory, attaches weapon M2 models)
+ void loadEquippedWeapons();
+
+ // Weapon sheathe state
+ void setWeaponsSheathed(bool sheathed) { weaponsSheathed_ = sheathed; }
+ bool isWeaponsSheathed() const { return weaponsSheathed_; }
+ void toggleWeaponsSheathed() { weaponsSheathed_ = !weaponsSheathed_; }
+
+ // Saved skin state accessors (used by game_screen.cpp for equipment re-compositing)
+ const std::string& getBodySkinPath() const { return bodySkinPath_; }
+ const std::vector& getUnderwearPaths() const { return underwearPaths_; }
+ uint32_t getSkinTextureSlotIndex() const { return skinTextureSlotIndex_; }
+ uint32_t getCloakTextureSlotIndex() const { return cloakTextureSlotIndex_; }
+
+private:
+ bool loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel);
+
+ rendering::Renderer* renderer_;
+ pipeline::AssetManager* assetManager_;
+ game::GameHandler* gameHandler_;
+ pipeline::DBCLayout* dbcLayout_;
+ EntitySpawner* entitySpawner_;
+
+ // Saved at spawn for skin re-compositing on equipment changes
+ std::string bodySkinPath_;
+ std::vector underwearPaths_;
+ uint32_t skinTextureSlotIndex_ = 0;
+ uint32_t cloakTextureSlotIndex_ = 0;
+
+ bool weaponsSheathed_ = false;
+};
+
+} // namespace core
+} // namespace wowee
diff --git a/include/core/application.hpp b/include/core/application.hpp
index a3f20984..148131ac 100644
--- a/include/core/application.hpp
+++ b/include/core/application.hpp
@@ -3,6 +3,8 @@
#include "core/window.hpp"
#include "core/input.hpp"
#include "core/entity_spawner.hpp"
+#include "core/appearance_composer.hpp"
+#include "core/world_loader.hpp"
#include "game/character.hpp"
#include "game/game_services.hpp"
#include "pipeline/blp_loader.hpp"
@@ -27,7 +29,7 @@ namespace ui { class UIManager; }
namespace auth { class AuthHandler; }
namespace game { class GameHandler; class World; class ExpansionRegistry; }
namespace pipeline { class AssetManager; class DBCLayout; struct M2Model; struct WMOModel; }
-namespace audio { enum class VoiceType; }
+namespace audio { enum class VoiceType; class AudioCoordinator; }
namespace addons { class AddonManager; }
namespace core {
@@ -42,6 +44,8 @@ enum class AppState {
};
class Application {
+ friend class WorldLoader;
+
public:
Application();
~Application();
@@ -73,9 +77,7 @@ public:
// Singleton access
static Application& getInstance() { return *instance; }
- // Weapon loading (called at spawn and on equipment change)
- void loadEquippedWeapons();
- bool loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel);
+
// Logout to login screen
void logoutToLogin();
@@ -85,26 +87,31 @@ public:
bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const;
bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const;
- // Character skin composite state (saved at spawn for re-compositing on equipment change)
- const std::string& getBodySkinPath() const { return bodySkinPath_; }
- const std::vector& getUnderwearPaths() const { return underwearPaths_; }
- uint32_t getSkinTextureSlotIndex() const { return skinTextureSlotIndex_; }
- uint32_t getCloakTextureSlotIndex() const { return cloakTextureSlotIndex_; }
+ // Character skin composite state — delegated to AppearanceComposer
+ const std::string& getBodySkinPath() const { return appearanceComposer_ ? appearanceComposer_->getBodySkinPath() : emptyString_; }
+ const std::vector& getUnderwearPaths() const { return appearanceComposer_ ? appearanceComposer_->getUnderwearPaths() : emptyStringVec_; }
+ uint32_t getSkinTextureSlotIndex() const { return appearanceComposer_ ? appearanceComposer_->getSkinTextureSlotIndex() : 0; }
+ uint32_t getCloakTextureSlotIndex() const { return appearanceComposer_ ? appearanceComposer_->getCloakTextureSlotIndex() : 0; }
uint32_t getGryphonDisplayId() const { return entitySpawner_ ? entitySpawner_->getGryphonDisplayId() : 0; }
uint32_t getWyvernDisplayId() const { return entitySpawner_ ? entitySpawner_->getWyvernDisplayId() : 0; }
// Entity spawner access
EntitySpawner* getEntitySpawner() { return entitySpawner_.get(); }
+ // Appearance composer access
+ AppearanceComposer* getAppearanceComposer() { return appearanceComposer_.get(); }
+
+ // World loader access
+ WorldLoader* getWorldLoader() { return worldLoader_.get(); }
+
+ // Audio coordinator access (Section 4.1: extracted audio subsystem)
+ audio::AudioCoordinator* getAudioCoordinator() { return audioCoordinator_.get(); }
+
private:
void update(float deltaTime);
void render();
void setupUICallbacks();
void spawnPlayerCharacter();
- std::string getPlayerModelPath() const;
- static const char* mapIdToName(uint32_t mapId);
- static const char* mapDisplayName(uint32_t mapId);
- void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z);
void buildFactionHostilityMap(uint8_t playerRace);
void setupTestTransport(); // Test transport boat for development
@@ -123,6 +130,9 @@ private:
std::unique_ptr expansionRegistry_;
std::unique_ptr dbcLayout_;
std::unique_ptr entitySpawner_;
+ std::unique_ptr appearanceComposer_;
+ std::unique_ptr worldLoader_;
+ std::unique_ptr audioCoordinator_;
AppState state = AppState::AUTHENTICATION;
bool running = false;
@@ -140,20 +150,11 @@ private:
uint32_t spawnedAppearanceBytes_ = 0;
uint8_t spawnedFacialFeatures_ = 0;
- // Saved at spawn for skin re-compositing
- std::string bodySkinPath_;
- std::vector underwearPaths_;
- uint32_t skinTextureSlotIndex_ = 0;
- uint32_t cloakTextureSlotIndex_ = 0;
+ // Static empty values for null-safe delegation
+ static inline const std::string emptyString_;
+ static inline const std::vector emptyStringVec_;
bool lastTaxiFlight_ = false;
- uint32_t loadedMapId_ = 0xFFFFFFFF; // Map ID of currently loaded terrain (0xFFFFFFFF = none)
- uint32_t worldLoadGeneration_ = 0; // Incremented on each world entry to detect re-entrant loads
- bool loadingWorld_ = false; // True while loadOnlineWorldTerrain is running
- struct PendingWorldEntry {
- uint32_t mapId; float x, y, z;
- };
- std::optional pendingWorldEntry_; // Deferred world entry during loading
float taxiLandingClampTimer_ = 0.0f;
float worldEntryMovementGraceTimer_ = 0.0f;
@@ -174,31 +175,11 @@ private:
glm::vec3 chargeEndPos_{0.0f}; // Render coordinates
uint64_t chargeTargetGuid_ = 0;
- bool weaponsSheathed_ = false;
bool wasAutoAttacking_ = false;
- bool mapNameCacheLoaded_ = false;
- std::unordered_map mapNameById_;
// Quest marker billboard sprites (above NPCs)
void loadQuestMarkerModels(); // Now loads BLP textures
void updateQuestMarkers(); // Updates billboard positions
-
- // Background world preloader — warms AssetManager file cache for the
- // expected world before the user clicks Enter World.
- struct WorldPreload {
- uint32_t mapId = 0;
- std::string mapName;
- int centerTileX = 0;
- int centerTileY = 0;
- std::atomic cancel{false};
- std::vector workers;
- };
- std::unique_ptr worldPreload_;
- void startWorldPreload(uint32_t mapId, const std::string& mapName, float serverX, float serverY);
- void cancelWorldPreload();
- void saveLastWorldInfo(uint32_t mapId, const std::string& mapName, float serverX, float serverY);
- struct LastWorldInfo { uint32_t mapId = 0; std::string mapName; float x = 0, y = 0; bool valid = false; };
- LastWorldInfo loadLastWorldInfo() const;
};
} // namespace core
diff --git a/include/core/world_loader.hpp b/include/core/world_loader.hpp
new file mode 100644
index 00000000..01ce5483
--- /dev/null
+++ b/include/core/world_loader.hpp
@@ -0,0 +1,125 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace wowee {
+
+namespace rendering { class Renderer; }
+namespace pipeline { class AssetManager; class DBCLayout; }
+namespace game { class GameHandler; class World; }
+namespace addons { class AddonManager; }
+
+namespace core {
+
+class Application;
+class EntitySpawner;
+class AppearanceComposer;
+class Window;
+
+/// Handles terrain streaming, map transitions, world preloading,
+/// and coordinate-aware tile management for online world entry.
+class WorldLoader {
+public:
+ WorldLoader(Application& app,
+ rendering::Renderer* renderer,
+ pipeline::AssetManager* assetManager,
+ game::GameHandler* gameHandler,
+ EntitySpawner* entitySpawner,
+ AppearanceComposer* appearanceComposer,
+ Window* window,
+ game::World* world,
+ addons::AddonManager* addonManager);
+ ~WorldLoader();
+
+ // Main terrain loading — drives loading screen, WMO/ADT detection, player spawn
+ void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z);
+
+ // Process deferred world entry (called from Application::update each frame)
+ void processPendingEntry();
+
+ // Map name utilities
+ static const char* mapIdToName(uint32_t mapId);
+ static const char* mapDisplayName(uint32_t mapId);
+
+ // Background preloading — warms AssetManager file cache
+ void startWorldPreload(uint32_t mapId, const std::string& mapName,
+ float serverX, float serverY);
+ void cancelWorldPreload();
+
+ // Persistent world info for session-to-session preloading
+ void saveLastWorldInfo(uint32_t mapId, const std::string& mapName,
+ float serverX, float serverY);
+ struct LastWorldInfo {
+ uint32_t mapId = 0;
+ std::string mapName;
+ float x = 0, y = 0;
+ bool valid = false;
+ };
+ LastWorldInfo loadLastWorldInfo() const;
+
+ // State accessors
+ uint32_t getLoadedMapId() const { return loadedMapId_; }
+ bool isLoadingWorld() const { return loadingWorld_; }
+ bool hasPendingEntry() const { return pendingWorldEntry_.has_value(); }
+
+ // Get cached map name by ID (returns empty string if not found)
+ std::string getMapNameById(uint32_t mapId) const {
+ auto it = mapNameById_.find(mapId);
+ return (it != mapNameById_.end()) ? it->second : std::string{};
+ }
+
+ // Set pending world entry for deferred processing via processPendingEntry()
+ void setPendingEntry(uint32_t mapId, float x, float y, float z) {
+ pendingWorldEntry_ = PendingWorldEntry{mapId, x, y, z};
+ }
+
+ // Reset methods (for logout / character switch)
+ void resetLoadedMap() { loadedMapId_ = 0xFFFFFFFF; }
+ void resetMapNameCache() { mapNameCacheLoaded_ = false; mapNameById_.clear(); }
+
+private:
+ Application& app_;
+ rendering::Renderer* renderer_;
+ pipeline::AssetManager* assetManager_;
+ game::GameHandler* gameHandler_;
+ EntitySpawner* entitySpawner_;
+ AppearanceComposer* appearanceComposer_;
+ Window* window_;
+ game::World* world_;
+ addons::AddonManager* addonManager_;
+
+ uint32_t loadedMapId_ = 0xFFFFFFFF; // Map ID of currently loaded terrain (0xFFFFFFFF = none)
+ uint32_t worldLoadGeneration_ = 0; // Incremented on each world entry to detect re-entrant loads
+ bool loadingWorld_ = false; // True while loadOnlineWorldTerrain is running
+
+ struct PendingWorldEntry {
+ uint32_t mapId; float x, y, z;
+ };
+ std::optional pendingWorldEntry_;
+
+ // Map.dbc name cache (loaded once per session)
+ bool mapNameCacheLoaded_ = false;
+ std::unordered_map mapNameById_;
+
+ // Background world preloader — warms AssetManager file cache for the
+ // expected world before the user clicks Enter World.
+ struct WorldPreload {
+ uint32_t mapId = 0;
+ std::string mapName;
+ int centerTileX = 0;
+ int centerTileY = 0;
+ std::atomic cancel{false};
+ std::vector workers;
+ };
+ std::unique_ptr worldPreload_;
+};
+
+} // namespace core
+} // namespace wowee
diff --git a/include/ui/action_bar_panel.hpp b/include/ui/action_bar_panel.hpp
index ae650485..4a62e642 100644
--- a/include/ui/action_bar_panel.hpp
+++ b/include/ui/action_bar_panel.hpp
@@ -4,6 +4,7 @@
// XP bar, reputation bar, macro resolution.
// ============================================================
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -70,7 +71,11 @@ public:
std::unordered_map macroPrimarySpellCache_;
size_t macroCacheSpellCount_ = 0;
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
private:
+ UIServices services_;
uint32_t resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler);
};
diff --git a/include/ui/auth_screen.hpp b/include/ui/auth_screen.hpp
index e1dbdde7..ff99d963 100644
--- a/include/ui/auth_screen.hpp
+++ b/include/ui/auth_screen.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include "auth/auth_handler.hpp"
#include
#include
@@ -30,6 +31,9 @@ public:
*/
void setOnSuccess(std::function callback) { onSuccess = callback; }
+ /// Set services (dependency injection)
+ void setServices(const UIServices& services) { services_ = services; }
+
/**
* Check if authentication is in progress
@@ -44,6 +48,8 @@ public:
const std::string& getStatusMessage() const { return statusMessage; }
private:
+ UIServices services_; // Injected service references
+
struct ServerProfile {
std::string hostname;
int port = 3724;
diff --git a/include/ui/character_screen.hpp b/include/ui/character_screen.hpp
index aae4d6a0..c4312bc8 100644
--- a/include/ui/character_screen.hpp
+++ b/include/ui/character_screen.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include "game/game_handler.hpp"
#include
#include
@@ -48,6 +49,9 @@ public:
void setOnBack(std::function cb) { onBack = std::move(cb); }
void setOnDeleteCharacter(std::function cb) { onDeleteCharacter = std::move(cb); }
+ /// Set services (dependency injection)
+ void setServices(const UIServices& services) { services_ = services; }
+
/**
* Reset selection state (e.g., when switching servers)
*/
@@ -89,6 +93,8 @@ public:
void selectCharacterByName(const std::string& name);
private:
+ UIServices services_; // Injected service references
+
// UI state
int selectedCharacterIndex = -1;
bool characterSelected = false;
diff --git a/include/ui/chat_panel.hpp b/include/ui/chat_panel.hpp
index 50dac84b..b6528545 100644
--- a/include/ui/chat_panel.hpp
+++ b/include/ui/chat_panel.hpp
@@ -1,6 +1,7 @@
#pragma once
#include "game/game_handler.hpp"
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -109,10 +110,16 @@ public:
/** Reset all chat settings to defaults. */
void restoreDefaults();
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
/** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */
std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler);
private:
+ // Section 3.5: Injected UI services (Phase B singleton breaking)
+ UIServices services_;
+
// ---- Chat input state ----
char chatInputBuffer_[512] = "";
char whisperTargetBuffer_[256] = "";
diff --git a/include/ui/combat_ui.hpp b/include/ui/combat_ui.hpp
index 7d7a8058..b09d2fc4 100644
--- a/include/ui/combat_ui.hpp
+++ b/include/ui/combat_ui.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -70,6 +71,12 @@ public:
SpellbookScreen& spellbookScreen);
void renderThreatWindow(game::GameHandler& gameHandler);
void renderBgScoreboard(game::GameHandler& gameHandler);
+
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
+private:
+ UIServices services_;
};
} // namespace ui
diff --git a/include/ui/dialog_manager.hpp b/include/ui/dialog_manager.hpp
index 2fd0fb19..9bd075ee 100644
--- a/include/ui/dialog_manager.hpp
+++ b/include/ui/dialog_manager.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -34,7 +35,12 @@ public:
/// called in render() after reclaim corpse button
void renderLateDialogs(game::GameHandler& gameHandler);
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
private:
+ // Section 3.5: Injected UI services
+ UIServices services_;
// Common ImGui window flags for popup dialogs
static constexpr ImGuiWindowFlags kDialogFlags =
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize;
diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp
index 0c29e66f..f35bd679 100644
--- a/include/ui/game_screen.hpp
+++ b/include/ui/game_screen.hpp
@@ -17,12 +17,14 @@
#include "ui/social_panel.hpp"
#include "ui/action_bar_panel.hpp"
#include "ui/window_manager.hpp"
+#include "ui/ui_services.hpp"
#include
#include
#include
#include
namespace wowee {
+namespace core { class AppearanceComposer; }
namespace pipeline { class AssetManager; }
namespace rendering { class Renderer; }
namespace ui {
@@ -50,7 +52,17 @@ public:
void saveSettings();
void loadSettings();
+ // Dependency injection for extracted classes (Phase A singleton breaking)
+ void setAppearanceComposer(core::AppearanceComposer* ac) { appearanceComposer_ = ac; }
+
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services);
+
private:
+ // Injected UI services (Section 3.5 Phase B - replaces getInstance() calls)
+ UIServices services_;
+ // Legacy pointer for Phase A compatibility (will be removed when all callsites migrate)
+ core::AppearanceComposer* appearanceComposer_ = nullptr;
// Chat panel (extracted from GameScreen — owns all chat state and rendering)
ChatPanel chatPanel_;
diff --git a/include/ui/settings_panel.hpp b/include/ui/settings_panel.hpp
index 36d92f85..5dd58136 100644
--- a/include/ui/settings_panel.hpp
+++ b/include/ui/settings_panel.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -149,7 +150,12 @@ public:
/// Return the platform-specific settings file path
static std::string getSettingsPath();
+ /// Set services (dependency injection)
+ void setServices(const UIServices& services) { services_ = services; }
+
private:
+ UIServices services_; // Injected service references
+
// Keybinding customization (private — only used in Controls tab)
int pendingRebindAction_ = -1; // -1 = not rebinding, otherwise action index
bool awaitingKeyPress_ = false;
diff --git a/include/ui/social_panel.hpp b/include/ui/social_panel.hpp
index 30bce495..1eed9a93 100644
--- a/include/ui/social_panel.hpp
+++ b/include/ui/social_panel.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -71,6 +72,12 @@ public:
ChatPanel& chatPanel);
void renderInspectWindow(game::GameHandler& gameHandler,
InventoryScreen& inventoryScreen);
+
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
+private:
+ UIServices services_;
};
} // namespace ui
diff --git a/include/ui/toast_manager.hpp b/include/ui/toast_manager.hpp
index 29c27983..34cafa4d 100644
--- a/include/ui/toast_manager.hpp
+++ b/include/ui/toast_manager.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -40,11 +41,17 @@ public:
/// Fire achievement earned toast + sound
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
// --- public state consumed by GameScreen for the golden burst overlay ---
float levelUpFlashAlpha = 0.0f;
uint32_t levelUpDisplayLevel = 0;
private:
+ // Section 3.5: Injected UI services
+ UIServices services_;
+
// ---- Ding effect (own level-up) ----
static constexpr float DING_DURATION = 4.0f;
float dingTimer_ = 0.0f;
diff --git a/include/ui/ui_manager.hpp b/include/ui/ui_manager.hpp
index ba1e4c09..bdc217d9 100644
--- a/include/ui/ui_manager.hpp
+++ b/include/ui/ui_manager.hpp
@@ -5,6 +5,7 @@
#include "ui/character_create_screen.hpp"
#include "ui/character_screen.hpp"
#include "ui/game_screen.hpp"
+#include "ui/ui_services.hpp"
#include
// Forward declare SDL_Event
@@ -13,7 +14,7 @@ union SDL_Event;
namespace wowee {
// Forward declarations
-namespace core { class Window; enum class AppState; }
+namespace core { class Window; class AppearanceComposer; enum class AppState; }
namespace auth { class AuthHandler; }
namespace game { class GameHandler; }
@@ -69,8 +70,23 @@ public:
CharacterScreen& getCharacterScreen() { return *characterScreen; }
GameScreen& getGameScreen() { return *gameScreen; }
+ // Dependency injection forwarding (Phase A singleton breaking)
+ void setAppearanceComposer(core::AppearanceComposer* ac) {
+ if (gameScreen) gameScreen->setAppearanceComposer(ac);
+ }
+
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) {
+ services_ = services;
+ if (gameScreen) gameScreen->setServices(services);
+ if (authScreen) authScreen->setServices(services);
+ if (characterScreen) characterScreen->setServices(services);
+ }
+ const UIServices& getServices() const { return services_; }
+
private:
core::Window* window = nullptr;
+ UIServices services_; // Section 3.5: Injected services
// UI Screens
std::unique_ptr authScreen;
diff --git a/include/ui/ui_services.hpp b/include/ui/ui_services.hpp
new file mode 100644
index 00000000..fdcd4b2b
--- /dev/null
+++ b/include/ui/ui_services.hpp
@@ -0,0 +1,55 @@
+#pragma once
+
+namespace wowee {
+
+// Forward declarations
+namespace core {
+ class Window;
+ class EntitySpawner;
+ class AppearanceComposer;
+ class WorldLoader;
+}
+namespace rendering { class Renderer; }
+namespace pipeline { class AssetManager; }
+namespace game {
+ class GameHandler;
+ class ExpansionRegistry;
+}
+namespace addons { class AddonManager; }
+namespace audio { class AudioCoordinator; }
+
+namespace ui {
+
+/**
+ * UI Services - Dependency injection container for UI components.
+ *
+ * Section 3.5: Break the singleton Phase B
+ *
+ * Replaces Application::getInstance() calls throughout UI code.
+ * Application creates this struct and injects it into UIManager,
+ * which propagates it to GameScreen and all child UI components.
+ *
+ * Owned by Application, shared as const pointers (non-owning).
+ */
+struct UIServices {
+ core::Window* window = nullptr;
+ rendering::Renderer* renderer = nullptr;
+ pipeline::AssetManager* assetManager = nullptr;
+ game::GameHandler* gameHandler = nullptr;
+ game::ExpansionRegistry* expansionRegistry = nullptr;
+ addons::AddonManager* addonManager = nullptr;
+ audio::AudioCoordinator* audioCoordinator = nullptr;
+
+ // Extracted classes (also available individually for Phase A compatibility)
+ core::EntitySpawner* entitySpawner = nullptr;
+ core::AppearanceComposer* appearanceComposer = nullptr;
+ core::WorldLoader* worldLoader = nullptr;
+
+ // Helper to check if core services are wired
+ bool isValid() const {
+ return window && renderer && assetManager && gameHandler;
+ }
+};
+
+} // namespace ui
+} // namespace wowee
diff --git a/include/ui/window_manager.hpp b/include/ui/window_manager.hpp
index f899910b..a5f3564c 100644
--- a/include/ui/window_manager.hpp
+++ b/include/ui/window_manager.hpp
@@ -7,6 +7,7 @@
// equipment sets, skills.
// ============================================================
#pragma once
+#include "ui/ui_services.hpp"
#include
#include
#include
@@ -173,7 +174,11 @@ public:
std::unordered_map extendedCostCache_;
bool extendedCostDbLoaded_ = false;
+ // Section 3.5: UIServices injection (Phase B singleton breaking)
+ void setServices(const UIServices& services) { services_ = services; }
+
private:
+ UIServices services_;
void loadExtendedCostDBC();
std::string formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler);
};
diff --git a/src/audio/audio_coordinator.cpp b/src/audio/audio_coordinator.cpp
new file mode 100644
index 00000000..346dd3fe
--- /dev/null
+++ b/src/audio/audio_coordinator.cpp
@@ -0,0 +1,87 @@
+#include "audio/audio_coordinator.hpp"
+#include "audio/audio_engine.hpp"
+#include "audio/music_manager.hpp"
+#include "audio/footstep_manager.hpp"
+#include "audio/activity_sound_manager.hpp"
+#include "audio/mount_sound_manager.hpp"
+#include "audio/npc_voice_manager.hpp"
+#include "audio/ambient_sound_manager.hpp"
+#include "audio/ui_sound_manager.hpp"
+#include "audio/combat_sound_manager.hpp"
+#include "audio/spell_sound_manager.hpp"
+#include "audio/movement_sound_manager.hpp"
+#include "pipeline/asset_manager.hpp"
+#include "core/logger.hpp"
+
+namespace wowee {
+namespace audio {
+
+AudioCoordinator::AudioCoordinator() = default;
+
+AudioCoordinator::~AudioCoordinator() {
+ shutdown();
+}
+
+bool AudioCoordinator::initialize() {
+ // Initialize AudioEngine (singleton)
+ if (!AudioEngine::instance().initialize()) {
+ LOG_WARNING("Failed to initialize AudioEngine - audio will be disabled");
+ audioAvailable_ = false;
+ return false;
+ }
+ audioAvailable_ = true;
+
+ // Create all audio managers (initialized later with asset manager)
+ musicManager_ = std::make_unique();
+ footstepManager_ = std::make_unique();
+ activitySoundManager_ = std::make_unique();
+ mountSoundManager_ = std::make_unique();
+ npcVoiceManager_ = std::make_unique();
+ ambientSoundManager_ = std::make_unique();
+ uiSoundManager_ = std::make_unique();
+ combatSoundManager_ = std::make_unique();
+ spellSoundManager_ = std::make_unique();
+ movementSoundManager_ = std::make_unique();
+
+ LOG_INFO("AudioCoordinator initialized with ", 10, " audio managers");
+ return true;
+}
+
+void AudioCoordinator::initializeWithAssets(pipeline::AssetManager* assetManager) {
+ if (!audioAvailable_ || !assetManager) return;
+
+ // MusicManager needs asset manager for zone music lookups
+ if (musicManager_) {
+ musicManager_->initialize(assetManager);
+ }
+
+ // Other managers may need asset manager for sound bank loading
+ // (Add similar calls as needed for other managers)
+
+ LOG_INFO("AudioCoordinator initialized with asset manager");
+}
+
+void AudioCoordinator::shutdown() {
+ // Reset all managers first (they may reference AudioEngine)
+ movementSoundManager_.reset();
+ spellSoundManager_.reset();
+ combatSoundManager_.reset();
+ uiSoundManager_.reset();
+ ambientSoundManager_.reset();
+ npcVoiceManager_.reset();
+ mountSoundManager_.reset();
+ activitySoundManager_.reset();
+ footstepManager_.reset();
+ musicManager_.reset();
+
+ // Shutdown audio engine last
+ if (audioAvailable_) {
+ AudioEngine::instance().shutdown();
+ audioAvailable_ = false;
+ }
+
+ LOG_INFO("AudioCoordinator shutdown complete");
+}
+
+} // namespace audio
+} // namespace wowee
diff --git a/src/core/appearance_composer.cpp b/src/core/appearance_composer.cpp
new file mode 100644
index 00000000..cedd2c43
--- /dev/null
+++ b/src/core/appearance_composer.cpp
@@ -0,0 +1,383 @@
+#include "core/appearance_composer.hpp"
+#include "core/entity_spawner.hpp"
+#include "core/logger.hpp"
+#include "rendering/renderer.hpp"
+#include "rendering/character_renderer.hpp"
+#include "pipeline/asset_manager.hpp"
+#include "pipeline/m2_loader.hpp"
+#include "pipeline/dbc_loader.hpp"
+#include "pipeline/dbc_layout.hpp"
+#include "game/game_handler.hpp"
+
+namespace wowee {
+namespace core {
+
+AppearanceComposer::AppearanceComposer(rendering::Renderer* renderer,
+ pipeline::AssetManager* assetManager,
+ game::GameHandler* gameHandler,
+ pipeline::DBCLayout* dbcLayout,
+ EntitySpawner* entitySpawner)
+ : renderer_(renderer)
+ , assetManager_(assetManager)
+ , gameHandler_(gameHandler)
+ , dbcLayout_(dbcLayout)
+ , entitySpawner_(entitySpawner)
+{
+}
+
+std::string AppearanceComposer::getPlayerModelPath(game::Race race, game::Gender gender) const {
+ return game::getPlayerModelPath(race, gender);
+}
+
+PlayerTextureInfo AppearanceComposer::resolvePlayerTextures(pipeline::M2Model& model,
+ game::Race race, game::Gender gender,
+ uint32_t appearanceBytes) {
+ PlayerTextureInfo result;
+
+ uint32_t targetRaceId = static_cast(race);
+ uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u;
+
+ // Race name for fallback texture paths
+ const char* raceFolderName = "Human";
+ switch (race) {
+ case game::Race::HUMAN: raceFolderName = "Human"; break;
+ case game::Race::ORC: raceFolderName = "Orc"; break;
+ case game::Race::DWARF: raceFolderName = "Dwarf"; break;
+ case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break;
+ case game::Race::UNDEAD: raceFolderName = "Scourge"; break;
+ case game::Race::TAUREN: raceFolderName = "Tauren"; break;
+ case game::Race::GNOME: raceFolderName = "Gnome"; break;
+ case game::Race::TROLL: raceFolderName = "Troll"; break;
+ case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break;
+ case game::Race::DRAENEI: raceFolderName = "Draenei"; break;
+ default: break;
+ }
+ const char* genderFolder = (gender == game::Gender::FEMALE) ? "Female" : "Male";
+ std::string raceGender = std::string(raceFolderName) + genderFolder;
+ result.bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp";
+ std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp";
+
+ // Extract appearance bytes for texture lookups
+ uint8_t charSkinId = appearanceBytes & 0xFF;
+ uint8_t charFaceId = (appearanceBytes >> 8) & 0xFF;
+ uint8_t charHairStyleId = (appearanceBytes >> 16) & 0xFF;
+ uint8_t charHairColorId = (appearanceBytes >> 24) & 0xFF;
+ LOG_INFO("Appearance: skin=", static_cast(charSkinId), " face=", static_cast(charFaceId),
+ " hairStyle=", static_cast(charHairStyleId), " hairColor=", static_cast(charHairColorId));
+
+ // Parse CharSections.dbc for skin/face/hair/underwear texture paths
+ auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc");
+ if (charSectionsDbc) {
+ LOG_INFO("CharSections.dbc loaded: ", charSectionsDbc->getRecordCount(), " records");
+ const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr;
+ auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL);
+ bool foundSkin = false;
+ bool foundUnderwear = false;
+ bool foundFaceLower = false;
+ bool foundHair = false;
+ for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
+ uint32_t raceId = charSectionsDbc->getUInt32(r, csF.raceId);
+ uint32_t sexId = charSectionsDbc->getUInt32(r, csF.sexId);
+ uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection);
+ uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex);
+ uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex);
+
+ if (raceId != targetRaceId || sexId != targetSexId) continue;
+
+ // Section 0 = skin: match by colorIndex = skin byte
+ if (baseSection == 0 && !foundSkin && colorIndex == charSkinId) {
+ std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
+ if (!tex1.empty()) {
+ result.bodySkinPath = tex1;
+ foundSkin = true;
+ LOG_INFO(" DBC body skin: ", result.bodySkinPath, " (skin=", static_cast(charSkinId), ")");
+ }
+ }
+ // Section 3 = hair: match variation=hairStyle, color=hairColor
+ else if (baseSection == 3 && !foundHair &&
+ variationIndex == charHairStyleId && colorIndex == charHairColorId) {
+ result.hairTexturePath = charSectionsDbc->getString(r, csF.texture1);
+ if (!result.hairTexturePath.empty()) {
+ foundHair = true;
+ LOG_INFO(" DBC hair texture: ", result.hairTexturePath,
+ " (style=", static_cast(charHairStyleId), " color=", static_cast(charHairColorId), ")");
+ }
+ }
+ // Section 1 = face: match variation=faceId, colorIndex=skinId
+ // Texture1 = face lower, Texture2 = face upper
+ else if (baseSection == 1 && !foundFaceLower &&
+ variationIndex == charFaceId && colorIndex == charSkinId) {
+ std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
+ std::string tex2 = charSectionsDbc->getString(r, csF.texture2);
+ if (!tex1.empty()) {
+ result.faceLowerPath = tex1;
+ LOG_INFO(" DBC face lower: ", result.faceLowerPath);
+ }
+ if (!tex2.empty()) {
+ result.faceUpperPath = tex2;
+ LOG_INFO(" DBC face upper: ", result.faceUpperPath);
+ }
+ foundFaceLower = true;
+ }
+ // Section 4 = underwear
+ else if (baseSection == 4 && !foundUnderwear && colorIndex == charSkinId) {
+ for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) {
+ std::string tex = charSectionsDbc->getString(r, f);
+ if (!tex.empty()) {
+ result.underwearPaths.push_back(tex);
+ LOG_INFO(" DBC underwear texture: ", tex);
+ }
+ }
+ foundUnderwear = true;
+ }
+
+ if (foundSkin && foundHair && foundFaceLower && foundUnderwear) break;
+ }
+
+ if (!foundHair) {
+ LOG_WARNING("No DBC hair match for style=", static_cast(charHairStyleId),
+ " color=", static_cast(charHairColorId),
+ " race=", targetRaceId, " sex=", targetSexId);
+ }
+ } else {
+ LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures");
+ }
+
+ // Fill model texture slots with resolved paths
+ for (auto& tex : model.textures) {
+ if (tex.type == 1 && tex.filename.empty()) {
+ tex.filename = result.bodySkinPath;
+ } else if (tex.type == 6) {
+ if (!result.hairTexturePath.empty()) {
+ tex.filename = result.hairTexturePath;
+ } else if (tex.filename.empty()) {
+ tex.filename = std::string("Character\\") + raceFolderName + "\\Hair00_00.blp";
+ }
+ } else if (tex.type == 8 && tex.filename.empty()) {
+ if (!result.underwearPaths.empty()) {
+ tex.filename = result.underwearPaths[0];
+ } else {
+ tex.filename = pelvisPath;
+ }
+ }
+ }
+
+ return result;
+}
+
+void AppearanceComposer::compositePlayerSkin(uint32_t modelSlotId, const PlayerTextureInfo& texInfo) {
+ if (!renderer_) return;
+ auto* charRenderer = renderer_->getCharacterRenderer();
+ if (!charRenderer) return;
+
+ // Save skin composite state for re-compositing on equipment changes
+ // Include face textures so compositeWithRegions can rebuild the full base
+ bodySkinPath_ = texInfo.bodySkinPath;
+ underwearPaths_.clear();
+ if (!texInfo.faceLowerPath.empty()) underwearPaths_.push_back(texInfo.faceLowerPath);
+ if (!texInfo.faceUpperPath.empty()) underwearPaths_.push_back(texInfo.faceUpperPath);
+ for (const auto& up : texInfo.underwearPaths) underwearPaths_.push_back(up);
+
+ // Composite body skin + face + underwear overlays
+ {
+ std::vector layers;
+ layers.push_back(texInfo.bodySkinPath);
+ if (!texInfo.faceLowerPath.empty()) layers.push_back(texInfo.faceLowerPath);
+ if (!texInfo.faceUpperPath.empty()) layers.push_back(texInfo.faceUpperPath);
+ for (const auto& up : texInfo.underwearPaths) {
+ layers.push_back(up);
+ }
+ if (layers.size() > 1) {
+ rendering::VkTexture* compositeTex = charRenderer->compositeTextures(layers);
+ if (compositeTex != 0) {
+ // Find type-1 (skin) texture slot and replace with composite
+ // We need model texture info — walk slots via charRenderer
+ // Use the model slot ID to find the right texture index
+ auto* modelData = charRenderer->getModelData(modelSlotId);
+ if (modelData) {
+ for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
+ if (modelData->textures[ti].type == 1) {
+ charRenderer->setModelTexture(modelSlotId, static_cast(ti), compositeTex);
+ skinTextureSlotIndex_ = static_cast(ti);
+ LOG_INFO("Replaced type-1 texture slot ", ti, " with composited body+face+underwear");
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Override hair texture on GPU (type-6 slot) after model load
+ if (!texInfo.hairTexturePath.empty()) {
+ rendering::VkTexture* hairTex = charRenderer->loadTexture(texInfo.hairTexturePath);
+ if (hairTex) {
+ auto* modelData = charRenderer->getModelData(modelSlotId);
+ if (modelData) {
+ for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
+ if (modelData->textures[ti].type == 6) {
+ charRenderer->setModelTexture(modelSlotId, static_cast(ti), hairTex);
+ LOG_INFO("Applied DBC hair texture to slot ", ti, ": ", texInfo.hairTexturePath);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Find cloak (type-2, Object Skin) texture slot index
+ {
+ auto* modelData = charRenderer->getModelData(modelSlotId);
+ if (modelData) {
+ for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
+ if (modelData->textures[ti].type == 2) {
+ cloakTextureSlotIndex_ = static_cast(ti);
+ LOG_INFO("Cloak texture slot: ", ti);
+ break;
+ }
+ }
+ }
+ }
+}
+
+std::unordered_set AppearanceComposer::buildDefaultPlayerGeosets(uint8_t hairStyleId, uint8_t facialId) {
+ std::unordered_set activeGeosets;
+ // Body parts (group 0: IDs 0-99, some models use up to 27)
+ for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i);
+
+ // Hair style geoset: group 1 = 100 + variation + 1
+ activeGeosets.insert(static_cast(100 + hairStyleId + 1));
+ // Facial hair geoset: group 2 = 200 + variation + 1
+ activeGeosets.insert(static_cast(200 + facialId + 1));
+ activeGeosets.insert(kGeosetBareForearms);
+ activeGeosets.insert(kGeosetBareShins);
+ activeGeosets.insert(kGeosetDefaultEars);
+ activeGeosets.insert(kGeosetBareSleeves);
+ activeGeosets.insert(kGeosetDefaultKneepads);
+ activeGeosets.insert(kGeosetBarePants);
+ activeGeosets.insert(kGeosetWithCape);
+ activeGeosets.insert(kGeosetBareFeet);
+ // 1703 = DK eye glow mesh — skip for normal characters
+ // Normal eyes are part of the face texture on the body mesh
+ return activeGeosets;
+}
+
+bool AppearanceComposer::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) {
+ auto m2Data = assetManager_->readFile(m2Path);
+ if (m2Data.empty()) return false;
+ outModel = pipeline::M2Loader::load(m2Data);
+ // Load skin (WotLK+ M2 format): strip .m2, append 00.skin
+ std::string skinPath = m2Path;
+ size_t dotPos = skinPath.rfind('.');
+ if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos);
+ skinPath += "00.skin";
+ auto skinData = assetManager_->readFile(skinPath);
+ if (!skinData.empty() && outModel.version >= 264)
+ pipeline::M2Loader::loadSkin(skinData, outModel);
+ return outModel.isValid();
+}
+
+void AppearanceComposer::loadEquippedWeapons() {
+ if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized())
+ return;
+ if (!gameHandler_) return;
+
+ auto* charRenderer = renderer_->getCharacterRenderer();
+ uint32_t charInstanceId = renderer_->getCharacterInstanceId();
+ if (charInstanceId == 0) return;
+
+ auto& inventory = gameHandler_->getInventory();
+
+ // Load ItemDisplayInfo.dbc
+ auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
+ if (!displayInfoDbc) {
+ LOG_WARNING("loadEquippedWeapons: failed to load ItemDisplayInfo.dbc");
+ return;
+ }
+ // Mapping: EquipSlot → attachment ID (1=RightHand, 2=LeftHand)
+ struct WeaponSlot {
+ game::EquipSlot slot;
+ uint32_t attachmentId;
+ };
+ WeaponSlot weaponSlots[] = {
+ { game::EquipSlot::MAIN_HAND, 1 },
+ { game::EquipSlot::OFF_HAND, 2 },
+ };
+
+ if (weaponsSheathed_) {
+ for (const auto& ws : weaponSlots) {
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ }
+ return;
+ }
+
+ for (const auto& ws : weaponSlots) {
+ const auto& equipSlot = inventory.getEquipSlot(ws.slot);
+
+ // If slot is empty or has no displayInfoId, detach any existing weapon
+ if (equipSlot.empty() || equipSlot.item.displayInfoId == 0) {
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+
+ uint32_t displayInfoId = equipSlot.item.displayInfoId;
+ int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
+ if (recIdx < 0) {
+ LOG_WARNING("loadEquippedWeapons: displayInfoId ", displayInfoId, " not found in DBC");
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+
+ const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
+ std::string modelName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModel"] : 1);
+ std::string textureName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3);
+
+ if (modelName.empty()) {
+ LOG_WARNING("loadEquippedWeapons: empty model name for displayInfoId ", displayInfoId);
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+
+ // Convert .mdx → .m2
+ std::string modelFile = modelName;
+ {
+ size_t dotPos = modelFile.rfind('.');
+ if (dotPos != std::string::npos) {
+ modelFile = modelFile.substr(0, dotPos) + ".m2";
+ } else {
+ modelFile += ".m2";
+ }
+ }
+
+ // Try Weapon directory first, then Shield
+ std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile;
+ pipeline::M2Model weaponModel;
+ if (!loadWeaponM2(m2Path, weaponModel)) {
+ m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile;
+ if (!loadWeaponM2(m2Path, weaponModel)) {
+ LOG_WARNING("loadEquippedWeapons: failed to load ", modelFile);
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+ }
+
+ // Build texture path
+ std::string texturePath;
+ if (!textureName.empty()) {
+ texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp";
+ if (!assetManager_->fileExists(texturePath)) {
+ texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp";
+ }
+ }
+
+ uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId();
+ bool ok = charRenderer->attachWeapon(charInstanceId, ws.attachmentId,
+ weaponModel, weaponModelId, texturePath);
+ if (ok) {
+ LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId);
+ }
+ }
+}
+
+} // namespace core
+} // namespace wowee
diff --git a/src/core/application.cpp b/src/core/application.cpp
index eb6cee00..e597cb91 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -31,6 +31,7 @@
#include "audio/footstep_manager.hpp"
#include "audio/activity_sound_manager.hpp"
#include "audio/audio_engine.hpp"
+#include "audio/audio_coordinator.hpp"
#include "addons/addon_manager.hpp"
#include
#include "pipeline/m2_loader.hpp"
@@ -38,6 +39,7 @@
#include "pipeline/wdt_loader.hpp"
#include "pipeline/dbc_loader.hpp"
#include "ui/ui_manager.hpp"
+#include "ui/ui_services.hpp"
#include "auth/auth_handler.hpp"
#include "game/game_handler.hpp"
#include "game/transport_manager.hpp"
@@ -84,131 +86,8 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) {
raw[0] == 'n' || raw[0] == 'N');
}
-// Default (bare) geoset IDs per equipment group.
-// Each group's base is groupNumber * 100; variant 01 is typically bare/default.
-constexpr uint16_t kGeosetDefaultConnector = 101; // Group 1: default hair connector
-constexpr uint16_t kGeosetBareForearms = 401; // Group 4: no gloves
-constexpr uint16_t kGeosetBareShins = 503; // Group 5: no boots
-constexpr uint16_t kGeosetDefaultEars = 702; // Group 7: ears
-constexpr uint16_t kGeosetBareSleeves = 801; // Group 8: no chest armor sleeves
-constexpr uint16_t kGeosetDefaultKneepads = 902; // Group 9: kneepads
-constexpr uint16_t kGeosetDefaultTabard = 1201; // Group 12: tabard base
-constexpr uint16_t kGeosetBarePants = 1301; // Group 13: no leggings
-constexpr uint16_t kGeosetNoCape = 1501; // Group 15: no cape
-constexpr uint16_t kGeosetWithCape = 1502; // Group 15: with cape
-constexpr uint16_t kGeosetBareFeet = 2002; // Group 20: bare feet
} // namespace
-
-const char* Application::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* Application::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 "";
- }
-}
-
-std::string Application::getPlayerModelPath() const {
- return game::getPlayerModelPath(playerRace_, playerGender_);
-}
-
-
Application* Application::instance = nullptr;
Application::Application() {
@@ -342,6 +221,29 @@ bool Application::initialize() {
dbcLayout_.get(), &gameServices_);
entitySpawner_->initialize();
+ appearanceComposer_ = std::make_unique(
+ renderer.get(), assetManager.get(), gameHandler.get(),
+ dbcLayout_.get(), entitySpawner_.get());
+
+ // Wire AppearanceComposer to UI components (Phase A singleton breaking)
+ if (uiManager) {
+ uiManager->setAppearanceComposer(appearanceComposer_.get());
+
+ // Wire all services to UI components (Phase B singleton breaking)
+ ui::UIServices uiServices;
+ uiServices.window = window.get();
+ uiServices.renderer = renderer.get();
+ uiServices.assetManager = assetManager.get();
+ uiServices.gameHandler = gameHandler.get();
+ uiServices.expansionRegistry = expansionRegistry_.get();
+ uiServices.addonManager = addonManager_.get(); // May be nullptr here, re-wire later
+ uiServices.audioCoordinator = audioCoordinator_.get();
+ uiServices.entitySpawner = entitySpawner_.get();
+ uiServices.appearanceComposer = appearanceComposer_.get();
+ uiServices.worldLoader = worldLoader_.get();
+ uiManager->setServices(uiServices);
+ }
+
// Ensure the main in-world CharacterRenderer can load textures immediately.
// Previously this was only wired during terrain initialization, which meant early spawns
// (before terrain load) would render with white fallback textures (notably hair).
@@ -355,15 +257,6 @@ bool Application::initialize() {
gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get());
}
- // Start background preload for last-played character's world.
- // Warms the file cache so terrain tile loading is faster at Enter World.
- {
- auto lastWorld = loadLastWorldInfo();
- if (lastWorld.valid) {
- startWorldPreload(lastWorld.mapId, lastWorld.mapName, lastWorld.x, lastWorld.y);
- }
- }
-
// Initialize addon system
addonManager_ = std::make_unique();
if (addonManager_->initialize(gameHandler.get())) {
@@ -621,6 +514,37 @@ bool Application::initialize() {
addonManager_.reset();
}
+ // Initialize world loader (handles terrain streaming, world preload, map transitions)
+ worldLoader_ = std::make_unique(
+ *this, renderer.get(), assetManager.get(), gameHandler.get(),
+ entitySpawner_.get(), appearanceComposer_.get(), window.get(),
+ world.get(), addonManager_.get());
+
+ // Re-wire UIServices now that all services (addonManager_, worldLoader_) are available
+ if (uiManager) {
+ ui::UIServices uiServices;
+ uiServices.window = window.get();
+ uiServices.renderer = renderer.get();
+ uiServices.assetManager = assetManager.get();
+ uiServices.gameHandler = gameHandler.get();
+ uiServices.expansionRegistry = expansionRegistry_.get();
+ uiServices.addonManager = addonManager_.get();
+ uiServices.audioCoordinator = audioCoordinator_.get();
+ uiServices.entitySpawner = entitySpawner_.get();
+ uiServices.appearanceComposer = appearanceComposer_.get();
+ uiServices.worldLoader = worldLoader_.get();
+ uiManager->setServices(uiServices);
+ }
+
+ // Start background preload for last-played character's world.
+ // Warms the file cache so terrain tile loading is faster at Enter World.
+ {
+ auto lastWorld = worldLoader_->loadLastWorldInfo();
+ if (lastWorld.valid) {
+ worldLoader_->startWorldPreload(lastWorld.mapId, lastWorld.mapName, lastWorld.x, lastWorld.y);
+ }
+ }
+
} else {
LOG_WARNING("Failed to initialize asset manager - asset loading will be unavailable");
LOG_WARNING("Set WOW_DATA_PATH environment variable to your WoW Data directory");
@@ -897,7 +821,9 @@ void Application::shutdown() {
}
// Stop background world preloader before destroying AssetManager
- cancelWorldPreload();
+ if (worldLoader_) {
+ worldLoader_->cancelWorldPreload();
+ };
// Save floor cache before renderer is destroyed
if (renderer && renderer->getWMORenderer()) {
@@ -970,9 +896,9 @@ void Application::setState(AppState newState) {
npcsSpawned = false;
playerCharacterSpawned = false;
addonsLoaded_ = false;
- weaponsSheathed_ = false;
+ if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
wasAutoAttacking_ = false;
- loadedMapId_ = 0xFFFFFFFF;
+ if (worldLoader_) worldLoader_->resetLoadedMap();
spawnedPlayerGuid_ = 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
@@ -1078,8 +1004,7 @@ void Application::reloadExpansionData() {
}
// Reset map name cache so it reloads from new expansion's Map.dbc
- mapNameCacheLoaded_ = false;
- mapNameById_.clear();
+ if (worldLoader_) worldLoader_->resetMapNameCache();
// Reset game handler DBC caches so they reload from new expansion data
if (gameHandler) {
@@ -1105,9 +1030,9 @@ void Application::logoutToLogin() {
// --- Per-session flags ---
npcsSpawned = false;
playerCharacterSpawned = false;
- weaponsSheathed_ = false;
+ if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
wasAutoAttacking_ = false;
- loadedMapId_ = 0xFFFFFFFF;
+ if (worldLoader_) worldLoader_->resetLoadedMap();
lastTaxiFlight_ = false;
taxiLandingClampTimer_ = 0.0f;
worldEntryMovementGraceTimer_ = 0.0f;
@@ -1241,9 +1166,9 @@ void Application::update(float deltaTime) {
updateCheckpoint = "in_game: auto-unsheathe";
if (gameHandler) {
const bool autoAttacking = gameHandler->isAutoAttacking();
- if (autoAttacking && !wasAutoAttacking_ && weaponsSheathed_) {
- weaponsSheathed_ = false;
- loadEquippedWeapons();
+ if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) {
+ appearanceComposer_->setWeaponsSheathed(false);
+ appearanceComposer_->loadEquippedWeapons();
}
wasAutoAttacking_ = autoAttacking;
}
@@ -1254,9 +1179,9 @@ void Application::update(float deltaTime) {
{
const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
auto& input = Input::getInstance();
- if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z)) {
- weaponsSheathed_ = !weaponsSheathed_;
- loadEquippedWeapons();
+ if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z) && appearanceComposer_) {
+ appearanceComposer_->toggleWeaponsSheathed();
+ appearanceComposer_->loadEquippedWeapons();
}
}
@@ -2142,18 +2067,9 @@ void Application::update(float deltaTime) {
break;
}
- if (pendingWorldEntry_ && !loadingWorld_ && state != AppState::DISCONNECTED) {
- auto entry = *pendingWorldEntry_;
- pendingWorldEntry_.reset();
- worldEntryMovementGraceTimer_ = 2.0f;
- taxiLandingClampTimer_ = 0.0f;
- lastTaxiFlight_ = false;
- 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);
+ // Process any pending world entry request via WorldLoader
+ if (worldLoader_ && state != AppState::DISCONNECTED) {
+ worldLoader_->processPendingEntry();
}
// Update renderer (camera, etc.) only when in-game
@@ -2352,7 +2268,8 @@ void Application::setupUICallbacks() {
// Reconnect to the same map: terrain stays loaded but all online entities are stale.
// Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world.
- if (entitySpawner_ && mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) {
+ uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF;
+ if (entitySpawner_ && mapId == currentLoadedMap && renderer && renderer->getTerrainManager() && isInitialEntry) {
LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)");
// Pending spawn queues and failure caches — clear so previously-failed GUIDs can retry.
@@ -2392,7 +2309,7 @@ void Application::setupUICallbacks() {
}
// Same-map teleport (taxi landing, GM teleport, hearthstone on same continent):
- if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager()) {
+ if (mapId == currentLoadedMap && renderer && renderer->getTerrainManager()) {
// Check if teleport is far enough to need terrain loading (>500 render units)
glm::vec3 oldPos = renderer->getCharacterPosition();
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
@@ -2414,7 +2331,7 @@ void Application::setupUICallbacks() {
renderer->getCameraController()->suppressMovementFor(1.0f);
renderer->getCameraController()->suspendGravityFor(10.0f);
}
- pendingWorldEntry_ = PendingWorldEntry{mapId, x, y, z};
+ if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z);
return;
}
LOG_INFO("Same-map teleport (map ", mapId, "), skipping full world reload");
@@ -2459,9 +2376,9 @@ void Application::setupUICallbacks() {
// If a world load is already in progress (re-entrant call from
// gameHandler->update() processing SMSG_NEW_WORLD during warmup),
// defer this entry. The current load will pick it up when it finishes.
- if (loadingWorld_) {
+ if (worldLoader_ && worldLoader_->isLoadingWorld()) {
LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)");
- pendingWorldEntry_ = {mapId, x, y, z};
+ worldLoader_->setPendingEntry(mapId, x, y, z);
return;
}
@@ -2470,7 +2387,7 @@ void Application::setupUICallbacks() {
// it runs after the current packet handler returns instead of recursing
// from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`.
LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")");
- pendingWorldEntry_ = {mapId, x, y, z};
+ if (worldLoader_) worldLoader_->setPendingEntry(mapId, x, y, z);
});
auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional {
@@ -2700,14 +2617,16 @@ void Application::setupUICallbacks() {
// Resolve map name from the cached Map.dbc table
std::string mapName;
- if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) {
- mapName = it->second;
- } else {
- mapName = mapIdToName(mapId);
+ if (worldLoader_) {
+ mapName = worldLoader_->getMapNameById(mapId);
+ }
+ if (mapName.empty()) {
+ mapName = WorldLoader::mapIdToName(mapId);
}
if (mapName.empty()) mapName = "Azeroth";
- if (mapId == loadedMapId_) {
+ uint32_t currentLoadedMap = worldLoader_ ? worldLoader_->getLoadedMapId() : 0xFFFFFFFF;
+ if (mapId == currentLoadedMap) {
// Same map: pre-enqueue tiles around the bind point so workers start
// loading them now. Uses render-space coords (canonicalToRender).
// Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time
@@ -2729,7 +2648,9 @@ void Application::setupUICallbacks() {
// loadOnlineWorldTerrain runs its blocking load loop.
// homeBindPos_ is canonical; startWorldPreload expects server coords.
glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z));
- startWorldPreload(mapId, mapName, server.x, server.y);
+ if (worldLoader_) {
+ worldLoader_->startWorldPreload(mapId, mapName, server.x, server.y);
+ }
LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName,
"' (id=", mapId, ")");
}
@@ -3606,7 +3527,7 @@ void Application::spawnPlayerCharacter() {
auto* charRenderer = renderer->getCharacterRenderer();
auto* camera = renderer->getCamera();
bool loaded = false;
- std::string m2Path = getPlayerModelPath();
+ std::string m2Path = appearanceComposer_->getPlayerModelPath(playerRace_, playerGender_);
std::string modelDir;
std::string baseName;
{
@@ -3643,144 +3564,18 @@ void Application::spawnPlayerCharacter() {
LOG_INFO(" Texture ", ti, ": type=", tex.type, " name='", tex.filename, "'");
}
- // Look up textures from CharSections.dbc for all races
+ // Resolve textures from CharSections.dbc via AppearanceComposer
+ PlayerTextureInfo texInfo;
bool useCharSections = true;
- uint32_t targetRaceId = static_cast(playerRace_);
- uint32_t targetSexId = (playerGender_ == game::Gender::FEMALE) ? 1u : 0u;
-
- // Race name for fallback texture paths
- const char* raceFolderName = "Human";
- switch (playerRace_) {
- case game::Race::HUMAN: raceFolderName = "Human"; break;
- case game::Race::ORC: raceFolderName = "Orc"; break;
- case game::Race::DWARF: raceFolderName = "Dwarf"; break;
- case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break;
- case game::Race::UNDEAD: raceFolderName = "Scourge"; break;
- case game::Race::TAUREN: raceFolderName = "Tauren"; break;
- case game::Race::GNOME: raceFolderName = "Gnome"; break;
- case game::Race::TROLL: raceFolderName = "Troll"; break;
- case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break;
- case game::Race::DRAENEI: raceFolderName = "Draenei"; break;
- default: break;
- }
- const char* genderFolder = (playerGender_ == game::Gender::FEMALE) ? "Female" : "Male";
- std::string raceGender = std::string(raceFolderName) + genderFolder;
- std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp";
- std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp";
- std::string faceLowerTexturePath;
- std::string faceUpperTexturePath;
- std::vector underwearPaths;
-
- // Extract appearance bytes for texture lookups
- uint8_t charSkinId = 0, charFaceId = 0, charHairStyleId = 0, charHairColorId = 0;
- if (gameHandler) {
- const game::Character* activeChar = gameHandler->getActiveCharacter();
- if (activeChar) {
- charSkinId = activeChar->appearanceBytes & 0xFF;
- charFaceId = (activeChar->appearanceBytes >> 8) & 0xFF;
- charHairStyleId = (activeChar->appearanceBytes >> 16) & 0xFF;
- charHairColorId = (activeChar->appearanceBytes >> 24) & 0xFF;
- LOG_INFO("Appearance: skin=", static_cast(charSkinId), " face=", static_cast(charFaceId),
- " hairStyle=", static_cast(charHairStyleId), " hairColor=", static_cast(charHairColorId));
- }
- }
-
- std::string hairTexturePath;
- if (useCharSections) {
- auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc");
- if (charSectionsDbc) {
- LOG_INFO("CharSections.dbc loaded: ", charSectionsDbc->getRecordCount(), " records");
- const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr;
- auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL);
- bool foundSkin = false;
- bool foundUnderwear = false;
- bool foundFaceLower = false;
- bool foundHair = false;
- for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
- uint32_t raceId = charSectionsDbc->getUInt32(r, csF.raceId);
- uint32_t sexId = charSectionsDbc->getUInt32(r, csF.sexId);
- uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection);
- uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex);
- uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex);
-
- if (raceId != targetRaceId || sexId != targetSexId) continue;
-
- // Section 0 = skin: match by colorIndex = skin byte
- if (baseSection == 0 && !foundSkin && colorIndex == charSkinId) {
- std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
- if (!tex1.empty()) {
- bodySkinPath = tex1;
- foundSkin = true;
- LOG_INFO(" DBC body skin: ", bodySkinPath, " (skin=", static_cast(charSkinId), ")");
- }
- }
- // Section 3 = hair: match variation=hairStyle, color=hairColor
- else if (baseSection == 3 && !foundHair &&
- variationIndex == charHairStyleId && colorIndex == charHairColorId) {
- hairTexturePath = charSectionsDbc->getString(r, csF.texture1);
- if (!hairTexturePath.empty()) {
- foundHair = true;
- LOG_INFO(" DBC hair texture: ", hairTexturePath,
- " (style=", static_cast(charHairStyleId), " color=", static_cast(charHairColorId), ")");
- }
- }
- // Section 1 = face: match variation=faceId, colorIndex=skinId
- // Texture1 = face lower, Texture2 = face upper
- else if (baseSection == 1 && !foundFaceLower &&
- variationIndex == charFaceId && colorIndex == charSkinId) {
- std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
- std::string tex2 = charSectionsDbc->getString(r, csF.texture2);
- if (!tex1.empty()) {
- faceLowerTexturePath = tex1;
- LOG_INFO(" DBC face lower: ", faceLowerTexturePath);
- }
- if (!tex2.empty()) {
- faceUpperTexturePath = tex2;
- LOG_INFO(" DBC face upper: ", faceUpperTexturePath);
- }
- foundFaceLower = true;
- }
- // Section 4 = underwear
- else if (baseSection == 4 && !foundUnderwear && colorIndex == charSkinId) {
- for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) {
- std::string tex = charSectionsDbc->getString(r, f);
- if (!tex.empty()) {
- underwearPaths.push_back(tex);
- LOG_INFO(" DBC underwear texture: ", tex);
- }
- }
- foundUnderwear = true;
- }
-
- if (foundSkin && foundHair && foundFaceLower && foundUnderwear) break;
- }
-
- if (!foundHair) {
- LOG_WARNING("No DBC hair match for style=", static_cast(charHairStyleId),
- " color=", static_cast(charHairColorId),
- " race=", targetRaceId, " sex=", targetSexId);
- }
- } else {
- LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures");
- }
-
- for (auto& tex : model.textures) {
- if (tex.type == 1 && tex.filename.empty()) {
- tex.filename = bodySkinPath;
- } else if (tex.type == 6) {
- if (!hairTexturePath.empty()) {
- tex.filename = hairTexturePath;
- } else if (tex.filename.empty()) {
- tex.filename = std::string("Character\\") + raceFolderName + "\\Hair00_00.blp";
- }
- } else if (tex.type == 8 && tex.filename.empty()) {
- if (!underwearPaths.empty()) {
- tex.filename = underwearPaths[0];
- } else {
- tex.filename = pelvisPath;
- }
+ if (appearanceComposer_) {
+ uint32_t appearanceBytes = 0;
+ if (gameHandler) {
+ const game::Character* activeChar = gameHandler->getActiveCharacter();
+ if (activeChar) {
+ appearanceBytes = activeChar->appearanceBytes;
}
}
+ texInfo = appearanceComposer_->resolvePlayerTextures(model, playerRace_, playerGender_, appearanceBytes);
}
// Load external .anim files for sequences with external data.
@@ -3806,62 +3601,9 @@ void Application::spawnPlayerCharacter() {
charRenderer->loadModel(model, 1);
- if (useCharSections) {
- // Save skin composite state for re-compositing on equipment changes
- // Include face textures so compositeWithRegions can rebuild the full base
- bodySkinPath_ = bodySkinPath;
- underwearPaths_.clear();
- if (!faceLowerTexturePath.empty()) underwearPaths_.push_back(faceLowerTexturePath);
- if (!faceUpperTexturePath.empty()) underwearPaths_.push_back(faceUpperTexturePath);
- for (const auto& up : underwearPaths) underwearPaths_.push_back(up);
-
- // Composite body skin + face + underwear overlays
- {
- std::vector layers;
- layers.push_back(bodySkinPath);
- if (!faceLowerTexturePath.empty()) layers.push_back(faceLowerTexturePath);
- if (!faceUpperTexturePath.empty()) layers.push_back(faceUpperTexturePath);
- for (const auto& up : underwearPaths) {
- layers.push_back(up);
- }
- if (layers.size() > 1) {
- rendering::VkTexture* compositeTex = charRenderer->compositeTextures(layers);
- if (compositeTex != 0) {
- for (size_t ti = 0; ti < model.textures.size(); ti++) {
- if (model.textures[ti].type == 1) {
- charRenderer->setModelTexture(1, static_cast(ti), compositeTex);
- skinTextureSlotIndex_ = static_cast(ti);
- LOG_INFO("Replaced type-1 texture slot ", ti, " with composited body+face+underwear");
- break;
- }
- }
- }
- }
- }
- // Override hair texture on GPU (type-6 slot) after model load
- if (!hairTexturePath.empty()) {
- rendering::VkTexture* hairTex = charRenderer->loadTexture(hairTexturePath);
- if (hairTex) {
- for (size_t ti = 0; ti < model.textures.size(); ti++) {
- if (model.textures[ti].type == 6) {
- charRenderer->setModelTexture(1, static_cast(ti), hairTex);
- LOG_INFO("Applied DBC hair texture to slot ", ti, ": ", hairTexturePath);
- break;
- }
- }
- }
- }
- } else {
- bodySkinPath_.clear();
- underwearPaths_.clear();
- }
- // Find cloak (type-2, Object Skin) texture slot index
- for (size_t ti = 0; ti < model.textures.size(); ti++) {
- if (model.textures[ti].type == 2) {
- cloakTextureSlotIndex_ = static_cast(ti);
- LOG_INFO("Cloak texture slot: ", ti);
- break;
- }
+ // Apply composited textures via AppearanceComposer (saves skin state for re-compositing)
+ if (useCharSections && appearanceComposer_) {
+ appearanceComposer_->compositePlayerSkin(1, texInfo);
}
loaded = true;
@@ -3942,12 +3684,7 @@ void Application::spawnPlayerCharacter() {
renderer->getCharacterPosition() = spawnPos;
renderer->setCharacterFollow(instanceId);
- // Default geosets for the active character (match CharacterPreview logic).
- // Previous hardcoded values (notably always inserting 101) caused wrong hair meshes in-world.
- std::unordered_set activeGeosets;
- // Body parts (group 0: IDs 0-99, some models use up to 27)
- for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i);
-
+ // Build default geosets for the active character via AppearanceComposer
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (gameHandler) {
@@ -3956,20 +3693,9 @@ void Application::spawnPlayerCharacter() {
facialId = ch->facialFeatures;
}
}
- // Hair style geoset: group 1 = 100 + variation + 1
- activeGeosets.insert(static_cast(100 + hairStyleId + 1));
- // Facial hair geoset: group 2 = 200 + variation + 1
- activeGeosets.insert(static_cast(200 + facialId + 1));
- activeGeosets.insert(kGeosetBareForearms);
- activeGeosets.insert(kGeosetBareShins);
- activeGeosets.insert(kGeosetDefaultEars);
- activeGeosets.insert(kGeosetBareSleeves);
- activeGeosets.insert(kGeosetDefaultKneepads);
- activeGeosets.insert(kGeosetBarePants);
- activeGeosets.insert(kGeosetWithCape);
- activeGeosets.insert(kGeosetBareFeet);
- // 1703 = DK eye glow mesh — skip for normal characters
- // Normal eyes are part of the face texture on the body mesh
+ auto activeGeosets = appearanceComposer_
+ ? appearanceComposer_->buildDefaultPlayerGeosets(hairStyleId, facialId)
+ : std::unordered_set{};
charRenderer->setActiveGeosets(instanceId, activeGeosets);
// Play idle animation (Stand = animation ID 0)
@@ -4024,124 +3750,7 @@ void Application::spawnPlayerCharacter() {
}
// Load equipped weapons (sword + shield)
- loadEquippedWeapons();
- }
-}
-
-bool Application::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) {
- auto m2Data = assetManager->readFile(m2Path);
- if (m2Data.empty()) return false;
- outModel = pipeline::M2Loader::load(m2Data);
- // Load skin (WotLK+ M2 format): strip .m2, append 00.skin
- std::string skinPath = m2Path;
- size_t dotPos = skinPath.rfind('.');
- if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos);
- skinPath += "00.skin";
- auto skinData = assetManager->readFile(skinPath);
- if (!skinData.empty() && outModel.version >= 264)
- pipeline::M2Loader::loadSkin(skinData, outModel);
- return outModel.isValid();
-}
-
-void Application::loadEquippedWeapons() {
- if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized())
- return;
- if (!gameHandler) return;
-
- auto* charRenderer = renderer->getCharacterRenderer();
- uint32_t charInstanceId = renderer->getCharacterInstanceId();
- if (charInstanceId == 0) return;
-
- auto& inventory = gameHandler->getInventory();
-
- // Load ItemDisplayInfo.dbc
- auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
- if (!displayInfoDbc) {
- LOG_WARNING("loadEquippedWeapons: failed to load ItemDisplayInfo.dbc");
- return;
- }
- // Mapping: EquipSlot → attachment ID (1=RightHand, 2=LeftHand)
- struct WeaponSlot {
- game::EquipSlot slot;
- uint32_t attachmentId;
- };
- WeaponSlot weaponSlots[] = {
- { game::EquipSlot::MAIN_HAND, 1 },
- { game::EquipSlot::OFF_HAND, 2 },
- };
-
- if (weaponsSheathed_) {
- for (const auto& ws : weaponSlots) {
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- }
- return;
- }
-
- for (const auto& ws : weaponSlots) {
- const auto& equipSlot = inventory.getEquipSlot(ws.slot);
-
- // If slot is empty or has no displayInfoId, detach any existing weapon
- if (equipSlot.empty() || equipSlot.item.displayInfoId == 0) {
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
-
- uint32_t displayInfoId = equipSlot.item.displayInfoId;
- int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
- if (recIdx < 0) {
- LOG_WARNING("loadEquippedWeapons: displayInfoId ", displayInfoId, " not found in DBC");
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
-
- const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
- std::string modelName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModel"] : 1);
- std::string textureName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3);
-
- if (modelName.empty()) {
- LOG_WARNING("loadEquippedWeapons: empty model name for displayInfoId ", displayInfoId);
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
-
- // Convert .mdx → .m2
- std::string modelFile = modelName;
- {
- size_t dotPos = modelFile.rfind('.');
- if (dotPos != std::string::npos) {
- modelFile = modelFile.substr(0, dotPos) + ".m2";
- } else {
- modelFile += ".m2";
- }
- }
-
- // Try Weapon directory first, then Shield
- std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile;
- pipeline::M2Model weaponModel;
- if (!loadWeaponM2(m2Path, weaponModel)) {
- m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile;
- if (!loadWeaponM2(m2Path, weaponModel)) {
- LOG_WARNING("loadEquippedWeapons: failed to load ", modelFile);
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
- }
-
- // Build texture path
- std::string texturePath;
- if (!textureName.empty()) {
- texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp";
- if (!assetManager->fileExists(texturePath)) {
- texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp";
- }
- }
-
- uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId();
- bool ok = charRenderer->attachWeapon(charInstanceId, ws.attachmentId,
- weaponModel, weaponModelId, texturePath);
- if (ok) {
- LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId);
- }
+ if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
}
}
@@ -4273,920 +3882,6 @@ void Application::buildFactionHostilityMap(uint8_t playerRace) {
" hostile (friendGroup=0x", std::hex, playerFriendGroup, ", enemyGroup=0x", playerEnemyGroup, std::dec, ")");
}
-void Application::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(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
- 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) {
- buildFactionHostilityMap(static_cast(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 != spawnedPlayerGuid_) ||
- (activeChar->appearanceBytes != spawnedAppearanceBytes_) ||
- (activeChar->facialFeatures != spawnedFacialFeatures_) ||
- (activeChar->race != playerRace_) ||
- (activeChar->gender != playerGender_) ||
- (activeChar->characterClass != playerClass_);
-
- if (!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);
- }
- }
- playerCharacterSpawned = false;
- spawnedPlayerGuid_ = 0;
- spawnedAppearanceBytes_ = 0;
- spawnedFacialFeatures_ = 0;
-
- playerRace_ = activeChar->race;
- playerGender_ = activeChar->gender;
- playerClass_ = activeChar->characterClass;
- spawnSnapToGround = false;
- weaponsSheathed_ = false;
- loadEquippedWeapons(); // will no-op until instance exists
- 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 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.get(), 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 (!playerCharacterSpawned) {
- spawnPlayerCharacter();
- 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 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(std::tolower(static_cast(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 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(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(wdtInfo.doodadSet),
- static_cast(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(std::tolower(static_cast(c)));
- if (ext == ".mdx" || ext == ".mdl") {
- m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2";
- }
- }
-
- std::vector 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 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(std::hash{}(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.get(), 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 (!playerCharacterSpawned) {
- spawnPlayerCharacter();
- 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.
- const int savedLoadRadius = 4;
- terrainMgr->setLoadRadius(3); // 7x7=49 tiles — prevents hitches on spawn
- terrainMgr->setUnloadRadius(7);
-
- // 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(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(loaded) / static_cast(total);
- float progress = 0.35f + tileProgress * 0.50f;
-
- auto now = std::chrono::high_resolution_clock::now();
- float elapsedSec = std::chrono::duration(now - startTime).count();
-
- char buf[192];
- if (loaded > 0 && remaining > 0) {
- float tilesPerSec = static_cast(loaded) / std::max(elapsedSec, 0.1f);
- float etaSec = static_cast(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(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(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* app = this;
-
- gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) {
- app->entitySpawner_->markCreatureDead(guid);
- uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid);
- if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid);
- if (instanceId != 0 && cr) {
- cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death
- }
- });
-
- gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) {
- app->entitySpawner_->unmarkCreatureDead(guid);
- uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid);
- if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid);
- if (instanceId != 0 && cr) {
- cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle
- }
- });
-
- gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) {
- uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid);
- if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid);
- if (instanceId != 0 && cr) {
- cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1
- }
- });
- }
-
- // 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(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);
- }
- 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(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(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);
- worldEntryMovementGraceTimer_ = 2.0f;
- taxiLandingClampTimer_ = 0.0f;
- lastTaxiFlight_ = 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).
- setState(AppState::IN_GAME);
-
- // Load addons once per session on first world entry
- if (addonManager_ && !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();
- addonsLoaded_ = true;
- addonManager_->fireEvent("VARIABLES_LOADED");
- addonManager_->fireEvent("PLAYER_LOGIN");
- addonManager_->fireEvent("PLAYER_ENTERING_WORLD");
- } else if (addonManager_ && addonsLoaded_) {
- // Subsequent world entries (e.g. teleport, instance entry)
- addonManager_->fireEvent("PLAYER_ENTERING_WORLD");
- }
-}
-
// Render bounds/position queries — delegates to EntitySpawner
bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const {
if (entitySpawner_) return entitySpawner_->getRenderBoundsForGuid(guid, outCenter, outRadius);
@@ -5451,126 +4146,5 @@ void Application::setupTestTransport() {
LOG_INFO("========================================");
}
-// ─── World Preloader ─────────────────────────────────────────────────────────
-// Pre-warms AssetManager file cache with ADT files (and their _obj0 variants)
-// for tiles around the expected spawn position. Runs in background so that
-// when loadOnlineWorldTerrain eventually asks TerrainManager workers to parse
-// the same files, every readFile() is an instant cache hit instead of disk I/O.
-
-void Application::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_->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>();
- // 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.get();
- std::string mn = mapName;
-
- int numWorkers = std::min(static_cast(jobs->size()), 4);
- auto nextJob = std::make_shared>(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(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 Application::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 Application::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";
- }
-}
-
-Application::LastWorldInfo Application::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(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
diff --git a/src/core/world_loader.cpp b/src/core/world_loader.cpp
new file mode 100644
index 00000000..9e90e747
--- /dev/null
+++ b/src/core/world_loader.cpp
@@ -0,0 +1,1217 @@
+// 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"
+#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
+#include
+#include
+#include
+#include
+#include
+#include
+
+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);
+ app_.worldEntryMovementGraceTimer_ = 2.0f;
+ app_.taxiLandingClampTimer_ = 0.0f;
+ app_.lastTaxiFlight_ = 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(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(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 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 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(std::tolower(static_cast(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 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(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(wdtInfo.doodadSet),
+ static_cast(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(std::tolower(static_cast(c)));
+ if (ext == ".mdx" || ext == ".mdl") {
+ m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2";
+ }
+ }
+
+ std::vector 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 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(std::hash{}(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.
+ const int savedLoadRadius = 4;
+ terrainMgr->setLoadRadius(3); // 7x7=49 tiles — prevents hitches on spawn
+ terrainMgr->setUnloadRadius(7);
+
+ // 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(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(loaded) / static_cast(total);
+ float progress = 0.35f + tileProgress * 0.50f;
+
+ auto now = std::chrono::high_resolution_clock::now();
+ float elapsedSec = std::chrono::duration(now - startTime).count();
+
+ char buf[192];
+ if (loaded > 0 && remaining > 0) {
+ float tilesPerSec = static_cast(loaded) / std::max(elapsedSec, 0.1f);
+ float etaSec = static_cast(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(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(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) {
+ cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death
+ }
+ });
+
+ 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) {
+ cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle
+ }
+ });
+
+ gameHandler_->setNpcSwingCallback([cr, spawner](uint64_t guid) {
+ uint32_t instanceId = spawner->getCreatureInstanceId(guid);
+ if (instanceId == 0) instanceId = spawner->getPlayerInstanceId(guid);
+ if (instanceId != 0 && cr) {
+ cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1
+ }
+ });
+ }
+
+ // 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(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(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(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);
+ app_.worldEntryMovementGraceTimer_ = 2.0f;
+ app_.taxiLandingClampTimer_ = 0.0f;
+ app_.lastTaxiFlight_ = 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_->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>();
+ // 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(jobs->size()), 4);
+ auto nextJob = std::make_shared>(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(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(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
diff --git a/src/ui/action_bar_panel.cpp b/src/ui/action_bar_panel.cpp
index 7ab39af4..49460c14 100644
--- a/src/ui/action_bar_panel.cpp
+++ b/src/ui/action_bar_panel.cpp
@@ -172,7 +172,7 @@ void ActionBarPanel::renderActionBar(game::GameHandler& gameHandler,
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
- auto* assetMgr = core::Application::getInstance().getAssetManager();
+ auto* assetMgr = services_.assetManager;
float slotSize = 48.0f * settingsPanel.pendingActionBarScale;
float spacing = 4.0f;
@@ -1107,7 +1107,7 @@ void ActionBarPanel::renderStanceBar(game::GameHandler& gameHandler,
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
- auto* assetMgr = core::Application::getInstance().getAssetManager();
+ auto* assetMgr = services_.assetManager;
// Match the action bar slot size so they align neatly
float slotSize = 38.0f;
@@ -1196,7 +1196,7 @@ void ActionBarPanel::renderBagBar(game::GameHandler& gameHandler,
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
- auto* assetMgr = core::Application::getInstance().getAssetManager();
+ auto* assetMgr = services_.assetManager;
float slotSize = 42.0f;
float spacing = 4.0f;
@@ -1232,7 +1232,7 @@ void ActionBarPanel::renderBagBar(game::GameHandler& gameHandler,
if (!blpData.empty()) {
auto image = pipeline::BLPLoader::load(blpData);
if (image.isValid()) {
- auto* w = core::Application::getInstance().getWindow();
+ auto* w = services_.window;
auto* vkCtx = w ? w->getVkContext() : nullptr;
if (vkCtx)
backpackIconTexture_ = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height);
@@ -1483,7 +1483,7 @@ void ActionBarPanel::renderXpBar(game::GameHandler& gameHandler,
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
(void)window; // Not used for positioning; kept for AssetManager if needed
// Position just above both action bars (bar1 at screenH-barH, bar2 above that)
diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp
index 8a6d0ef3..19ff5331 100644
--- a/src/ui/chat_panel.cpp
+++ b/src/ui/chat_panel.cpp
@@ -197,8 +197,8 @@ void ChatPanel::render(game::GameHandler& gameHandler,
InventoryScreen& inventoryScreen,
SpellbookScreen& spellbookScreen,
QuestLogScreen& questLogScreen) {
- auto* window = core::Application::getInstance().getWindow();
- auto* assetMgr = core::Application::getInstance().getAssetManager();
+ auto* window = services_.window;
+ auto* assetMgr = services_.assetManager;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
float chatW = std::min(500.0f, screenW * 0.4f);
@@ -1109,7 +1109,7 @@ void ChatPanel::render(game::GameHandler& gameHandler,
std::string bodyLower = mMsg.message;
for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c)));
if (bodyLower.find(selfNameLower) != std::string::npos) {
- if (auto* renderer = core::Application::getInstance().getRenderer()) {
+ if (auto* renderer = services_.renderer) {
if (auto* ui = renderer->getUiSoundManager())
ui->playWhisperReceived();
}
@@ -2151,7 +2151,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
// /run — execute Lua script via addon system
if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) {
std::string luaCode = command.substr(spacePos + 1);
- auto* am = core::Application::getInstance().getAddonManager();
+ auto* am = services_.addonManager;
if (am) {
am->runScript(luaCode);
} else {
@@ -2164,7 +2164,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
// /dump — evaluate Lua expression and print result
if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) {
std::string expr = command.substr(spacePos + 1);
- auto* am = core::Application::getInstance().getAddonManager();
+ auto* am = services_.addonManager;
if (am && am->isInitialized()) {
// Wrap expression in print(tostring(...)) to display the value
std::string wrapped = "local __v = " + expr +
@@ -2187,7 +2187,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
// Check addon slash commands (SlashCmdList) before built-in commands
{
- auto* am = core::Application::getInstance().getAddonManager();
+ auto* am = services_.addonManager;
if (am && am->isInitialized()) {
std::string slashCmd = "/" + cmdLower;
std::string slashArgs;
@@ -2214,7 +2214,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
// /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files)
if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") {
- auto* am = core::Application::getInstance().getAddonManager();
+ auto* am = services_.addonManager;
if (am) {
am->reload();
am->fireEvent("VARIABLES_LOADED");
@@ -2301,7 +2301,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") {
const auto& pmi = gameHandler.getMovementInfo();
std::string zoneName;
- if (auto* rend = core::Application::getInstance().getRenderer())
+ if (auto* rend = services_.renderer)
zoneName = rend->getCurrentZoneName();
char buf[256];
snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s",
@@ -2327,7 +2327,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
// /zone command — print current zone name
if (cmdLower == "zone") {
std::string zoneName;
- if (auto* rend = core::Application::getInstance().getRenderer())
+ if (auto* rend = services_.renderer)
zoneName = rend->getCurrentZoneName();
game::MessageChatData sysMsg;
sysMsg.type = game::ChatType::SYSTEM;
@@ -4323,7 +4323,7 @@ void ChatPanel::sendChatMessage(game::GameHandler& gameHandler,
std::string emoteText = rendering::Renderer::getEmoteText(cmdLower, targetNamePtr);
if (!emoteText.empty()) {
// Play the emote animation
- auto* renderer = core::Application::getInstance().getRenderer();
+ auto* renderer = services_.renderer;
if (renderer) {
renderer->playEmote(cmdLower);
}
@@ -4697,11 +4697,11 @@ std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::
void ChatPanel::renderBubbles(game::GameHandler& gameHandler) {
if (chatBubbles_.empty()) return;
- auto* renderer = core::Application::getInstance().getRenderer();
+ auto* renderer = services_.renderer;
auto* camera = renderer ? renderer->getCamera() : nullptr;
if (!camera) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
diff --git a/src/ui/combat_ui.cpp b/src/ui/combat_ui.cpp
index 8ba732e7..17b6e0c9 100644
--- a/src/ui/combat_ui.cpp
+++ b/src/ui/combat_ui.cpp
@@ -60,7 +60,7 @@ namespace ui {
void CombatUI::renderCastBar(game::GameHandler& gameHandler, SpellIconFn getSpellIcon) {
if (!gameHandler.isCasting()) return;
- auto* assetMgr = core::Application::getInstance().getAssetManager();
+ auto* assetMgr = services_.assetManager;
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
@@ -187,8 +187,8 @@ void CombatUI::renderCooldownTracker(game::GameHandler& gameHandler,
return a.remaining > b.remaining;
});
- auto* assetMgr = core::Application::getInstance().getAssetManager();
- auto* window = core::Application::getInstance().getWindow();
+ auto* assetMgr = services_.assetManager;
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -268,7 +268,7 @@ void CombatUI::renderRaidWarningOverlay(game::GameHandler& gameHandler) {
// Walk only the new messages (deque — iterate from back by skipping old ones)
size_t toScan = newCount - raidWarnChatSeenCount_;
size_t startIdx = newCount > toScan ? newCount - toScan : 0;
- auto* renderer = core::Application::getInstance().getRenderer();
+ auto* renderer = services_.renderer;
for (size_t i = startIdx; i < newCount; ++i) {
const auto& msg = chatHistory[i];
if (msg.type == game::ChatType::RAID_WARNING ||
@@ -361,13 +361,13 @@ void CombatUI::renderCombatText(game::GameHandler& gameHandler) {
const auto& entries = gameHandler.getCombatText();
if (entries.empty()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
if (!window) return;
const float screenW = static_cast(window->getWidth());
const float screenH = static_cast(window->getHeight());
// Camera for world-space projection
- auto* appRenderer = core::Application::getInstance().getRenderer();
+ auto* appRenderer = services_.renderer;
rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr;
glm::mat4 viewProj;
if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix();
@@ -785,7 +785,7 @@ void CombatUI::renderDPSMeter(game::GameHandler& gameHandler,
fmtNum(hps, hpsBuf, sizeof(hpsBuf));
// Position: small floating label just above the action bar, right of center
- auto* appWin = core::Application::getInstance().getWindow();
+ auto* appWin = services_.window;
float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f;
float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f;
@@ -866,7 +866,7 @@ void CombatUI::renderBuffBar(game::GameHandler& gameHandler,
}
if (activeCount == 0 && !gameHandler.hasPet()) return;
- auto* assetMgr = core::Application::getInstance().getAssetManager();
+ auto* assetMgr = services_.assetManager;
// Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210)
// Anchored to the right side to stay away from party frames on the left
@@ -1201,7 +1201,7 @@ void CombatUI::renderBattlegroundScore(game::GameHandler& gameHandler) {
if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv;
}
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
// Width scales with screen but stays reasonable
@@ -1598,7 +1598,7 @@ void CombatUI::renderCombatLog(game::GameHandler& gameHandler,
ImGui::TextColored(color, "%s", desc);
// Hover tooltip: show rich spell info for entries with a known spell
if (e.spellId != 0 && ImGui::IsItemHovered()) {
- auto* assetMgrLog = core::Application::getInstance().getAssetManager();
+ auto* assetMgrLog = services_.assetManager;
ImGui::BeginTooltip();
bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog);
if (!richOk) {
diff --git a/src/ui/dialog_manager.cpp b/src/ui/dialog_manager.cpp
index dd90a990..0677efb3 100644
--- a/src/ui/dialog_manager.cpp
+++ b/src/ui/dialog_manager.cpp
@@ -69,7 +69,7 @@ void DialogManager::renderLateDialogs(game::GameHandler& gameHandler) {
void DialogManager::renderGroupInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingGroupInvite()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 200), ImGuiCond_Always);
@@ -93,7 +93,7 @@ void DialogManager::renderGroupInvitePopup(game::GameHandler& gameHandler) {
void DialogManager::renderDuelRequestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingDuelRequest()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 250), ImGuiCond_Always);
@@ -158,7 +158,7 @@ void DialogManager::renderDuelCountdown(game::GameHandler& gameHandler) {
void DialogManager::renderItemTextWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isItemTextOpen()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -194,7 +194,7 @@ void DialogManager::renderItemTextWindow(game::GameHandler& gameHandler) {
void DialogManager::renderSharedQuestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingSharedQuest()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 490), ImGuiCond_Always);
@@ -224,7 +224,7 @@ void DialogManager::renderSummonRequestPopup(game::GameHandler& gameHandler) {
gameHandler.tickSummonTimeout(dt);
if (!gameHandler.hasPendingSummonRequest()) return; // expired
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 430), ImGuiCond_Always);
@@ -252,7 +252,7 @@ void DialogManager::renderSummonRequestPopup(game::GameHandler& gameHandler) {
void DialogManager::renderTradeRequestPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingTradeRequest()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 370), ImGuiCond_Always);
@@ -284,7 +284,7 @@ void DialogManager::renderTradeWindow(game::GameHandler& gameHandler,
const uint64_t peerGold = gameHandler.getPeerTradeGold();
const auto& peerName = gameHandler.getTradePeerName();
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -443,7 +443,7 @@ void DialogManager::renderLootRollPopup(game::GameHandler& gameHandler,
const auto& roll = gameHandler.getPendingLootRoll();
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 310), ImGuiCond_Always);
@@ -584,7 +584,7 @@ void DialogManager::renderLootRollPopup(game::GameHandler& gameHandler,
void DialogManager::renderGuildInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingGuildInvite()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 250), ImGuiCond_Always);
@@ -610,7 +610,7 @@ void DialogManager::renderGuildInvitePopup(game::GameHandler& gameHandler) {
void DialogManager::renderReadyCheckPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingReadyCheck()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -684,7 +684,7 @@ void DialogManager::renderBgInvitePopup(game::GameHandler& gameHandler) {
return;
}
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -745,7 +745,7 @@ void DialogManager::renderBfMgrInvitePopup(game::GameHandler& gameHandler) {
// Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager)
if (!gameHandler.hasBfMgrInvite()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -802,7 +802,7 @@ void DialogManager::renderLfgProposalPopup(game::GameHandler& gameHandler) {
using LfgState = game::GameHandler::LfgState;
if (gameHandler.getLfgState() != LfgState::Proposal) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -851,7 +851,7 @@ void DialogManager::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) {
using LfgState = game::GameHandler::LfgState;
if (gameHandler.getLfgState() != LfgState::RoleCheck) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast(window->getHeight()) : 720.0f;
@@ -915,7 +915,7 @@ void DialogManager::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) {
void DialogManager::renderResurrectDialog(game::GameHandler& gameHandler) {
if (!gameHandler.showResurrectDialog()) return;
- auto* window = core::Application::getInstance().getWindow();
+ auto* window = services_.window;
float screenW = window ? static_cast(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast