Stabilize taxi/state sync and creature spawn handling

This commit is contained in:
Kelsi 2026-02-11 21:14:35 -08:00
parent 38cef8d9c6
commit 40b50454ce
18 changed files with 818 additions and 127 deletions

View file

@ -420,6 +420,52 @@ void Application::update(float deltaTime) {
auto cq2 = std::chrono::high_resolution_clock::now();
creatureQTime += std::chrono::duration<float, std::milli>(cq2 - cq1).count();
// Self-heal missing creature visuals: if a nearby UNIT exists in
// entity state but has no render instance, queue a spawn retry.
if (gameHandler) {
static float creatureResyncTimer = 0.0f;
creatureResyncTimer += deltaTime;
if (creatureResyncTimer >= 1.0f) {
creatureResyncTimer = 0.0f;
glm::vec3 playerPos(0.0f);
bool havePlayerPos = false;
uint64_t playerGuid = gameHandler->getPlayerGuid();
if (auto playerEntity = gameHandler->getEntityManager().getEntity(playerGuid)) {
playerPos = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
havePlayerPos = true;
}
const float kResyncRadiusSq = 260.0f * 260.0f;
for (const auto& pair : gameHandler->getEntityManager().getEntities()) {
uint64_t guid = pair.first;
const auto& entity = pair.second;
if (!entity || guid == playerGuid) continue;
if (entity->getType() != game::ObjectType::UNIT) continue;
auto unit = std::dynamic_pointer_cast<game::Unit>(entity);
if (!unit || unit->getDisplayId() == 0) continue;
if (creatureInstances_.count(guid) || pendingCreatureSpawnGuids_.count(guid)) continue;
if (havePlayerPos) {
glm::vec3 pos(unit->getX(), unit->getY(), unit->getZ());
glm::vec3 delta = pos - playerPos;
float distSq = glm::dot(delta, delta);
if (distSq > kResyncRadiusSq) continue;
}
PendingCreatureSpawn retrySpawn{};
retrySpawn.guid = guid;
retrySpawn.displayId = unit->getDisplayId();
retrySpawn.x = unit->getX();
retrySpawn.y = unit->getY();
retrySpawn.z = unit->getZ();
retrySpawn.orientation = unit->getOrientation();
pendingCreatureSpawns_.push_back(retrySpawn);
pendingCreatureSpawnGuids_.insert(guid);
}
}
}
auto goq1 = std::chrono::high_resolution_clock::now();
processGameObjectSpawnQueue();
auto goq2 = std::chrono::high_resolution_clock::now();
@ -447,16 +493,67 @@ void Application::update(float deltaTime) {
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
}
bool onTaxi = gameHandler && (gameHandler->isOnTaxiFlight() || gameHandler->isTaxiMountActive());
bool onTaxi = gameHandler &&
(gameHandler->isOnTaxiFlight() ||
gameHandler->isTaxiMountActive() ||
gameHandler->isTaxiActivationPending());
if (worldEntryMovementGraceTimer_ > 0.0f) {
worldEntryMovementGraceTimer_ -= deltaTime;
}
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->setExternalFollow(onTaxi);
renderer->getCameraController()->setExternalMoving(onTaxi);
if (onTaxi) {
// Drop any stale local movement toggles while server drives taxi motion.
renderer->getCameraController()->clearMovementInputs();
taxiLandingClampTimer_ = 0.0f;
}
if (lastTaxiFlight_ && !onTaxi) {
renderer->getCameraController()->clearMovementInputs();
// Keep clamping for a short grace window after taxi ends to avoid
// falling through WMOs while floor/collision state settles.
taxiLandingClampTimer_ = 1.5f;
}
if (!onTaxi &&
worldEntryMovementGraceTimer_ <= 0.0f &&
!gameHandler->isMounted() &&
taxiLandingClampTimer_ > 0.0f) {
taxiLandingClampTimer_ -= deltaTime;
if (renderer && gameHandler) {
glm::vec3 p = renderer->getCharacterPosition();
std::optional<float> terrainFloor;
std::optional<float> wmoFloor;
std::optional<float> m2Floor;
if (renderer->getTerrainManager()) {
terrainFloor = renderer->getTerrainManager()->getHeightAt(p.x, p.y);
}
if (renderer->getWMORenderer()) {
// Probe from above so we can recover when current Z is already below floor.
wmoFloor = renderer->getWMORenderer()->getFloorHeight(p.x, p.y, p.z + 40.0f);
}
if (renderer->getM2Renderer()) {
// Include M2 floors (bridges/platforms) in landing recovery.
m2Floor = renderer->getM2Renderer()->getFloorHeight(p.x, p.y, p.z + 40.0f);
}
std::optional<float> targetFloor;
if (terrainFloor) targetFloor = terrainFloor;
if (wmoFloor && (!targetFloor || *wmoFloor > *targetFloor)) targetFloor = wmoFloor;
if (m2Floor && (!targetFloor || *m2Floor > *targetFloor)) targetFloor = m2Floor;
if (targetFloor) {
float targetZ = *targetFloor + 0.10f;
// Only lift upward to prevent sinking through floors/bridges.
// Never force the player downward from a valid elevated surface.
if (p.z < targetZ - 0.05f) {
p.z = targetZ;
renderer->getCharacterPosition() = p;
glm::vec3 canonical = core::coords::renderToCanonical(p);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT);
}
}
}
}
bool idleOrbit = renderer->getCameraController()->isIdleOrbit();
if (idleOrbit && !idleYawned_ && renderer) {
@ -494,10 +591,27 @@ void Application::update(float deltaTime) {
if (onTaxi) {
auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid());
glm::vec3 canonical(0.0f);
bool haveCanonical = false;
if (playerEntity) {
glm::vec3 canonical(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
canonical = glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ());
haveCanonical = true;
} else {
// Fallback for brief entity gaps during taxi start/updates:
// movementInfo is still updated by client taxi simulation.
const auto& move = gameHandler->getMovementInfo();
canonical = glm::vec3(move.x, move.y, move.z);
haveCanonical = true;
}
if (haveCanonical) {
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->getCharacterPosition() = renderPos;
if (renderer->getCameraController()) {
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
if (followTarget) {
*followTarget = renderPos;
}
}
}
} else if (onTransport) {
// Transport mode: compose world position from transport transform + local offset
@ -523,16 +637,16 @@ void Application::update(float deltaTime) {
}
}
// Send movement heartbeat every 500ms (keeps server position in sync)
// Skip periodic taxi heartbeats; taxi start sends explicit heartbeats already.
if (gameHandler && renderer && !onTaxi) {
// Send periodic movement heartbeats.
// Keep them active during taxi as well to avoid occasional server-side
// flight stalls waiting for movement sync updates.
if (gameHandler && renderer) {
movementHeartbeatTimer += deltaTime;
if (movementHeartbeatTimer >= 0.5f) {
float hbInterval = onTaxi ? 0.25f : 0.5f;
if (movementHeartbeatTimer >= hbInterval) {
movementHeartbeatTimer = 0.0f;
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT);
}
} else {
movementHeartbeatTimer = 0.0f;
}
auto sync2 = std::chrono::high_resolution_clock::now();
@ -540,7 +654,7 @@ void Application::update(float deltaTime) {
// Log profiling every 60 frames
if (++appProfileCounter >= 60) {
LOG_INFO("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f,
LOG_DEBUG("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f,
"ms world=", worldTime / 60.0f, "ms spawn=", spawnTime / 60.0f,
"ms creatureQ=", creatureQTime / 60.0f, "ms goQ=", goQTime / 60.0f,
"ms mount=", mountTime / 60.0f, "ms npcMgr=", npcMgrTime / 60.0f,
@ -578,7 +692,7 @@ void Application::update(float deltaTime) {
uiTime += std::chrono::duration<float, std::milli>(u2 - u1).count();
if (state == AppState::IN_GAME && ++rendererProfileCounter >= 60) {
LOG_INFO("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f,
LOG_DEBUG("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f,
"ms ui=", uiTime / 60.0f, "ms");
rendererProfileCounter = 0;
rendererTime = uiTime = 0.0f;
@ -690,23 +804,129 @@ void Application::setupUICallbacks() {
// World entry callback (online mode) - load terrain when entering world
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z) {
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")");
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
loadOnlineWorldTerrain(mapId, x, y, z);
});
// /unstuck — snap upward 10m to escape minor WMO cracks
gameHandler->setUnstuckCallback([this]() {
auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional<float> {
std::optional<float> terrainFloor;
std::optional<float> wmoFloor;
std::optional<float> m2Floor;
if (renderer && renderer->getTerrainManager()) {
terrainFloor = renderer->getTerrainManager()->getHeightAt(x, y);
}
if (renderer && renderer->getWMORenderer()) {
wmoFloor = renderer->getWMORenderer()->getFloorHeight(x, y, probeZ);
}
if (renderer && renderer->getM2Renderer()) {
m2Floor = renderer->getM2Renderer()->getFloorHeight(x, y, probeZ);
}
std::optional<float> best;
if (terrainFloor) best = terrainFloor;
if (wmoFloor && (!best || *wmoFloor > *best)) best = wmoFloor;
if (m2Floor && (!best || *m2Floor > *best)) best = m2Floor;
return best;
};
auto clearStuckMovement = [this]() {
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
}
if (gameHandler) {
gameHandler->forceClearTaxiAndMovementState();
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_STRAFE);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_TURN);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_SWIM);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT);
}
};
auto syncTeleportedPositionToServer = [this](const glm::vec3& renderPos) {
if (!gameHandler) return;
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_STRAFE);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_STOP_TURN);
gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT);
};
auto forceServerTeleportCommand = [this](const glm::vec3& renderPos) {
if (!gameHandler) return;
// Server-authoritative reset first, then teleport.
gameHandler->sendChatMessage(game::ChatType::SAY, ".revive", "");
gameHandler->sendChatMessage(game::ChatType::SAY, ".dismount", "");
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
glm::vec3 serverPos = core::coords::canonicalToServer(canonical);
std::ostringstream cmd;
cmd.setf(std::ios::fixed);
cmd.precision(3);
cmd << ".go xyz "
<< serverPos.x << " "
<< serverPos.y << " "
<< serverPos.z << " "
<< gameHandler->getCurrentMapId() << " "
<< gameHandler->getMovementInfo().orientation;
gameHandler->sendChatMessage(game::ChatType::SAY, cmd.str(), "");
};
// /unstuck — prefer safe position or nearest floor, avoid blind +Z snaps.
gameHandler->setUnstuckCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() {
if (!renderer || !renderer->getCameraController()) return;
worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f);
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
clearStuckMovement();
auto* cc = renderer->getCameraController();
auto* ft = cc->getFollowTargetMutable();
if (!ft) return;
glm::vec3 pos = *ft;
pos.z += 10.0f;
if (cc->hasLastSafePosition()) {
pos = cc->getLastSafePosition();
pos.z += 1.5f;
cc->teleportTo(pos);
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to last safe position");
return;
}
if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) {
pos.z = *floor + 0.2f;
} else {
pos.z += 20.0f;
}
// Nudge forward slightly to break edge-cases where unstuck lands exactly
// on problematic collision seams.
if (gameHandler) {
float renderYaw = gameHandler->getMovementInfo().orientation + glm::radians(90.0f);
pos.x += std::cos(renderYaw) * 2.0f;
pos.y += std::sin(renderYaw) * 2.0f;
}
cc->teleportTo(pos);
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: recovered to sampled floor");
});
// /unstuckgy — snap upward 50m to clear all WMO geometry, gravity re-settles onto terrain
gameHandler->setUnstuckGyCallback([this]() {
// /unstuckgy — stronger recovery: safe/home position, then sampled floor fallback.
gameHandler->setUnstuckGyCallback([this, sampleBestFloorAt, clearStuckMovement, syncTeleportedPositionToServer, forceServerTeleportCommand]() {
if (!renderer || !renderer->getCameraController()) return;
worldEntryMovementGraceTimer_ = std::max(worldEntryMovementGraceTimer_, 1.5f);
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
clearStuckMovement();
auto* cc = renderer->getCameraController();
auto* ft = cc->getFollowTargetMutable();
if (!ft) return;
@ -716,15 +936,45 @@ void Application::setupUICallbacks() {
glm::vec3 safePos = cc->getLastSafePosition();
safePos.z += 5.0f;
cc->teleportTo(safePos);
syncTeleportedPositionToServer(safePos);
forceServerTeleportCommand(safePos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to last safe position");
return;
}
// No safe position — snap 50m upward to clear all WMO geometry
uint32_t bindMap = 0;
glm::vec3 bindPos(0.0f);
if (gameHandler && gameHandler->getHomeBind(bindMap, bindPos) &&
bindMap == gameHandler->getCurrentMapId()) {
bindPos.z += 2.0f;
cc->teleportTo(bindPos);
syncTeleportedPositionToServer(bindPos);
forceServerTeleportCommand(bindPos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to home bind position");
return;
}
// No safe/bind position — try current XY with a high floor probe.
glm::vec3 pos = *ft;
pos.z += 50.0f;
if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 120.0f)) {
pos.z = *floor + 0.5f;
cc->teleportTo(pos);
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: teleported to sampled floor");
return;
}
// Last fallback: high snap to clear deeply bad geometry.
pos.z += 60.0f;
cc->teleportTo(pos);
LOG_INFO("Unstuck: snapped 50m upward");
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
LOG_INFO("Unstuck: high fallback snap");
});
// Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry
@ -2295,19 +2545,23 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
// Skip if already spawned
if (creatureInstances_.count(guid)) return;
if (nonRenderableCreatureDisplayIds_.count(displayId)) {
creaturePermanentFailureGuids_.insert(guid);
return;
}
// Get model path from displayId
std::string m2Path = getModelPathForDisplayId(displayId);
if (m2Path.empty()) {
LOG_WARNING("No model path for displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")");
nonRenderableCreatureDisplayIds_.insert(displayId);
creaturePermanentFailureGuids_.insert(guid);
return;
}
{
// Intentionally invisible helper creatures should not consume retry budget.
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) {
creaturePermanentFailureGuids_.insert(guid);
return;
}
}
auto* charRenderer = renderer->getCharacterRenderer();
@ -2325,15 +2579,12 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
auto m2Data = assetManager->readFile(m2Path);
if (m2Data.empty()) {
LOG_WARNING("Failed to read creature M2: ", m2Path);
creaturePermanentFailureGuids_.insert(guid);
return;
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (model.vertices.empty()) {
LOG_WARNING("Failed to parse creature M2: ", m2Path);
nonRenderableCreatureDisplayIds_.insert(displayId);
creaturePermanentFailureGuids_.insert(guid);
return;
}
@ -2360,7 +2611,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
if (!charRenderer->loadModel(model, modelId)) {
LOG_WARNING("Failed to load creature model: ", m2Path);
creaturePermanentFailureGuids_.insert(guid);
return;
}
@ -2502,28 +2752,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
// Convert canonical → render coordinates
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
// Smart filtering for bad spawn data:
// - If over ocean AND at continental height (Z > 50): bad data, skip
// - If over ocean AND near sea level (Z <= 50): water creature, allow
// - If over land: preserve server Z for elevated platforms/roofs and only
// correct clearly underground spawns.
if (renderer->getTerrainManager()) {
auto terrainH = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y);
if (!terrainH) {
// No terrain at this X,Y position (ocean/void)
if (z > 50.0f) {
// High altitude over ocean = bad spawn data (e.g., bears at Z=94 over water)
return;
}
// Low altitude = probably legitimate water creature, allow spawn at original Z
} else {
// Keep authentic server Z for NPCs on raised geometry (e.g. flight masters).
// Only lift up if spawn is clearly below terrain.
if (renderPos.z < (*terrainH - 2.0f)) {
renderPos.z = *terrainH + 0.1f;
}
}
}
// Keep authoritative server Z for online creature spawns.
// Terrain-based lifting can incorrectly move elevated NPCs (e.g. flight masters on
// Stormwind ramparts) to bad heights relative to WMO geometry.
// Convert canonical WoW orientation (0=north) -> render yaw (0=west)
float renderYaw = orientation + glm::radians(90.0f);
@ -2537,8 +2768,16 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
return;
}
// NOTE: Custom humanoid NPC geoset/equipment overrides are currently too
// aggressive and can make NPCs invisible (targetable but not rendered).
// Keep default model geosets for online creatures until this path is made
// data-accurate per display model.
static constexpr bool kEnableNpcHumanoidOverrides = false;
// Set geosets for humanoid NPCs based on CreatureDisplayInfoExtra
if (itDisplayData != displayDataMap_.end() && itDisplayData->second.extraDisplayId != 0) {
if (kEnableNpcHumanoidOverrides &&
itDisplayData != displayDataMap_.end() &&
itDisplayData->second.extraDisplayId != 0) {
auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId);
if (itExtra != humanoidExtraMap_.end()) {
const auto& extra = itExtra->second;
@ -2768,6 +3007,83 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
}
}
// Optional NPC helmet attachments (kept disabled for stability: this path
// can increase spawn-time pressure and regress NPC visibility in crowded areas).
static constexpr bool kEnableNpcHelmetAttachments = false;
if (kEnableNpcHelmetAttachments &&
itDisplayData != displayDataMap_.end() &&
itDisplayData->second.extraDisplayId != 0) {
auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId);
if (itExtra != humanoidExtraMap_.end()) {
const auto& extra = itExtra->second;
if (extra.equipDisplayId[0] != 0) { // Helm slot
auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
if (itemDisplayDbc) {
int32_t helmIdx = itemDisplayDbc->findRecordById(extra.equipDisplayId[0]);
if (helmIdx >= 0) {
std::string helmModelName = itemDisplayDbc->getString(static_cast<uint32_t>(helmIdx), 1);
if (!helmModelName.empty()) {
size_t dotPos = helmModelName.rfind('.');
if (dotPos != std::string::npos) {
helmModelName = helmModelName.substr(0, dotPos);
}
static const std::unordered_map<uint8_t, std::string> racePrefix = {
{1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"},
{6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"}
};
std::string genderSuffix = (extra.sexId == 0) ? "M" : "F";
std::string raceSuffix;
auto itRace = racePrefix.find(extra.raceId);
if (itRace != racePrefix.end()) {
raceSuffix = "_" + itRace->second + genderSuffix;
}
std::string helmPath;
std::vector<uint8_t> helmData;
if (!raceSuffix.empty()) {
helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2";
helmData = assetManager->readFile(helmPath);
}
if (helmData.empty()) {
helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2";
helmData = assetManager->readFile(helmPath);
}
if (!helmData.empty()) {
auto helmModel = pipeline::M2Loader::load(helmData);
std::string skinPath = helmPath.substr(0, helmPath.size() - 3) + "00.skin";
auto skinData = assetManager->readFile(skinPath);
if (!skinData.empty()) {
pipeline::M2Loader::loadSkin(skinData, helmModel);
}
if (helmModel.isValid()) {
uint32_t helmModelId = nextCreatureModelId_++;
std::string helmTexName = itemDisplayDbc->getString(static_cast<uint32_t>(helmIdx), 3);
std::string helmTexPath;
if (!helmTexName.empty()) {
if (!raceSuffix.empty()) {
std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp";
if (assetManager->fileExists(suffixedTex)) {
helmTexPath = suffixedTex;
}
}
if (helmTexPath.empty()) {
helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp";
}
}
// Attachment point 11 = Head
charRenderer->attachWeapon(instanceId, 11, helmModel, helmModelId, helmTexPath);
}
}
}
}
}
}
}
}
// Play idle animation and fade in
charRenderer->playAnimation(instanceId, 0, true);
charRenderer->startFadeIn(instanceId, 0.5f);
@ -2775,8 +3091,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
// Track instance
creatureInstances_[guid] = instanceId;
creatureModelIds_[guid] = modelId;
creaturePermanentFailureGuids_.erase(guid);
LOG_INFO("Spawned creature: guid=0x", std::hex, guid, std::dec,
" displayId=", displayId, " at (", x, ", ", y, ", ", z, ")");
}
@ -3440,7 +3754,7 @@ void Application::updateQuestMarkers() {
static int logCounter = 0;
if (++logCounter % 300 == 0) { // Log every ~10 seconds at 30fps
LOG_INFO("Quest markers: ", questStatuses.size(), " NPCs with quest status");
LOG_DEBUG("Quest markers: ", questStatuses.size(), " NPCs with quest status");
}
// Clear all markers (we'll re-add active ones)
@ -3493,7 +3807,7 @@ void Application::updateQuestMarkers() {
}
if (firstRun && markersAdded > 0) {
LOG_INFO("Quest markers: Added ", markersAdded, " markers on first run");
LOG_DEBUG("Quest markers: Added ", markersAdded, " markers on first run");
firstRun = false;
}
}