2026-04-05 19:30:44 +03:00
|
|
|
|
#include "core/entity_spawner.hpp"
|
|
|
|
|
|
#include "core/coordinates.hpp"
|
|
|
|
|
|
#include "core/logger.hpp"
|
|
|
|
|
|
#include "rendering/renderer.hpp"
|
|
|
|
|
|
#include "rendering/animation_controller.hpp"
|
|
|
|
|
|
#include "rendering/vk_context.hpp"
|
|
|
|
|
|
#include "rendering/character_renderer.hpp"
|
|
|
|
|
|
#include "rendering/wmo_renderer.hpp"
|
|
|
|
|
|
#include "rendering/m2_renderer.hpp"
|
|
|
|
|
|
#include "audio/npc_voice_manager.hpp"
|
|
|
|
|
|
#include "pipeline/m2_loader.hpp"
|
|
|
|
|
|
#include "pipeline/wmo_loader.hpp"
|
|
|
|
|
|
#include "rendering/animation/animation_ids.hpp"
|
|
|
|
|
|
#include "pipeline/dbc_loader.hpp"
|
|
|
|
|
|
#include "pipeline/asset_manager.hpp"
|
|
|
|
|
|
#include "pipeline/dbc_layout.hpp"
|
|
|
|
|
|
#include "game/game_handler.hpp"
|
|
|
|
|
|
#include "game/game_services.hpp"
|
|
|
|
|
|
#include "game/transport_manager.hpp"
|
|
|
|
|
|
|
|
|
|
|
|
#include <cmath>
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
#include <cctype>
|
|
|
|
|
|
#include <sstream>
|
|
|
|
|
|
#include <cstring>
|
|
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
|
namespace core {
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processAsyncCreatureResults(bool unlimited) {
|
|
|
|
|
|
// Check completed async model loads and finalize on main thread (GPU upload + instance creation).
|
|
|
|
|
|
// Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates.
|
|
|
|
|
|
// Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls.
|
|
|
|
|
|
static constexpr int kMaxModelUploadsPerTick = 1;
|
|
|
|
|
|
static constexpr int kMaxModelUploadsPerTickWarmup = 1;
|
|
|
|
|
|
static constexpr float kFinalizeBudgetMs = 2.0f;
|
|
|
|
|
|
static constexpr float kFinalizeBudgetWarmupMs = 2.0f;
|
|
|
|
|
|
const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick;
|
|
|
|
|
|
const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs;
|
|
|
|
|
|
const auto tickStart = std::chrono::steady_clock::now();
|
|
|
|
|
|
int modelUploads = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) {
|
|
|
|
|
|
if (std::chrono::duration<float, std::milli>(
|
|
|
|
|
|
std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!it->future.valid() ||
|
|
|
|
|
|
it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto result = it->future.get();
|
|
|
|
|
|
it = asyncCreatureLoads_.erase(it);
|
|
|
|
|
|
asyncCreatureDisplayLoads_.erase(result.displayId);
|
|
|
|
|
|
|
|
|
|
|
|
// Failures and cache hits need no GPU work — process them even when the
|
|
|
|
|
|
// upload budget is exhausted. Previously the budget check was above this
|
|
|
|
|
|
// point, blocking ALL ready futures (including zero-cost ones) after a
|
|
|
|
|
|
// single upload, which throttled creature spawn throughput during world load.
|
|
|
|
|
|
if (result.permanent_failure) {
|
|
|
|
|
|
nonRenderableCreatureDisplayIds_.insert(result.displayId);
|
|
|
|
|
|
creaturePermanentFailureGuids_.insert(result.guid);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(result.guid);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!result.valid || !result.model) {
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(result.guid);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Another async result may have already uploaded this displayId while this
|
|
|
|
|
|
// task was still running; in that case, skip duplicate GPU upload.
|
|
|
|
|
|
if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) {
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(result.guid);
|
|
|
|
|
|
if (!creatureInstances_.count(result.guid) &&
|
|
|
|
|
|
!creaturePermanentFailureGuids_.count(result.guid)) {
|
|
|
|
|
|
PendingCreatureSpawn s{};
|
|
|
|
|
|
s.guid = result.guid;
|
|
|
|
|
|
s.displayId = result.displayId;
|
|
|
|
|
|
s.x = result.x;
|
|
|
|
|
|
s.y = result.y;
|
|
|
|
|
|
s.z = result.z;
|
|
|
|
|
|
s.orientation = result.orientation;
|
|
|
|
|
|
s.scale = result.scale;
|
|
|
|
|
|
pendingCreatureSpawns_.push_back(s);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.insert(result.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only actual GPU uploads count toward the per-tick budget.
|
|
|
|
|
|
if (modelUploads >= maxUploadsThisTick) {
|
|
|
|
|
|
// Re-queue this result — it needs a GPU upload but we're at budget.
|
|
|
|
|
|
// Push a new pending spawn so it's retried next frame.
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(result.guid);
|
|
|
|
|
|
PendingCreatureSpawn s{};
|
|
|
|
|
|
s.guid = result.guid;
|
|
|
|
|
|
s.displayId = result.displayId;
|
|
|
|
|
|
s.x = result.x; s.y = result.y; s.z = result.z;
|
|
|
|
|
|
s.orientation = result.orientation;
|
|
|
|
|
|
s.scale = result.scale;
|
|
|
|
|
|
pendingCreatureSpawns_.push_back(s);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.insert(result.guid);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Model parsed on background thread — upload to GPU on main thread.
|
|
|
|
|
|
auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr;
|
|
|
|
|
|
if (!charRenderer) {
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Count upload attempts toward the frame budget even if upload fails.
|
|
|
|
|
|
// Otherwise repeated failures can consume an unbounded amount of frame time.
|
|
|
|
|
|
modelUploads++;
|
|
|
|
|
|
|
|
|
|
|
|
// Upload model to GPU (must happen on main thread)
|
|
|
|
|
|
// Use pre-decoded BLP cache to skip main-thread texture decode
|
|
|
|
|
|
auto uploadStart = std::chrono::steady_clock::now();
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(&result.predecodedTextures);
|
|
|
|
|
|
if (!charRenderer->loadModel(*result.model, result.modelId)) {
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(nullptr);
|
|
|
|
|
|
nonRenderableCreatureDisplayIds_.insert(result.displayId);
|
|
|
|
|
|
creaturePermanentFailureGuids_.insert(result.guid);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(result.guid);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(nullptr);
|
|
|
|
|
|
{
|
|
|
|
|
|
auto uploadEnd = std::chrono::steady_clock::now();
|
|
|
|
|
|
float uploadMs = std::chrono::duration<float, std::milli>(uploadEnd - uploadStart).count();
|
|
|
|
|
|
if (uploadMs > 100.0f) {
|
|
|
|
|
|
LOG_WARNING("charRenderer->loadModel took ", uploadMs, "ms displayId=", result.displayId,
|
|
|
|
|
|
" preDecoded=", result.predecodedTextures.size());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Save remaining pre-decoded textures (display skins) for spawnOnlineCreature
|
|
|
|
|
|
if (!result.predecodedTextures.empty()) {
|
|
|
|
|
|
displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures);
|
|
|
|
|
|
}
|
|
|
|
|
|
displayIdModelCache_[result.displayId] = result.modelId;
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(result.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(result.guid);
|
|
|
|
|
|
|
|
|
|
|
|
// Re-queue as a normal pending spawn — model is now cached, so sync spawn is fast
|
|
|
|
|
|
// (only creates instance + applies textures, no file I/O).
|
|
|
|
|
|
if (!creatureInstances_.count(result.guid) &&
|
|
|
|
|
|
!creaturePermanentFailureGuids_.count(result.guid)) {
|
|
|
|
|
|
PendingCreatureSpawn s{};
|
|
|
|
|
|
s.guid = result.guid;
|
|
|
|
|
|
s.displayId = result.displayId;
|
|
|
|
|
|
s.x = result.x;
|
|
|
|
|
|
s.y = result.y;
|
|
|
|
|
|
s.z = result.z;
|
|
|
|
|
|
s.orientation = result.orientation;
|
|
|
|
|
|
s.scale = result.scale;
|
|
|
|
|
|
pendingCreatureSpawns_.push_back(s);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.insert(result.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processAsyncNpcCompositeResults(bool unlimited) {
|
|
|
|
|
|
auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr;
|
|
|
|
|
|
if (!charRenderer) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing
|
|
|
|
|
|
// simultaneously. In unlimited mode (load screen), process everything without cap.
|
|
|
|
|
|
static constexpr float kCompositeBudgetMs = 2.0f;
|
|
|
|
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
|
|
|
|
|
|
for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) {
|
|
|
|
|
|
if (!unlimited) {
|
|
|
|
|
|
float elapsed = std::chrono::duration<float, std::milli>(
|
|
|
|
|
|
std::chrono::steady_clock::now() - startTime).count();
|
|
|
|
|
|
if (elapsed >= kCompositeBudgetMs) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!it->future.valid() ||
|
|
|
|
|
|
it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
auto result = it->future.get();
|
|
|
|
|
|
it = asyncNpcCompositeLoads_.erase(it);
|
|
|
|
|
|
|
|
|
|
|
|
const auto& info = result.info;
|
|
|
|
|
|
|
|
|
|
|
|
// Set pre-decoded cache so texture loads skip synchronous BLP decode
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(&result.predecodedTextures);
|
|
|
|
|
|
|
|
|
|
|
|
// --- Apply skin to type-1 slots ---
|
|
|
|
|
|
rendering::VkTexture* skinTex = nullptr;
|
|
|
|
|
|
|
|
|
|
|
|
if (info.hasBakedSkin) {
|
|
|
|
|
|
// Baked skin: load from pre-decoded cache
|
|
|
|
|
|
skinTex = charRenderer->loadTexture(info.bakedSkinPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (info.hasComposite) {
|
|
|
|
|
|
// Composite with face/underwear/equipment regions on top of base skin
|
|
|
|
|
|
rendering::VkTexture* compositeTex = nullptr;
|
|
|
|
|
|
if (!info.regionLayers.empty()) {
|
|
|
|
|
|
compositeTex = charRenderer->compositeWithRegions(info.basePath,
|
|
|
|
|
|
info.overlayPaths, info.regionLayers);
|
|
|
|
|
|
} else if (!info.overlayPaths.empty()) {
|
|
|
|
|
|
std::vector<std::string> skinLayers;
|
|
|
|
|
|
skinLayers.push_back(info.basePath);
|
|
|
|
|
|
for (const auto& op : info.overlayPaths) skinLayers.push_back(op);
|
|
|
|
|
|
compositeTex = charRenderer->compositeTextures(skinLayers);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (compositeTex) skinTex = compositeTex;
|
|
|
|
|
|
} else if (info.hasSimpleSkin) {
|
|
|
|
|
|
// Simple skin: just base texture, no compositing
|
|
|
|
|
|
auto* baseTex = charRenderer->loadTexture(info.basePath);
|
|
|
|
|
|
if (baseTex) skinTex = baseTex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (skinTex) {
|
|
|
|
|
|
for (uint32_t slot : info.skinTextureSlots) {
|
|
|
|
|
|
charRenderer->setModelTexture(info.modelId, slot, skinTex);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Apply hair texture to type-6 slots ---
|
|
|
|
|
|
if (!info.hairTexturePath.empty()) {
|
|
|
|
|
|
rendering::VkTexture* hairTex = charRenderer->loadTexture(info.hairTexturePath);
|
|
|
|
|
|
rendering::VkTexture* whTex = charRenderer->loadTexture("");
|
|
|
|
|
|
if (hairTex && hairTex != whTex) {
|
|
|
|
|
|
for (uint32_t slot : info.hairTextureSlots) {
|
|
|
|
|
|
charRenderer->setModelTexture(info.modelId, slot, hairTex);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (info.useBakedForHair && skinTex) {
|
|
|
|
|
|
// Bald NPC: use skin/baked texture for scalp cap
|
|
|
|
|
|
for (uint32_t slot : info.hairTextureSlots) {
|
|
|
|
|
|
charRenderer->setModelTexture(info.modelId, slot, skinTex);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(nullptr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processCreatureSpawnQueue(bool unlimited) {
|
|
|
|
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
// Budget: max 2ms per frame for creature spawning to prevent stutter.
|
|
|
|
|
|
// In unlimited mode (load screen), process everything without budget cap.
|
|
|
|
|
|
static constexpr float kSpawnBudgetMs = 2.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// First, finalize any async model loads that completed on background threads.
|
|
|
|
|
|
processAsyncCreatureResults(unlimited);
|
|
|
|
|
|
{
|
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
|
float asyncMs = std::chrono::duration<float, std::milli>(now - startTime).count();
|
|
|
|
|
|
if (asyncMs > 100.0f) {
|
|
|
|
|
|
LOG_WARNING("processAsyncCreatureResults took ", asyncMs, "ms");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (pendingCreatureSpawns_.empty()) return;
|
|
|
|
|
|
if (!creatureLookupsBuilt_) {
|
|
|
|
|
|
buildCreatureDisplayLookups();
|
|
|
|
|
|
if (!creatureLookupsBuilt_) return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int processed = 0;
|
|
|
|
|
|
int asyncLaunched = 0;
|
|
|
|
|
|
size_t rotationsLeft = pendingCreatureSpawns_.size();
|
|
|
|
|
|
while (!pendingCreatureSpawns_.empty() &&
|
|
|
|
|
|
(unlimited || processed < MAX_SPAWNS_PER_FRAME) &&
|
|
|
|
|
|
rotationsLeft > 0) {
|
|
|
|
|
|
// Check time budget every iteration (including first — async results may
|
|
|
|
|
|
// have already consumed the budget via GPU model uploads).
|
|
|
|
|
|
if (!unlimited) {
|
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
|
float elapsedMs = std::chrono::duration<float, std::milli>(now - startTime).count();
|
|
|
|
|
|
if (elapsedMs >= kSpawnBudgetMs) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PendingCreatureSpawn s = pendingCreatureSpawns_.front();
|
|
|
|
|
|
pendingCreatureSpawns_.pop_front();
|
|
|
|
|
|
|
|
|
|
|
|
if (nonRenderableCreatureDisplayIds_.count(s.displayId)) {
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(s.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(s.guid);
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
rotationsLeft = pendingCreatureSpawns_.size();
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end());
|
|
|
|
|
|
|
|
|
|
|
|
// For new models: launch async load on background thread instead of blocking.
|
|
|
|
|
|
if (needsNewModel) {
|
|
|
|
|
|
// Keep exactly one background load per displayId. Additional spawns for
|
|
|
|
|
|
// the same displayId stay queued and will spawn once cache is populated.
|
|
|
|
|
|
if (asyncCreatureDisplayLoads_.count(s.displayId)) {
|
|
|
|
|
|
pendingCreatureSpawns_.push_back(s);
|
|
|
|
|
|
rotationsLeft--;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS;
|
|
|
|
|
|
if (static_cast<int>(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) {
|
|
|
|
|
|
// Too many in-flight — defer to next frame
|
|
|
|
|
|
pendingCreatureSpawns_.push_back(s);
|
|
|
|
|
|
rotationsLeft--;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string m2Path = getModelPathForDisplayId(s.displayId);
|
|
|
|
|
|
if (m2Path.empty()) {
|
|
|
|
|
|
nonRenderableCreatureDisplayIds_.insert(s.displayId);
|
|
|
|
|
|
creaturePermanentFailureGuids_.insert(s.guid);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(s.guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(s.guid);
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
rotationsLeft = pendingCreatureSpawns_.size();
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for invisible stalkers
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string lowerPath = m2Path;
|
|
|
|
|
|
std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (lowerPath.find("invisiblestalker") != std::string::npos ||
|
|
|
|
|
|
lowerPath.find("invisible_stalker") != std::string::npos) {
|
|
|
|
|
|
nonRenderableCreatureDisplayIds_.insert(s.displayId);
|
|
|
|
|
|
creaturePermanentFailureGuids_.insert(s.guid);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(s.guid);
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
rotationsLeft = pendingCreatureSpawns_.size();
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Launch async M2 load — file I/O and parsing happen off the main thread.
|
|
|
|
|
|
uint32_t modelId = nextCreatureModelId_++;
|
|
|
|
|
|
auto* am = assetManager_;
|
|
|
|
|
|
|
|
|
|
|
|
// Collect display skin texture paths for background pre-decode
|
|
|
|
|
|
std::vector<std::string> displaySkinPaths;
|
|
|
|
|
|
{
|
|
|
|
|
|
auto itDD = displayDataMap_.find(s.displayId);
|
|
|
|
|
|
if (itDD != displayDataMap_.end()) {
|
|
|
|
|
|
std::string modelDir;
|
|
|
|
|
|
size_t lastSlash = m2Path.find_last_of("\\/");
|
|
|
|
|
|
if (lastSlash != std::string::npos) modelDir = m2Path.substr(0, lastSlash + 1);
|
|
|
|
|
|
|
|
|
|
|
|
auto resolveForAsync = [&](const std::string& skinField) {
|
|
|
|
|
|
if (skinField.empty()) return;
|
|
|
|
|
|
std::string raw = skinField;
|
|
|
|
|
|
std::replace(raw.begin(), raw.end(), '/', '\\');
|
|
|
|
|
|
while (!raw.empty() && std::isspace(static_cast<unsigned char>(raw.front()))) raw.erase(raw.begin());
|
|
|
|
|
|
while (!raw.empty() && std::isspace(static_cast<unsigned char>(raw.back()))) raw.pop_back();
|
|
|
|
|
|
if (raw.empty()) return;
|
|
|
|
|
|
bool hasExt = raw.size() >= 4 && raw.substr(raw.size()-4) == ".blp";
|
|
|
|
|
|
bool hasDir = raw.find('\\') != std::string::npos;
|
|
|
|
|
|
std::vector<std::string> candidates;
|
|
|
|
|
|
if (hasDir) {
|
|
|
|
|
|
candidates.push_back(raw);
|
|
|
|
|
|
if (!hasExt) candidates.push_back(raw + ".blp");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
candidates.push_back(modelDir + raw);
|
|
|
|
|
|
if (!hasExt) candidates.push_back(modelDir + raw + ".blp");
|
|
|
|
|
|
candidates.push_back(raw);
|
|
|
|
|
|
if (!hasExt) candidates.push_back(raw + ".blp");
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const auto& c : candidates) {
|
|
|
|
|
|
if (am->fileExists(c)) { displaySkinPaths.push_back(c); return; }
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
resolveForAsync(itDD->second.skin1);
|
|
|
|
|
|
resolveForAsync(itDD->second.skin2);
|
|
|
|
|
|
resolveForAsync(itDD->second.skin3);
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-decode humanoid NPC textures (bake, skin, face, underwear, hair, equipment)
|
|
|
|
|
|
if (itDD->second.extraDisplayId != 0) {
|
|
|
|
|
|
auto itHE = humanoidExtraMap_.find(itDD->second.extraDisplayId);
|
|
|
|
|
|
if (itHE != humanoidExtraMap_.end()) {
|
|
|
|
|
|
const auto& he = itHE->second;
|
|
|
|
|
|
// Baked texture
|
|
|
|
|
|
if (!he.bakeName.empty()) {
|
|
|
|
|
|
displaySkinPaths.push_back("Textures\\BakedNpcTextures\\" + he.bakeName);
|
|
|
|
|
|
}
|
|
|
|
|
|
// CharSections: skin, face, underwear
|
|
|
|
|
|
auto csDbc = am->loadDBC("CharSections.dbc");
|
|
|
|
|
|
if (csDbc) {
|
|
|
|
|
|
const auto* csL = pipeline::getActiveDBCLayout()
|
|
|
|
|
|
? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr;
|
|
|
|
|
|
auto csF = pipeline::detectCharSectionsFields(csDbc.get(), csL);
|
|
|
|
|
|
uint32_t nRace = static_cast<uint32_t>(he.raceId);
|
|
|
|
|
|
uint32_t nSex = static_cast<uint32_t>(he.sexId);
|
|
|
|
|
|
uint32_t nSkin = static_cast<uint32_t>(he.skinId);
|
|
|
|
|
|
uint32_t nFace = static_cast<uint32_t>(he.faceId);
|
|
|
|
|
|
for (uint32_t r = 0; r < csDbc->getRecordCount(); r++) {
|
|
|
|
|
|
uint32_t rId = csDbc->getUInt32(r, csF.raceId);
|
|
|
|
|
|
uint32_t sId = csDbc->getUInt32(r, csF.sexId);
|
|
|
|
|
|
if (rId != nRace || sId != nSex) continue;
|
|
|
|
|
|
uint32_t section = csDbc->getUInt32(r, csF.baseSection);
|
|
|
|
|
|
uint32_t variation = csDbc->getUInt32(r, csF.variationIndex);
|
|
|
|
|
|
uint32_t color = csDbc->getUInt32(r, csF.colorIndex);
|
|
|
|
|
|
if (section == 0 && color == nSkin) {
|
|
|
|
|
|
std::string t = csDbc->getString(r, csF.texture1);
|
|
|
|
|
|
if (!t.empty()) displaySkinPaths.push_back(t);
|
|
|
|
|
|
} else if (section == 1 && variation == nFace && color == nSkin) {
|
|
|
|
|
|
std::string t1 = csDbc->getString(r, csF.texture1);
|
|
|
|
|
|
std::string t2 = csDbc->getString(r, csF.texture2);
|
|
|
|
|
|
if (!t1.empty()) displaySkinPaths.push_back(t1);
|
|
|
|
|
|
if (!t2.empty()) displaySkinPaths.push_back(t2);
|
|
|
|
|
|
} else if (section == 3 && variation == static_cast<uint32_t>(he.hairStyleId)
|
|
|
|
|
|
&& color == static_cast<uint32_t>(he.hairColorId)) {
|
|
|
|
|
|
std::string t = csDbc->getString(r, csF.texture1);
|
|
|
|
|
|
if (!t.empty()) displaySkinPaths.push_back(t);
|
|
|
|
|
|
} else if (section == 4 && color == nSkin) {
|
|
|
|
|
|
for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) {
|
|
|
|
|
|
std::string t = csDbc->getString(r, f);
|
|
|
|
|
|
if (!t.empty()) displaySkinPaths.push_back(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Equipment region textures
|
|
|
|
|
|
auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc");
|
|
|
|
|
|
if (idiDbc) {
|
|
|
|
|
|
static constexpr const char* compDirs[] = {
|
|
|
|
|
|
"ArmUpperTexture", "ArmLowerTexture", "HandTexture",
|
|
|
|
|
|
"TorsoUpperTexture", "TorsoLowerTexture",
|
|
|
|
|
|
"LegUpperTexture", "LegLowerTexture", "FootTexture",
|
|
|
|
|
|
};
|
|
|
|
|
|
const auto* idiL = pipeline::getActiveDBCLayout()
|
|
|
|
|
|
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
|
|
|
|
|
const uint32_t trf[8] = {
|
|
|
|
|
|
idiL ? (*idiL)["TextureArmUpper"] : 14u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureArmLower"] : 15u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureHand"] : 16u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureTorsoUpper"]: 17u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureTorsoLower"]: 18u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureLegUpper"] : 19u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureLegLower"] : 20u,
|
|
|
|
|
|
idiL ? (*idiL)["TextureFoot"] : 21u,
|
|
|
|
|
|
};
|
|
|
|
|
|
const bool isFem = (he.sexId == 1);
|
|
|
|
|
|
for (int eq = 0; eq < 11; eq++) {
|
|
|
|
|
|
uint32_t did = he.equipDisplayId[eq];
|
|
|
|
|
|
if (did == 0) continue;
|
|
|
|
|
|
int32_t recIdx = idiDbc->findRecordById(did);
|
|
|
|
|
|
if (recIdx < 0) continue;
|
|
|
|
|
|
for (int region = 0; region < 8; region++) {
|
|
|
|
|
|
std::string texName = idiDbc->getString(static_cast<uint32_t>(recIdx), trf[region]);
|
|
|
|
|
|
if (texName.empty()) continue;
|
|
|
|
|
|
std::string base = "Item\\TextureComponents\\" +
|
|
|
|
|
|
std::string(compDirs[region]) + "\\" + texName;
|
|
|
|
|
|
std::string gp = base + (isFem ? "_F.blp" : "_M.blp");
|
|
|
|
|
|
std::string up = base + "_U.blp";
|
2026-04-14 04:20:28 -07:00
|
|
|
|
std::string bp = base + ".blp";
|
2026-04-05 19:30:44 +03:00
|
|
|
|
if (am->fileExists(gp)) displaySkinPaths.push_back(gp);
|
|
|
|
|
|
else if (am->fileExists(up)) displaySkinPaths.push_back(up);
|
2026-04-14 04:20:28 -07:00
|
|
|
|
else if (am->fileExists(bp)) displaySkinPaths.push_back(bp);
|
2026-04-05 19:30:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AsyncCreatureLoad load;
|
|
|
|
|
|
load.future = std::async(std::launch::async,
|
|
|
|
|
|
[am, m2Path, modelId, s, skinPaths = std::move(displaySkinPaths)]() -> PreparedCreatureModel {
|
|
|
|
|
|
PreparedCreatureModel result;
|
|
|
|
|
|
result.guid = s.guid;
|
|
|
|
|
|
result.displayId = s.displayId;
|
|
|
|
|
|
result.modelId = modelId;
|
|
|
|
|
|
result.x = s.x;
|
|
|
|
|
|
result.y = s.y;
|
|
|
|
|
|
result.z = s.z;
|
|
|
|
|
|
result.orientation = s.orientation;
|
|
|
|
|
|
result.scale = s.scale;
|
|
|
|
|
|
|
|
|
|
|
|
auto m2Data = am->readFile(m2Path);
|
|
|
|
|
|
if (m2Data.empty()) {
|
|
|
|
|
|
result.permanent_failure = true;
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto model = std::make_shared<pipeline::M2Model>(pipeline::M2Loader::load(m2Data));
|
|
|
|
|
|
if (model->vertices.empty()) {
|
|
|
|
|
|
result.permanent_failure = true;
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load skin file
|
|
|
|
|
|
if (model->version >= 264) {
|
|
|
|
|
|
std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin";
|
|
|
|
|
|
auto skinData = am->readFile(skinPath);
|
|
|
|
|
|
if (!skinData.empty()) {
|
|
|
|
|
|
pipeline::M2Loader::loadSkin(skinData, *model);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load external .anim files
|
|
|
|
|
|
std::string basePath = m2Path.substr(0, m2Path.size() - 3);
|
|
|
|
|
|
for (uint32_t si = 0; si < model->sequences.size(); si++) {
|
|
|
|
|
|
if (!(model->sequences[si].flags & 0x20)) {
|
|
|
|
|
|
char animFileName[256];
|
|
|
|
|
|
snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim",
|
|
|
|
|
|
basePath.c_str(), model->sequences[si].id, model->sequences[si].variationIndex);
|
|
|
|
|
|
auto animData = am->readFileOptional(animFileName);
|
|
|
|
|
|
if (!animData.empty()) {
|
|
|
|
|
|
pipeline::M2Loader::loadAnimFile(m2Data, animData, si, *model);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-decode model textures on background thread
|
|
|
|
|
|
for (const auto& tex : model->textures) {
|
|
|
|
|
|
if (tex.filename.empty()) continue;
|
|
|
|
|
|
std::string texKey = tex.filename;
|
|
|
|
|
|
std::replace(texKey.begin(), texKey.end(), '/', '\\');
|
|
|
|
|
|
std::transform(texKey.begin(), texKey.end(), texKey.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue;
|
|
|
|
|
|
auto blp = am->loadTexture(texKey);
|
|
|
|
|
|
if (blp.isValid()) {
|
|
|
|
|
|
result.predecodedTextures[texKey] = std::move(blp);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-decode display skin textures (skin1/skin2/skin3 from CreatureDisplayInfo)
|
|
|
|
|
|
for (const auto& sp : skinPaths) {
|
|
|
|
|
|
std::string key = sp;
|
|
|
|
|
|
std::replace(key.begin(), key.end(), '/', '\\');
|
|
|
|
|
|
std::transform(key.begin(), key.end(), key.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (result.predecodedTextures.count(key)) continue;
|
|
|
|
|
|
auto blp = am->loadTexture(key);
|
|
|
|
|
|
if (blp.isValid()) {
|
|
|
|
|
|
result.predecodedTextures[key] = std::move(blp);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.model = std::move(model);
|
|
|
|
|
|
result.valid = true;
|
|
|
|
|
|
return result;
|
|
|
|
|
|
});
|
|
|
|
|
|
asyncCreatureLoads_.push_back(std::move(load));
|
|
|
|
|
|
asyncCreatureDisplayLoads_.insert(s.displayId);
|
|
|
|
|
|
asyncLaunched++;
|
|
|
|
|
|
// Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it
|
|
|
|
|
|
rotationsLeft = pendingCreatureSpawns_.size();
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cached model — spawn is fast (no file I/O, just instance creation + texture setup)
|
|
|
|
|
|
{
|
|
|
|
|
|
auto spawnStart = std::chrono::steady_clock::now();
|
|
|
|
|
|
spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale);
|
|
|
|
|
|
auto spawnEnd = std::chrono::steady_clock::now();
|
|
|
|
|
|
float spawnMs = std::chrono::duration<float, std::milli>(spawnEnd - spawnStart).count();
|
|
|
|
|
|
if (spawnMs > 100.0f) {
|
|
|
|
|
|
LOG_WARNING("spawnOnlineCreature took ", spawnMs, "ms displayId=", s.displayId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(s.guid);
|
|
|
|
|
|
|
|
|
|
|
|
// If spawn still failed, retry for a limited number of frames.
|
|
|
|
|
|
if (!creatureInstances_.count(s.guid)) {
|
|
|
|
|
|
if (creaturePermanentFailureGuids_.erase(s.guid) > 0) {
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(s.guid);
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint16_t retries = 0;
|
|
|
|
|
|
auto it = creatureSpawnRetryCounts_.find(s.guid);
|
|
|
|
|
|
if (it != creatureSpawnRetryCounts_.end()) {
|
|
|
|
|
|
retries = it->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (retries < MAX_CREATURE_SPAWN_RETRIES) {
|
|
|
|
|
|
creatureSpawnRetryCounts_[s.guid] = static_cast<uint16_t>(retries + 1);
|
|
|
|
|
|
pendingCreatureSpawns_.push_back(s);
|
|
|
|
|
|
pendingCreatureSpawnGuids_.insert(s.guid);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(s.guid);
|
|
|
|
|
|
LOG_WARNING("Dropping creature spawn after retries: guid=0x", std::hex, s.guid, std::dec,
|
|
|
|
|
|
" displayId=", s.displayId);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(s.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
rotationsLeft = pendingCreatureSpawns_.size();
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processPlayerSpawnQueue() {
|
|
|
|
|
|
if (pendingPlayerSpawns_.empty()) return;
|
|
|
|
|
|
if (!assetManager_ || !assetManager_->isInitialized()) return;
|
|
|
|
|
|
|
|
|
|
|
|
int processed = 0;
|
|
|
|
|
|
while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) {
|
|
|
|
|
|
PendingPlayerSpawn s = pendingPlayerSpawns_.front();
|
|
|
|
|
|
pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin());
|
|
|
|
|
|
pendingPlayerSpawnGuids_.erase(s.guid);
|
|
|
|
|
|
|
|
|
|
|
|
// Skip if already spawned (could have been spawned by a previous update this frame)
|
|
|
|
|
|
if (playerInstances_.count(s.guid)) {
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation);
|
|
|
|
|
|
// Apply any equipment updates that arrived before the player was spawned.
|
|
|
|
|
|
auto pit = pendingOnlinePlayerEquipment_.find(s.guid);
|
|
|
|
|
|
if (pit != pendingOnlinePlayerEquipment_.end()) {
|
|
|
|
|
|
deferredEquipmentQueue_.push_back({s.guid, pit->second});
|
|
|
|
|
|
pendingOnlinePlayerEquipment_.erase(pit);
|
|
|
|
|
|
}
|
|
|
|
|
|
processed++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<std::string> EntitySpawner::resolveEquipmentTexturePaths(uint64_t guid,
|
|
|
|
|
|
const std::array<uint32_t, 19>& displayInfoIds,
|
|
|
|
|
|
const std::array<uint8_t, 19>& /*inventoryTypes*/) const {
|
|
|
|
|
|
std::vector<std::string> paths;
|
|
|
|
|
|
|
|
|
|
|
|
auto it = onlinePlayerAppearance_.find(guid);
|
|
|
|
|
|
if (it == onlinePlayerAppearance_.end()) return paths;
|
|
|
|
|
|
const OnlinePlayerAppearanceState& st = it->second;
|
|
|
|
|
|
|
|
|
|
|
|
// Add base skin + underwear paths
|
|
|
|
|
|
if (!st.bodySkinPath.empty()) paths.push_back(st.bodySkinPath);
|
|
|
|
|
|
for (const auto& up : st.underwearPaths) {
|
|
|
|
|
|
if (!up.empty()) paths.push_back(up);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve equipment region texture paths (same logic as setOnlinePlayerEquipment)
|
|
|
|
|
|
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
|
|
|
|
|
|
if (!displayInfoDbc) return paths;
|
|
|
|
|
|
const auto* idiL = pipeline::getActiveDBCLayout()
|
|
|
|
|
|
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
|
|
|
|
|
|
|
|
|
|
|
static constexpr const char* componentDirs[] = {
|
|
|
|
|
|
"ArmUpperTexture", "ArmLowerTexture", "HandTexture",
|
|
|
|
|
|
"TorsoUpperTexture", "TorsoLowerTexture",
|
|
|
|
|
|
"LegUpperTexture", "LegLowerTexture", "FootTexture",
|
|
|
|
|
|
};
|
|
|
|
|
|
uint32_t texRegionFields[8];
|
|
|
|
|
|
pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields);
|
|
|
|
|
|
const bool isFemale = (st.genderId == 1);
|
|
|
|
|
|
|
|
|
|
|
|
for (int s = 0; s < 19; s++) {
|
|
|
|
|
|
uint32_t did = displayInfoIds[s];
|
|
|
|
|
|
if (did == 0) continue;
|
|
|
|
|
|
int32_t recIdx = displayInfoDbc->findRecordById(did);
|
|
|
|
|
|
if (recIdx < 0) continue;
|
|
|
|
|
|
for (int region = 0; region < 8; region++) {
|
|
|
|
|
|
std::string texName = displayInfoDbc->getString(
|
|
|
|
|
|
static_cast<uint32_t>(recIdx), texRegionFields[region]);
|
|
|
|
|
|
if (texName.empty()) continue;
|
|
|
|
|
|
std::string base = "Item\\TextureComponents\\" +
|
|
|
|
|
|
std::string(componentDirs[region]) + "\\" + texName;
|
|
|
|
|
|
std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp");
|
|
|
|
|
|
std::string unisexPath = base + "_U.blp";
|
2026-04-14 04:20:28 -07:00
|
|
|
|
std::string basePath = base + ".blp";
|
2026-04-05 19:30:44 +03:00
|
|
|
|
if (assetManager_->fileExists(genderPath)) paths.push_back(genderPath);
|
|
|
|
|
|
else if (assetManager_->fileExists(unisexPath)) paths.push_back(unisexPath);
|
2026-04-14 04:20:28 -07:00
|
|
|
|
else if (assetManager_->fileExists(basePath)) paths.push_back(basePath);
|
2026-04-05 19:30:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return paths;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processAsyncEquipmentResults() {
|
|
|
|
|
|
for (auto it = asyncEquipmentLoads_.begin(); it != asyncEquipmentLoads_.end(); ) {
|
|
|
|
|
|
if (!it->future.valid() ||
|
|
|
|
|
|
it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
auto result = it->future.get();
|
|
|
|
|
|
it = asyncEquipmentLoads_.erase(it);
|
|
|
|
|
|
|
|
|
|
|
|
auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr;
|
|
|
|
|
|
if (!charRenderer) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Set pre-decoded cache so compositeWithRegions skips synchronous BLP decode
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(&result.predecodedTextures);
|
|
|
|
|
|
setOnlinePlayerEquipment(result.guid, result.displayInfoIds, result.inventoryTypes);
|
|
|
|
|
|
charRenderer->setPredecodedBLPCache(nullptr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processDeferredEquipmentQueue() {
|
|
|
|
|
|
// First, finalize any completed async pre-decodes
|
|
|
|
|
|
processAsyncEquipmentResults();
|
|
|
|
|
|
|
|
|
|
|
|
if (deferredEquipmentQueue_.empty()) return;
|
|
|
|
|
|
// Limit in-flight async equipment loads
|
|
|
|
|
|
if (asyncEquipmentLoads_.size() >= 2) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto [guid, equipData] = deferredEquipmentQueue_.front();
|
|
|
|
|
|
deferredEquipmentQueue_.erase(deferredEquipmentQueue_.begin());
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve all texture paths that compositeWithRegions will need
|
|
|
|
|
|
auto texturePaths = resolveEquipmentTexturePaths(guid, equipData.first, equipData.second);
|
|
|
|
|
|
|
|
|
|
|
|
if (texturePaths.empty()) {
|
|
|
|
|
|
// No textures to pre-decode — just apply directly (fast path)
|
|
|
|
|
|
LOG_WARNING("Equipment fast path for guid=0x", std::hex, guid, std::dec,
|
|
|
|
|
|
" (no textures to pre-decode)");
|
|
|
|
|
|
setOnlinePlayerEquipment(guid, equipData.first, equipData.second);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-05 20:12:17 -07:00
|
|
|
|
LOG_DEBUG("Equipment async pre-decode for guid=0x", std::hex, guid, std::dec,
|
2026-04-05 19:30:44 +03:00
|
|
|
|
" textures=", texturePaths.size());
|
|
|
|
|
|
|
|
|
|
|
|
// Launch background BLP pre-decode
|
|
|
|
|
|
auto* am = assetManager_;
|
|
|
|
|
|
auto displayInfoIds = equipData.first;
|
|
|
|
|
|
auto inventoryTypes = equipData.second;
|
|
|
|
|
|
AsyncEquipmentLoad load;
|
|
|
|
|
|
load.future = std::async(std::launch::async,
|
|
|
|
|
|
[am, guid, displayInfoIds, inventoryTypes, paths = std::move(texturePaths)]() -> PreparedEquipmentUpdate {
|
|
|
|
|
|
PreparedEquipmentUpdate result;
|
|
|
|
|
|
result.guid = guid;
|
|
|
|
|
|
result.displayInfoIds = displayInfoIds;
|
|
|
|
|
|
result.inventoryTypes = inventoryTypes;
|
|
|
|
|
|
for (const auto& path : paths) {
|
|
|
|
|
|
std::string key = path;
|
|
|
|
|
|
std::replace(key.begin(), key.end(), '/', '\\');
|
|
|
|
|
|
std::transform(key.begin(), key.end(), key.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (result.predecodedTextures.count(key)) continue;
|
|
|
|
|
|
auto blp = am->loadTexture(key);
|
|
|
|
|
|
if (blp.isValid()) {
|
|
|
|
|
|
result.predecodedTextures[key] = std::move(blp);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
});
|
|
|
|
|
|
asyncEquipmentLoads_.push_back(std::move(load));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processAsyncGameObjectResults() {
|
|
|
|
|
|
for (auto it = asyncGameObjectLoads_.begin(); it != asyncGameObjectLoads_.end(); ) {
|
|
|
|
|
|
if (!it->future.valid() ||
|
|
|
|
|
|
it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto result = it->future.get();
|
|
|
|
|
|
it = asyncGameObjectLoads_.erase(it);
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.valid || !result.isWmo || !result.wmoModel) {
|
|
|
|
|
|
// Fallback: spawn via sync path (likely an M2 or failed WMO)
|
|
|
|
|
|
spawnOnlineGameObject(result.guid, result.entry, result.displayId,
|
|
|
|
|
|
result.x, result.y, result.z, result.orientation, result.scale);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// WMO parsed on background thread — do GPU upload + instance creation on main thread
|
|
|
|
|
|
auto* wmoRenderer = renderer_ ? renderer_->getWMORenderer() : nullptr;
|
|
|
|
|
|
if (!wmoRenderer) continue;
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t modelId = 0;
|
|
|
|
|
|
auto itCache = gameObjectDisplayIdWmoCache_.find(result.displayId);
|
|
|
|
|
|
if (itCache != gameObjectDisplayIdWmoCache_.end()) {
|
|
|
|
|
|
modelId = itCache->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
modelId = nextGameObjectWmoModelId_++;
|
|
|
|
|
|
wmoRenderer->setPredecodedBLPCache(&result.predecodedTextures);
|
|
|
|
|
|
if (!wmoRenderer->loadModel(*result.wmoModel, modelId)) {
|
|
|
|
|
|
wmoRenderer->setPredecodedBLPCache(nullptr);
|
|
|
|
|
|
LOG_WARNING("Failed to load async gameobject WMO: ", result.modelPath);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
wmoRenderer->setPredecodedBLPCache(nullptr);
|
|
|
|
|
|
gameObjectDisplayIdWmoCache_[result.displayId] = modelId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(
|
|
|
|
|
|
glm::vec3(result.x, result.y, result.z));
|
|
|
|
|
|
uint32_t instanceId = wmoRenderer->createInstance(
|
|
|
|
|
|
modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale);
|
|
|
|
|
|
if (instanceId == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
gameObjectInstances_[result.guid] = {modelId, instanceId, true};
|
|
|
|
|
|
|
|
|
|
|
|
// Queue transport doodad loading if applicable
|
|
|
|
|
|
std::string lowerPath = result.modelPath;
|
|
|
|
|
|
std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (lowerPath.find("transport") != std::string::npos) {
|
|
|
|
|
|
const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId);
|
|
|
|
|
|
if (doodadTemplates && !doodadTemplates->empty()) {
|
|
|
|
|
|
PendingTransportDoodadBatch batch;
|
|
|
|
|
|
batch.guid = result.guid;
|
|
|
|
|
|
batch.modelId = modelId;
|
|
|
|
|
|
batch.instanceId = instanceId;
|
|
|
|
|
|
batch.x = result.x;
|
|
|
|
|
|
batch.y = result.y;
|
|
|
|
|
|
batch.z = result.z;
|
|
|
|
|
|
batch.orientation = result.orientation;
|
|
|
|
|
|
batch.doodadBudget = doodadTemplates->size();
|
|
|
|
|
|
pendingTransportDoodadBatches_.push_back(batch);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processGameObjectSpawnQueue() {
|
|
|
|
|
|
// Finalize any completed async WMO loads first
|
|
|
|
|
|
processAsyncGameObjectResults();
|
|
|
|
|
|
|
|
|
|
|
|
if (pendingGameObjectSpawns_.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
static int goQueueLogCounter = 0;
|
|
|
|
|
|
if (++goQueueLogCounter % 60 == 1) {
|
|
|
|
|
|
LOG_DEBUG("GO queue: ", pendingGameObjectSpawns_.size(), " pending, ",
|
|
|
|
|
|
gameObjectInstances_.size(), " spawned, ",
|
|
|
|
|
|
gameObjectDisplayIdFailedCache_.size(), " failed");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Process spawns: cached WMOs and M2s go sync (cheap), uncached WMOs go async
|
|
|
|
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
static constexpr float kBudgetMs = 2.0f;
|
|
|
|
|
|
static constexpr int kMaxAsyncLoads = 2;
|
|
|
|
|
|
|
|
|
|
|
|
while (!pendingGameObjectSpawns_.empty()) {
|
|
|
|
|
|
float elapsedMs = std::chrono::duration<float, std::milli>(
|
|
|
|
|
|
std::chrono::steady_clock::now() - startTime).count();
|
|
|
|
|
|
if (elapsedMs >= kBudgetMs) break;
|
|
|
|
|
|
|
|
|
|
|
|
auto& s = pendingGameObjectSpawns_.front();
|
|
|
|
|
|
|
|
|
|
|
|
// Check if this is an uncached WMO that needs async loading
|
|
|
|
|
|
std::string modelPath;
|
|
|
|
|
|
if (gameObjectLookupsBuilt_) {
|
|
|
|
|
|
// Check transport overrides first
|
|
|
|
|
|
bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(s.guid);
|
|
|
|
|
|
if (isTransport) {
|
|
|
|
|
|
if (s.entry == 20808 || s.entry == 176231 || s.entry == 176310)
|
|
|
|
|
|
modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo";
|
|
|
|
|
|
else if (s.displayId == 807 || s.displayId == 808 || s.displayId == 175080 || s.displayId == 176495 || s.displayId == 164871)
|
|
|
|
|
|
modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo";
|
|
|
|
|
|
else if (s.displayId == 1587)
|
|
|
|
|
|
modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo";
|
|
|
|
|
|
else if (s.displayId == 2454 || s.displayId == 181688 || s.displayId == 190536)
|
|
|
|
|
|
modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (modelPath.empty())
|
|
|
|
|
|
modelPath = getGameObjectModelPathForDisplayId(s.displayId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string lowerPath = modelPath;
|
|
|
|
|
|
std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo";
|
|
|
|
|
|
bool isCached = isWmo && gameObjectDisplayIdWmoCache_.count(s.displayId);
|
|
|
|
|
|
|
|
|
|
|
|
if (isWmo && !isCached && !modelPath.empty() &&
|
|
|
|
|
|
static_cast<int>(asyncGameObjectLoads_.size()) < kMaxAsyncLoads) {
|
|
|
|
|
|
// Launch async WMO load — file I/O + parse on background thread
|
|
|
|
|
|
auto* am = assetManager_;
|
|
|
|
|
|
PendingGameObjectSpawn capture = s;
|
|
|
|
|
|
std::string capturePath = modelPath;
|
|
|
|
|
|
AsyncGameObjectLoad load;
|
|
|
|
|
|
load.future = std::async(std::launch::async,
|
|
|
|
|
|
[am, capture, capturePath]() -> PreparedGameObjectWMO {
|
|
|
|
|
|
PreparedGameObjectWMO result;
|
|
|
|
|
|
result.guid = capture.guid;
|
|
|
|
|
|
result.entry = capture.entry;
|
|
|
|
|
|
result.displayId = capture.displayId;
|
|
|
|
|
|
result.x = capture.x;
|
|
|
|
|
|
result.y = capture.y;
|
|
|
|
|
|
result.z = capture.z;
|
|
|
|
|
|
result.orientation = capture.orientation;
|
|
|
|
|
|
result.scale = capture.scale;
|
|
|
|
|
|
result.modelPath = capturePath;
|
|
|
|
|
|
result.isWmo = true;
|
|
|
|
|
|
|
|
|
|
|
|
auto wmoData = am->readFile(capturePath);
|
|
|
|
|
|
if (wmoData.empty()) return result;
|
|
|
|
|
|
|
|
|
|
|
|
auto wmo = std::make_shared<pipeline::WMOModel>(
|
|
|
|
|
|
pipeline::WMOLoader::load(wmoData));
|
|
|
|
|
|
|
|
|
|
|
|
// Load groups
|
|
|
|
|
|
if (wmo->nGroups > 0) {
|
|
|
|
|
|
std::string basePath = capturePath;
|
|
|
|
|
|
std::string ext;
|
|
|
|
|
|
if (basePath.size() > 4) {
|
|
|
|
|
|
ext = basePath.substr(basePath.size() - 4);
|
|
|
|
|
|
basePath = basePath.substr(0, basePath.size() - 4);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (uint32_t gi = 0; gi < wmo->nGroups; gi++) {
|
|
|
|
|
|
char suffix[16];
|
|
|
|
|
|
snprintf(suffix, sizeof(suffix), "_%03u%s", gi, ext.c_str());
|
|
|
|
|
|
auto groupData = am->readFile(basePath + suffix);
|
|
|
|
|
|
if (groupData.empty()) {
|
|
|
|
|
|
snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi);
|
|
|
|
|
|
groupData = am->readFile(basePath + suffix);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!groupData.empty()) {
|
|
|
|
|
|
pipeline::WMOLoader::loadGroup(groupData, *wmo, gi);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pre-decode WMO textures on background thread
|
|
|
|
|
|
for (const auto& texPath : wmo->textures) {
|
|
|
|
|
|
if (texPath.empty()) continue;
|
|
|
|
|
|
std::string texKey = texPath;
|
|
|
|
|
|
size_t nul = texKey.find('\0');
|
|
|
|
|
|
if (nul != std::string::npos) texKey.resize(nul);
|
|
|
|
|
|
std::replace(texKey.begin(), texKey.end(), '/', '\\');
|
|
|
|
|
|
std::transform(texKey.begin(), texKey.end(), texKey.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (texKey.empty()) continue;
|
|
|
|
|
|
// Convert to .blp extension
|
|
|
|
|
|
if (texKey.size() >= 4) {
|
|
|
|
|
|
std::string ext = texKey.substr(texKey.size() - 4);
|
|
|
|
|
|
if (ext == ".tga" || ext == ".dds") {
|
|
|
|
|
|
texKey = texKey.substr(0, texKey.size() - 4) + ".blp";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue;
|
|
|
|
|
|
auto blp = am->loadTexture(texKey);
|
|
|
|
|
|
if (blp.isValid()) {
|
|
|
|
|
|
result.predecodedTextures[texKey] = std::move(blp);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.wmoModel = wmo;
|
|
|
|
|
|
result.valid = true;
|
|
|
|
|
|
return result;
|
|
|
|
|
|
});
|
|
|
|
|
|
asyncGameObjectLoads_.push_back(std::move(load));
|
|
|
|
|
|
pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin());
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cached WMO or M2 — spawn synchronously (cheap)
|
|
|
|
|
|
spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale);
|
|
|
|
|
|
pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processPendingTransportRegistrations() {
|
|
|
|
|
|
if (pendingTransportRegistrations_.empty()) return;
|
|
|
|
|
|
if (!gameHandler_ || !renderer_) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto* transportManager = gameHandler_->getTransportManager();
|
|
|
|
|
|
if (!transportManager) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
static constexpr int kMaxRegistrationsPerFrame = 2;
|
|
|
|
|
|
static constexpr float kRegistrationBudgetMs = 2.0f;
|
|
|
|
|
|
int processed = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (auto it = pendingTransportRegistrations_.begin();
|
|
|
|
|
|
it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) {
|
|
|
|
|
|
float elapsedMs = std::chrono::duration<float, std::milli>(
|
|
|
|
|
|
std::chrono::steady_clock::now() - startTime).count();
|
|
|
|
|
|
if (elapsedMs >= kRegistrationBudgetMs) break;
|
|
|
|
|
|
|
|
|
|
|
|
const PendingTransportRegistration pending = *it;
|
|
|
|
|
|
auto goIt = gameObjectInstances_.find(pending.guid);
|
|
|
|
|
|
if (goIt == gameObjectInstances_.end()) {
|
|
|
|
|
|
it = pendingTransportRegistrations_.erase(it);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (transportManager->getTransport(pending.guid)) {
|
|
|
|
|
|
transportManager->updateServerTransport(
|
|
|
|
|
|
pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation);
|
|
|
|
|
|
it = pendingTransportRegistrations_.erase(it);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const uint32_t wmoInstanceId = goIt->second.instanceId;
|
2026-04-05 20:12:17 -07:00
|
|
|
|
LOG_DEBUG("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec,
|
2026-04-05 19:30:44 +03:00
|
|
|
|
" entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId,
|
|
|
|
|
|
" pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")");
|
|
|
|
|
|
|
|
|
|
|
|
// TransportAnimation.dbc is indexed by GameObject entry.
|
|
|
|
|
|
uint32_t pathId = pending.entry;
|
|
|
|
|
|
const bool preferServerData = gameHandler_->hasServerTransportUpdate(pending.guid);
|
|
|
|
|
|
|
|
|
|
|
|
bool clientAnim = transportManager->isClientSideAnimation();
|
|
|
|
|
|
LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim,
|
|
|
|
|
|
" guid=0x", std::hex, pending.guid, std::dec,
|
|
|
|
|
|
" entry=", pending.entry, " pathId=", pathId,
|
|
|
|
|
|
" preferServer=", preferServerData);
|
|
|
|
|
|
|
|
|
|
|
|
glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z);
|
|
|
|
|
|
const bool shipOrZeppelinDisplay =
|
|
|
|
|
|
(pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 ||
|
|
|
|
|
|
pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 ||
|
|
|
|
|
|
pending.displayId == 807 || pending.displayId == 808);
|
|
|
|
|
|
bool hasUsablePath = transportManager->hasPathForEntry(pending.entry);
|
|
|
|
|
|
if (shipOrZeppelinDisplay) {
|
|
|
|
|
|
hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 20:12:17 -07:00
|
|
|
|
LOG_DEBUG("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath,
|
2026-04-05 19:30:44 +03:00
|
|
|
|
" preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay);
|
|
|
|
|
|
|
|
|
|
|
|
if (preferServerData) {
|
|
|
|
|
|
if (!hasUsablePath) {
|
|
|
|
|
|
std::vector<glm::vec3> path = { canonicalSpawnPos };
|
|
|
|
|
|
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
|
2026-04-05 20:12:17 -07:00
|
|
|
|
LOG_DEBUG("Server-first strict registration: stationary fallback for GUID 0x",
|
2026-04-05 19:30:44 +03:00
|
|
|
|
std::hex, pending.guid, std::dec, " entry=", pending.entry);
|
|
|
|
|
|
} else {
|
2026-04-05 20:12:17 -07:00
|
|
|
|
LOG_DEBUG("Server-first transport registration: using entry DBC path for entry ", pending.entry);
|
2026-04-05 19:30:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
} else if (!hasUsablePath) {
|
|
|
|
|
|
bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462);
|
|
|
|
|
|
uint32_t inferredPath = transportManager->inferDbcPathForSpawn(
|
|
|
|
|
|
canonicalSpawnPos, 1200.0f, allowZOnly);
|
|
|
|
|
|
if (inferredPath != 0) {
|
|
|
|
|
|
pathId = inferredPath;
|
|
|
|
|
|
LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId);
|
|
|
|
|
|
if (remappedPath != 0) {
|
|
|
|
|
|
pathId = remappedPath;
|
|
|
|
|
|
LOG_WARNING("Using remapped fallback transport path ", pathId,
|
|
|
|
|
|
" for entry ", pending.entry, " displayId=", pending.displayId,
|
|
|
|
|
|
" (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry,
|
|
|
|
|
|
" - transport will be stationary");
|
|
|
|
|
|
std::vector<glm::vec3> path = { canonicalSpawnPos };
|
|
|
|
|
|
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry);
|
|
|
|
|
|
|
|
|
|
|
|
if (!goIt->second.isWmo) {
|
|
|
|
|
|
if (auto* tr = transportManager->getTransport(pending.guid)) {
|
|
|
|
|
|
tr->isM2 = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
transportManager->updateServerTransport(
|
|
|
|
|
|
pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation);
|
|
|
|
|
|
|
|
|
|
|
|
auto moveIt = pendingTransportMoves_.find(pending.guid);
|
|
|
|
|
|
if (moveIt != pendingTransportMoves_.end()) {
|
|
|
|
|
|
const PendingTransportMove latestMove = moveIt->second;
|
|
|
|
|
|
transportManager->updateServerTransport(
|
|
|
|
|
|
pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation);
|
|
|
|
|
|
LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec,
|
|
|
|
|
|
" pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z,
|
|
|
|
|
|
") orientation=", latestMove.orientation);
|
|
|
|
|
|
pendingTransportMoves_.erase(moveIt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (glm::dot(canonicalSpawnPos, canonicalSpawnPos) < 1.0f) {
|
|
|
|
|
|
auto goData = gameHandler_->getCachedGameObjectInfo(pending.entry);
|
|
|
|
|
|
if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) {
|
|
|
|
|
|
uint32_t taxiPathId = goData->data[0];
|
|
|
|
|
|
if (transportManager->hasTaxiPath(taxiPathId)) {
|
|
|
|
|
|
transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId);
|
|
|
|
|
|
LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry,
|
|
|
|
|
|
" taxiPathId=", taxiPathId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (auto* tr = transportManager->getTransport(pending.guid); tr) {
|
2026-04-05 20:12:17 -07:00
|
|
|
|
LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec,
|
2026-04-05 19:30:44 +03:00
|
|
|
|
" entry=", pending.entry, " displayId=", pending.displayId,
|
|
|
|
|
|
" pathId=", tr->pathId,
|
|
|
|
|
|
" mode=", (tr->useClientAnimation ? "client" : "server"),
|
|
|
|
|
|
" serverUpdates=", tr->serverUpdateCount);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec,
|
|
|
|
|
|
" entry=", pending.entry, " displayId=", pending.displayId,
|
|
|
|
|
|
" (TransportManager instance missing)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
++processed;
|
|
|
|
|
|
it = pendingTransportRegistrations_.erase(it);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processPendingTransportDoodads() {
|
|
|
|
|
|
if (pendingTransportDoodadBatches_.empty()) return;
|
|
|
|
|
|
if (!renderer_ || !assetManager_) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto* wmoRenderer = renderer_->getWMORenderer();
|
|
|
|
|
|
auto* m2Renderer = renderer_->getM2Renderer();
|
|
|
|
|
|
if (!wmoRenderer || !m2Renderer) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
static constexpr float kDoodadBudgetMs = 4.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// Batch all GPU uploads into a single async command buffer submission so that
|
|
|
|
|
|
// N doodads with multiple textures each don't each block on vkQueueSubmit +
|
|
|
|
|
|
// vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds
|
|
|
|
|
|
// of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST.
|
|
|
|
|
|
auto* vkCtx = renderer_->getVkContext();
|
|
|
|
|
|
if (vkCtx) vkCtx->beginUploadBatch();
|
|
|
|
|
|
|
|
|
|
|
|
size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME;
|
|
|
|
|
|
for (auto it = pendingTransportDoodadBatches_.begin();
|
|
|
|
|
|
it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) {
|
|
|
|
|
|
// Time budget check
|
|
|
|
|
|
float elapsedMs = std::chrono::duration<float, std::milli>(
|
|
|
|
|
|
std::chrono::steady_clock::now() - startTime).count();
|
|
|
|
|
|
if (elapsedMs >= kDoodadBudgetMs) break;
|
|
|
|
|
|
auto goIt = gameObjectInstances_.find(it->guid);
|
|
|
|
|
|
if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo ||
|
|
|
|
|
|
goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) {
|
|
|
|
|
|
it = pendingTransportDoodadBatches_.erase(it);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId);
|
|
|
|
|
|
if (!doodadTemplates || doodadTemplates->empty()) {
|
|
|
|
|
|
it = pendingTransportDoodadBatches_.erase(it);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size());
|
|
|
|
|
|
while (it->nextIndex < maxIndex && budgetLeft > 0) {
|
|
|
|
|
|
// Per-doodad time budget (each does synchronous file I/O + parse + GPU upload)
|
|
|
|
|
|
float innerMs = std::chrono::duration<float, std::milli>(
|
|
|
|
|
|
std::chrono::steady_clock::now() - startTime).count();
|
|
|
|
|
|
if (innerMs >= kDoodadBudgetMs) { budgetLeft = 0; break; }
|
|
|
|
|
|
|
|
|
|
|
|
const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex];
|
|
|
|
|
|
it->nextIndex++;
|
|
|
|
|
|
budgetLeft--;
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(doodadTemplate.m2Path));
|
|
|
|
|
|
auto m2Data = assetManager_->readFile(doodadTemplate.m2Path);
|
|
|
|
|
|
if (m2Data.empty()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data);
|
|
|
|
|
|
std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin";
|
|
|
|
|
|
std::vector<uint8_t> skinData = assetManager_->readFile(skinPath);
|
|
|
|
|
|
if (!skinData.empty() && m2Model.version >= 264) {
|
|
|
|
|
|
pipeline::M2Loader::loadSkin(skinData, m2Model);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!m2Model.isValid()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue;
|
|
|
|
|
|
uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f);
|
|
|
|
|
|
if (m2InstanceId == 0) continue;
|
|
|
|
|
|
m2Renderer->setSkipCollision(m2InstanceId, true);
|
|
|
|
|
|
|
|
|
|
|
|
wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform);
|
|
|
|
|
|
it->spawnedDoodads++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (it->nextIndex >= maxIndex) {
|
|
|
|
|
|
if (it->spawnedDoodads > 0) {
|
|
|
|
|
|
LOG_DEBUG("Spawned ", it->spawnedDoodads,
|
|
|
|
|
|
" transport doodads for WMO instance ", it->instanceId);
|
|
|
|
|
|
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z));
|
|
|
|
|
|
glm::mat4 wmoTransform(1.0f);
|
|
|
|
|
|
wmoTransform = glm::translate(wmoTransform, renderPos);
|
|
|
|
|
|
wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1));
|
|
|
|
|
|
wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform);
|
|
|
|
|
|
}
|
|
|
|
|
|
it = pendingTransportDoodadBatches_.erase(it);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Finalize the upload batch — submit all GPU copies in one shot (async, no wait).
|
|
|
|
|
|
if (vkCtx) vkCtx->endUploadBatch();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::processPendingMount() {
|
|
|
|
|
|
if (pendingMountDisplayId_ == 0) return;
|
|
|
|
|
|
uint32_t mountDisplayId = pendingMountDisplayId_;
|
|
|
|
|
|
pendingMountDisplayId_ = 0;
|
|
|
|
|
|
LOG_INFO("processPendingMount: loading displayId ", mountDisplayId);
|
|
|
|
|
|
|
|
|
|
|
|
if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_) return;
|
|
|
|
|
|
auto* charRenderer = renderer_->getCharacterRenderer();
|
|
|
|
|
|
|
|
|
|
|
|
std::string m2Path = getModelPathForDisplayId(mountDisplayId);
|
|
|
|
|
|
if (m2Path.empty()) {
|
|
|
|
|
|
LOG_WARNING("No model path for mount displayId ", mountDisplayId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check model cache
|
|
|
|
|
|
uint32_t modelId = 0;
|
|
|
|
|
|
auto cacheIt = displayIdModelCache_.find(mountDisplayId);
|
|
|
|
|
|
if (cacheIt != displayIdModelCache_.end()) {
|
|
|
|
|
|
modelId = cacheIt->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
modelId = nextCreatureModelId_++;
|
|
|
|
|
|
|
|
|
|
|
|
auto m2Data = assetManager_->readFile(m2Path);
|
|
|
|
|
|
if (m2Data.empty()) {
|
|
|
|
|
|
LOG_WARNING("Failed to read mount M2: ", m2Path);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
|
|
|
|
|
|
if (model.vertices.empty()) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse mount M2: ", m2Path);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load skin file (only for WotLK M2s - vanilla has embedded skin)
|
|
|
|
|
|
if (model.version >= 264) {
|
|
|
|
|
|
std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin";
|
|
|
|
|
|
auto skinData = assetManager_->readFile(skinPath);
|
|
|
|
|
|
if (!skinData.empty()) {
|
|
|
|
|
|
pipeline::M2Loader::loadSkin(skinData, model);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Missing skin file for WotLK mount M2: ", skinPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load external .anim files (only idle + run needed for mounts)
|
|
|
|
|
|
std::string basePath = m2Path.substr(0, m2Path.size() - 3);
|
|
|
|
|
|
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
|
|
|
|
|
if (!(model.sequences[si].flags & 0x20)) {
|
|
|
|
|
|
uint32_t animId = model.sequences[si].id;
|
|
|
|
|
|
// Only load stand, walk, run anims to avoid hang
|
|
|
|
|
|
if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue;
|
|
|
|
|
|
char animFileName[256];
|
|
|
|
|
|
snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim",
|
|
|
|
|
|
basePath.c_str(), animId, model.sequences[si].variationIndex);
|
|
|
|
|
|
auto animData = assetManager_->readFileOptional(animFileName);
|
|
|
|
|
|
if (!animData.empty()) {
|
|
|
|
|
|
pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!charRenderer->loadModel(model, modelId)) {
|
|
|
|
|
|
LOG_WARNING("Failed to load mount model: ", m2Path);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
displayIdModelCache_[mountDisplayId] = modelId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply creature skin textures from CreatureDisplayInfo.dbc.
|
|
|
|
|
|
// Re-apply even for cached models so transient failures can self-heal.
|
|
|
|
|
|
std::string modelDir;
|
|
|
|
|
|
size_t lastSlash = m2Path.find_last_of("\\/");
|
|
|
|
|
|
if (lastSlash != std::string::npos) {
|
|
|
|
|
|
modelDir = m2Path.substr(0, lastSlash + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto itDisplayData = displayDataMap_.find(mountDisplayId);
|
|
|
|
|
|
bool haveDisplayData = false;
|
|
|
|
|
|
CreatureDisplayData dispData{};
|
|
|
|
|
|
if (itDisplayData != displayDataMap_.end()) {
|
|
|
|
|
|
dispData = itDisplayData->second;
|
|
|
|
|
|
haveDisplayData = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Some taxi mount display IDs are sparse; recover skins by matching model path.
|
|
|
|
|
|
std::string lowerMountPath = m2Path;
|
|
|
|
|
|
std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
int bestScore = -1;
|
|
|
|
|
|
for (const auto& [dispId, data] : displayDataMap_) {
|
|
|
|
|
|
auto pit = modelIdToPath_.find(data.modelId);
|
|
|
|
|
|
if (pit == modelIdToPath_.end()) continue;
|
|
|
|
|
|
std::string p = pit->second;
|
|
|
|
|
|
std::transform(p.begin(), p.end(), p.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (p != lowerMountPath) continue;
|
|
|
|
|
|
int score = 0;
|
|
|
|
|
|
if (!data.skin1.empty()) {
|
|
|
|
|
|
std::string p1 = modelDir + data.skin1 + ".blp";
|
|
|
|
|
|
score += assetManager_->fileExists(p1) ? 30 : 3;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!data.skin2.empty()) {
|
|
|
|
|
|
std::string p2 = modelDir + data.skin2 + ".blp";
|
|
|
|
|
|
score += assetManager_->fileExists(p2) ? 20 : 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!data.skin3.empty()) {
|
|
|
|
|
|
std::string p3 = modelDir + data.skin3 + ".blp";
|
|
|
|
|
|
score += assetManager_->fileExists(p3) ? 10 : 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (score > bestScore) {
|
|
|
|
|
|
bestScore = score;
|
|
|
|
|
|
dispData = data;
|
|
|
|
|
|
haveDisplayData = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (haveDisplayData) {
|
|
|
|
|
|
LOG_INFO("Recovered mount display data by model path for displayId=", mountDisplayId,
|
|
|
|
|
|
" skin1='", dispData.skin1, "' skin2='", dispData.skin2,
|
|
|
|
|
|
"' skin3='", dispData.skin3, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (haveDisplayData) {
|
|
|
|
|
|
// If this displayId has no skins, try to find another displayId for the same model with skins.
|
|
|
|
|
|
if (dispData.skin1.empty() && dispData.skin2.empty() && dispData.skin3.empty()) {
|
|
|
|
|
|
uint32_t sourceModelId = dispData.modelId;
|
|
|
|
|
|
int bestScore = -1;
|
|
|
|
|
|
for (const auto& [dispId, data] : displayDataMap_) {
|
|
|
|
|
|
if (data.modelId != sourceModelId) continue;
|
|
|
|
|
|
int score = 0;
|
|
|
|
|
|
if (!data.skin1.empty()) {
|
|
|
|
|
|
std::string p = modelDir + data.skin1 + ".blp";
|
|
|
|
|
|
score += assetManager_->fileExists(p) ? 30 : 3;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!data.skin2.empty()) {
|
|
|
|
|
|
std::string p = modelDir + data.skin2 + ".blp";
|
|
|
|
|
|
score += assetManager_->fileExists(p) ? 20 : 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!data.skin3.empty()) {
|
|
|
|
|
|
std::string p = modelDir + data.skin3 + ".blp";
|
|
|
|
|
|
score += assetManager_->fileExists(p) ? 10 : 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (score > bestScore) {
|
|
|
|
|
|
bestScore = score;
|
|
|
|
|
|
dispData = data;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Mount skin fallback for displayId=", mountDisplayId,
|
|
|
|
|
|
" modelId=", sourceModelId, " skin1='", dispData.skin1,
|
|
|
|
|
|
"' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
const auto* md = charRenderer->getModelData(modelId);
|
|
|
|
|
|
if (md) {
|
|
|
|
|
|
LOG_INFO("Mount model textures: ", md->textures.size(), " slots, skin1='", dispData.skin1,
|
|
|
|
|
|
"' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'");
|
|
|
|
|
|
for (size_t ti = 0; ti < md->textures.size(); ti++) {
|
|
|
|
|
|
LOG_INFO(" tex[", ti, "] type=", md->textures[ti].type,
|
|
|
|
|
|
" filename='", md->textures[ti].filename, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int replaced = 0;
|
|
|
|
|
|
for (size_t ti = 0; ti < md->textures.size(); ti++) {
|
|
|
|
|
|
const auto& tex = md->textures[ti];
|
|
|
|
|
|
std::string texPath;
|
|
|
|
|
|
if (tex.type == 11 && !dispData.skin1.empty()) {
|
|
|
|
|
|
texPath = modelDir + dispData.skin1 + ".blp";
|
|
|
|
|
|
} else if (tex.type == 12 && !dispData.skin2.empty()) {
|
|
|
|
|
|
texPath = modelDir + dispData.skin2 + ".blp";
|
|
|
|
|
|
} else if (tex.type == 13 && !dispData.skin3.empty()) {
|
|
|
|
|
|
texPath = modelDir + dispData.skin3 + ".blp";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!texPath.empty()) {
|
|
|
|
|
|
rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath);
|
|
|
|
|
|
if (skinTex) {
|
|
|
|
|
|
charRenderer->setModelTexture(modelId, static_cast<uint32_t>(ti), skinTex);
|
|
|
|
|
|
LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath);
|
|
|
|
|
|
replaced++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING(" Failed to load skin texture slot ", ti, ": ", texPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Force skin textures onto type-0 (hardcoded) slots that have no filename
|
|
|
|
|
|
if (replaced == 0) {
|
|
|
|
|
|
for (size_t ti = 0; ti < md->textures.size(); ti++) {
|
|
|
|
|
|
const auto& tex = md->textures[ti];
|
|
|
|
|
|
if (tex.type == 0 && tex.filename.empty()) {
|
|
|
|
|
|
// Empty hardcoded slot — try skin1 then skin2
|
|
|
|
|
|
std::string texPath;
|
|
|
|
|
|
if (!dispData.skin1.empty() && replaced == 0) {
|
|
|
|
|
|
texPath = modelDir + dispData.skin1 + ".blp";
|
|
|
|
|
|
} else if (!dispData.skin2.empty()) {
|
|
|
|
|
|
texPath = modelDir + dispData.skin2 + ".blp";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!texPath.empty()) {
|
|
|
|
|
|
rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath);
|
|
|
|
|
|
if (skinTex) {
|
|
|
|
|
|
charRenderer->setModelTexture(modelId, static_cast<uint32_t>(ti), skinTex);
|
|
|
|
|
|
LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath);
|
|
|
|
|
|
replaced++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If still no textures, try hardcoded model texture filenames
|
|
|
|
|
|
if (replaced == 0) {
|
|
|
|
|
|
for (size_t ti = 0; ti < md->textures.size(); ti++) {
|
|
|
|
|
|
if (!md->textures[ti].filename.empty()) {
|
|
|
|
|
|
rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename);
|
|
|
|
|
|
if (texId) {
|
|
|
|
|
|
charRenderer->setModelTexture(modelId, static_cast<uint32_t>(ti), texId);
|
|
|
|
|
|
LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename);
|
|
|
|
|
|
replaced++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Final fallback for gryphon/wyvern: try well-known skin texture names
|
|
|
|
|
|
if (replaced == 0 && !md->textures.empty()) {
|
|
|
|
|
|
std::string lowerMountPath = m2Path;
|
|
|
|
|
|
std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
if (lowerMountPath.find("gryphon") != std::string::npos) {
|
|
|
|
|
|
const char* gryphonSkins[] = {
|
|
|
|
|
|
"Creature\\Gryphon\\Gryphon_Skin.blp",
|
|
|
|
|
|
"Creature\\Gryphon\\Gryphon_Skin01.blp",
|
|
|
|
|
|
"Creature\\Gryphon\\GRYPHON_SKIN01.BLP",
|
|
|
|
|
|
nullptr
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const char** p = gryphonSkins; *p; ++p) {
|
|
|
|
|
|
rendering::VkTexture* texId = charRenderer->loadTexture(*p);
|
|
|
|
|
|
if (texId) {
|
|
|
|
|
|
charRenderer->setModelTexture(modelId, 0, texId);
|
|
|
|
|
|
LOG_INFO(" Forced gryphon skin fallback: ", *p);
|
|
|
|
|
|
replaced++;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (lowerMountPath.find("wyvern") != std::string::npos) {
|
|
|
|
|
|
const char* wyvernSkins[] = {
|
|
|
|
|
|
"Creature\\Wyvern\\Wyvern_Skin.blp",
|
|
|
|
|
|
"Creature\\Wyvern\\Wyvern_Skin01.blp",
|
|
|
|
|
|
nullptr
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const char** p = wyvernSkins; *p; ++p) {
|
|
|
|
|
|
rendering::VkTexture* texId = charRenderer->loadTexture(*p);
|
|
|
|
|
|
if (texId) {
|
|
|
|
|
|
charRenderer->setModelTexture(modelId, 0, texId);
|
|
|
|
|
|
LOG_INFO(" Forced wyvern skin fallback: ", *p);
|
|
|
|
|
|
replaced++;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Mount texture setup: ", replaced, " textures applied");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mountModelId_ = modelId;
|
|
|
|
|
|
|
|
|
|
|
|
// Create mount instance at player position
|
|
|
|
|
|
glm::vec3 mountPos = renderer_->getCharacterPosition();
|
|
|
|
|
|
float yawRad = glm::radians(renderer_->getCharacterYaw());
|
|
|
|
|
|
uint32_t instanceId = charRenderer->createInstance(modelId, mountPos,
|
|
|
|
|
|
glm::vec3(0.0f, 0.0f, yawRad), 1.0f);
|
|
|
|
|
|
|
|
|
|
|
|
if (instanceId == 0) {
|
|
|
|
|
|
LOG_WARNING("Failed to create mount instance");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mountInstanceId_ = instanceId;
|
|
|
|
|
|
|
|
|
|
|
|
// Compute height offset — place player above mount's back
|
|
|
|
|
|
// Use tight bounds from actual vertices (M2 header bounds can be inaccurate)
|
|
|
|
|
|
const auto* modelData = charRenderer->getModelData(modelId);
|
|
|
|
|
|
float heightOffset = 1.8f;
|
|
|
|
|
|
if (modelData && !modelData->vertices.empty()) {
|
|
|
|
|
|
float minZ = std::numeric_limits<float>::max();
|
|
|
|
|
|
float maxZ = -std::numeric_limits<float>::max();
|
|
|
|
|
|
for (const auto& v : modelData->vertices) {
|
|
|
|
|
|
if (v.position.z < minZ) minZ = v.position.z;
|
|
|
|
|
|
if (v.position.z > maxZ) maxZ = v.position.z;
|
|
|
|
|
|
}
|
|
|
|
|
|
float extentZ = maxZ - minZ;
|
|
|
|
|
|
LOG_INFO("Mount tight bounds: minZ=", minZ, " maxZ=", maxZ, " extentZ=", extentZ);
|
|
|
|
|
|
if (extentZ > 0.5f) {
|
|
|
|
|
|
// Saddle point is roughly 75% up the model, measured from model origin
|
|
|
|
|
|
heightOffset = maxZ * 0.8f;
|
|
|
|
|
|
if (heightOffset < 1.0f) heightOffset = extentZ * 0.75f;
|
|
|
|
|
|
if (heightOffset < 1.0f) heightOffset = 1.8f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (auto* ac = renderer_->getAnimationController()) ac->setMounted(instanceId, mountDisplayId, heightOffset, m2Path);
|
|
|
|
|
|
|
|
|
|
|
|
// For taxi mounts, start with flying animation; for ground mounts, start with stand
|
|
|
|
|
|
bool isTaxi = gameHandler_ && gameHandler_->isOnTaxiFlight();
|
|
|
|
|
|
uint32_t startAnim = rendering::anim::STAND;
|
|
|
|
|
|
if (isTaxi) {
|
|
|
|
|
|
// Try WotLK fly anims first, then Vanilla-friendly fallbacks
|
|
|
|
|
|
using namespace rendering::anim;
|
|
|
|
|
|
uint32_t taxiCandidates[] = {FLY_FORWARD, FLY_IDLE, FLY_RUN_2, FLY_SPELL, FLY_RISE, SPELL_KNEEL_LOOP, FLY_CUSTOM_SPELL_10, DEAD, RUN};
|
|
|
|
|
|
for (uint32_t anim : taxiCandidates) {
|
|
|
|
|
|
if (charRenderer->hasAnimation(instanceId, anim)) {
|
|
|
|
|
|
startAnim = anim;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// If none found, startAnim stays 0 (Stand/hover) which is fine for flying creatures
|
|
|
|
|
|
}
|
|
|
|
|
|
charRenderer->playAnimation(instanceId, startAnim, true);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("processPendingMount: DONE displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::despawnCreature(uint64_t guid) {
|
|
|
|
|
|
// If this guid is a PLAYER, it will be tracked in playerInstances_.
|
|
|
|
|
|
// Route to the correct despawn path so we don't leak instances.
|
|
|
|
|
|
if (playerInstances_.count(guid)) {
|
|
|
|
|
|
despawnPlayer(guid);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pendingCreatureSpawnGuids_.erase(guid);
|
|
|
|
|
|
creatureSpawnRetryCounts_.erase(guid);
|
|
|
|
|
|
creaturePermanentFailureGuids_.erase(guid);
|
|
|
|
|
|
deadCreatureGuids_.erase(guid);
|
|
|
|
|
|
|
|
|
|
|
|
auto it = creatureInstances_.find(guid);
|
|
|
|
|
|
if (it == creatureInstances_.end()) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (renderer_ && renderer_->getCharacterRenderer()) {
|
|
|
|
|
|
renderer_->getCharacterRenderer()->removeInstance(it->second);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
creatureInstances_.erase(it);
|
|
|
|
|
|
creatureModelIds_.erase(guid);
|
|
|
|
|
|
creatureRenderPosCache_.erase(guid);
|
|
|
|
|
|
creatureWeaponsAttached_.erase(guid);
|
|
|
|
|
|
creatureWeaponAttachAttempts_.erase(guid);
|
|
|
|
|
|
creatureWasMoving_.erase(guid);
|
|
|
|
|
|
creatureWasSwimming_.erase(guid);
|
|
|
|
|
|
creatureWasFlying_.erase(guid);
|
|
|
|
|
|
creatureWasWalking_.erase(guid);
|
|
|
|
|
|
creatureSwimmingState_.erase(guid);
|
|
|
|
|
|
creatureWalkingState_.erase(guid);
|
|
|
|
|
|
creatureFlyingState_.erase(guid);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EntitySpawner::despawnGameObject(uint64_t guid) {
|
|
|
|
|
|
pendingTransportDoodadBatches_.erase(
|
|
|
|
|
|
std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(),
|
|
|
|
|
|
[guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }),
|
|
|
|
|
|
pendingTransportDoodadBatches_.end());
|
|
|
|
|
|
|
|
|
|
|
|
auto it = gameObjectInstances_.find(guid);
|
|
|
|
|
|
if (it == gameObjectInstances_.end()) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (renderer_) {
|
|
|
|
|
|
if (it->second.isWmo) {
|
|
|
|
|
|
if (auto* wmoRenderer = renderer_->getWMORenderer()) {
|
|
|
|
|
|
wmoRenderer->removeInstance(it->second.instanceId);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (auto* m2Renderer = renderer_->getM2Renderer()) {
|
|
|
|
|
|
m2Renderer->removeInstance(it->second.instanceId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
gameObjectInstances_.erase(it);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool EntitySpawner::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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} // namespace core
|
|
|
|
|
|
} // namespace wowee
|