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

@ -162,6 +162,8 @@ private:
uint32_t gryphonDisplayId_ = 0;
uint32_t wyvernDisplayId_ = 0;
bool lastTaxiFlight_ = false;
float taxiLandingClampTimer_ = 0.0f;
float worldEntryMovementGraceTimer_ = 0.0f;
float taxiStreamCooldown_ = 0.0f;
bool idleYawned_ = false;
@ -193,7 +195,7 @@ private:
float x, y, z, orientation;
};
std::vector<PendingCreatureSpawn> pendingCreatureSpawns_;
static constexpr int MAX_SPAWNS_PER_FRAME = 24;
static constexpr int MAX_SPAWNS_PER_FRAME = 96;
static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300;
std::unordered_set<uint64_t> pendingCreatureSpawnGuids_;
std::unordered_map<uint64_t, uint16_t> creatureSpawnRetryCounts_;

View file

@ -29,7 +29,7 @@ public:
size_t getAvailableRAM() const;
/**
* Get recommended cache budget (30% of available RAM)
* Get recommended cache budget (80% of available RAM, capped at 90% of total RAM)
*/
size_t getRecommendedCacheBudget() const;

View file

@ -623,6 +623,8 @@ public:
void activateTaxi(uint32_t destNodeId);
bool isOnTaxiFlight() const { return onTaxiFlight_; }
bool isTaxiMountActive() const { return taxiMountActive_; }
bool isTaxiActivationPending() const { return taxiActivatePending_; }
void forceClearTaxiAndMovementState();
const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; }
uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; }
@ -927,6 +929,8 @@ private:
uint32_t pingSequence = 0; // Ping sequence number (increments)
float timeSinceLastPing = 0.0f; // Time since last ping sent (seconds)
float pingInterval = 30.0f; // Ping interval (30 seconds)
float timeSinceLastMoveHeartbeat_ = 0.0f; // Periodic movement heartbeat to keep server position synced
float moveHeartbeatInterval_ = 0.5f;
uint32_t lastLatency = 0; // Last measured latency (milliseconds)
// Player GUID and map
@ -1086,6 +1090,7 @@ private:
float taxiActivateTimer_ = 0.0f;
bool taxiClientActive_ = false;
float taxiLandingCooldown_ = 0.0f; // Prevent re-entering taxi right after landing
float taxiStartGrace_ = 0.0f; // Ignore transient landing/dismount checks right after takeoff
size_t taxiClientIndex_ = 0;
std::vector<glm::vec3> taxiClientPath_;
float taxiClientSpeed_ = 32.0f;

View file

@ -433,6 +433,9 @@ struct MovementInfo {
*/
class MovementPacket {
public:
static void writePackedGuid(network::Packet& packet, uint64_t guid);
static void writeMovementPayload(network::Packet& packet, const MovementInfo& info);
/**
* Build a movement packet
*

View file

@ -172,6 +172,15 @@ private:
std::optional<float> cachedCamWmoFloor;
bool hasCachedCamFloor = false;
// Terrain-aware camera pivot lift cache (throttled for performance).
glm::vec3 lastPivotLiftQueryPos_ = glm::vec3(0.0f);
float lastPivotLiftDistance_ = 0.0f;
int pivotLiftQueryCounter_ = 0;
float cachedPivotLift_ = 0.0f;
static constexpr int PIVOT_LIFT_QUERY_INTERVAL = 3;
static constexpr float PIVOT_LIFT_POS_THRESHOLD = 0.5f;
static constexpr float PIVOT_LIFT_DIST_THRESHOLD = 0.5f;
// Cached floor height queries (update every 5 frames or 2 unit movement)
glm::vec3 lastFloorQueryPos = glm::vec3(0.0f);
std::optional<float> cachedFloorHeight;

4
restart-worldserver.sh Normal file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker restart ac-worldserver

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;
}
}

View file

@ -2,11 +2,31 @@
#include "core/logger.hpp"
#include <fstream>
#include <string>
#include <sstream>
#include <sys/sysinfo.h>
namespace wowee {
namespace core {
namespace {
size_t readMemAvailableBytesFromProc() {
std::ifstream meminfo("/proc/meminfo");
if (!meminfo.is_open()) return 0;
std::string line;
while (std::getline(meminfo, line)) {
// Format: "MemAvailable: 123456789 kB"
if (line.rfind("MemAvailable:", 0) != 0) continue;
std::istringstream iss(line.substr(13));
size_t kb = 0;
iss >> kb;
if (kb > 0) return kb * 1024ull;
break;
}
return 0;
}
} // namespace
MemoryMonitor& MemoryMonitor::getInstance() {
static MemoryMonitor instance;
return instance;
@ -25,10 +45,18 @@ void MemoryMonitor::initialize() {
}
size_t MemoryMonitor::getAvailableRAM() const {
// Best source on Linux for reclaimable memory headroom.
if (size_t memAvailable = readMemAvailableBytesFromProc(); memAvailable > 0) {
return memAvailable;
}
struct sysinfo info;
if (sysinfo(&info) == 0) {
// Available = free + buffers + cached
return static_cast<size_t>(info.freeram) * info.mem_unit;
// Fallback approximation if /proc/meminfo is unavailable.
size_t freeBytes = static_cast<size_t>(info.freeram) * info.mem_unit;
size_t bufferBytes = static_cast<size_t>(info.bufferram) * info.mem_unit;
size_t available = freeBytes + bufferBytes;
return (totalRAM_ > 0 && available > totalRAM_) ? totalRAM_ : available;
}
return totalRAM_ / 2; // Fallback: assume 50% available
}

View file

@ -160,6 +160,7 @@ void GameHandler::update(float deltaTime) {
// Send periodic heartbeat if in world
if (state == WorldState::IN_WORLD) {
timeSinceLastPing += deltaTime;
timeSinceLastMoveHeartbeat_ += deltaTime;
if (timeSinceLastPing >= pingInterval) {
if (socket) {
@ -168,6 +169,11 @@ void GameHandler::update(float deltaTime) {
timeSinceLastPing = 0.0f;
}
if (timeSinceLastMoveHeartbeat_ >= moveHeartbeatInterval_) {
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
timeSinceLastMoveHeartbeat_ = 0.0f;
}
// Update cast timer (Phase 3)
if (casting && castTimeRemaining > 0.0f) {
castTimeRemaining -= deltaTime;
@ -203,6 +209,9 @@ void GameHandler::update(float deltaTime) {
if (taxiLandingCooldown_ > 0.0f) {
taxiLandingCooldown_ -= deltaTime;
}
if (taxiStartGrace_ > 0.0f) {
taxiStartGrace_ -= deltaTime;
}
// Taxi logic timing
auto taxiStart = std::chrono::high_resolution_clock::now();
@ -215,7 +224,8 @@ void GameHandler::update(float deltaTime) {
if (unit &&
(unit->getUnitFlags() & 0x00000100) == 0 &&
!taxiClientActive_ &&
!taxiActivatePending_) {
!taxiActivatePending_ &&
taxiStartGrace_ <= 0.0f) {
onTaxiFlight_ = false;
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
if (taxiMountActive_ && mountCallback_) {
@ -247,7 +257,7 @@ void GameHandler::update(float deltaTime) {
serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0;
}
if (serverStillTaxi || taxiClientActive_ || taxiActivatePending_) {
if (taxiStartGrace_ > 0.0f || serverStillTaxi || taxiClientActive_ || taxiActivatePending_) {
onTaxiFlight_ = true;
} else {
if (mountCallback_) mountCallback_(0);
@ -264,6 +274,25 @@ void GameHandler::update(float deltaTime) {
}
}
// Keep non-taxi mount state server-authoritative.
// Some server paths don't emit explicit mount field updates in lockstep
// with local visual state changes, so reconcile continuously.
if (!onTaxiFlight_ && !taxiMountActive_) {
auto playerEntity = entityManager.getEntity(playerGuid);
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
if (playerUnit) {
uint32_t serverMountDisplayId = playerUnit->getMountDisplayId();
if (serverMountDisplayId != currentMountDisplayId_) {
LOG_INFO("Mount reconcile: server=", serverMountDisplayId,
" local=", currentMountDisplayId_);
currentMountDisplayId_ = serverMountDisplayId;
if (mountCallback_) {
mountCallback_(serverMountDisplayId);
}
}
}
}
if (taxiRecoverPending_ && state == WorldState::IN_WORLD) {
auto playerEntity = entityManager.getEntity(playerGuid);
if (playerEntity) {
@ -408,7 +437,7 @@ void GameHandler::update(float deltaTime) {
// Log profiling every 60 frames
if (++profileCounter >= 60) {
LOG_INFO("UPDATE PROFILE (60 frames): socket=", socketTime / 60.0f, "ms taxi=", taxiTime / 60.0f,
LOG_DEBUG("UPDATE PROFILE (60 frames): socket=", socketTime / 60.0f, "ms taxi=", taxiTime / 60.0f,
"ms distance=", distanceCheckTime / 60.0f, "ms entity=", entityUpdateTime / 60.0f,
"ms TOTAL=", totalTime / 60.0f, "ms");
profileCounter = 0;
@ -1603,6 +1632,20 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
movementInfo.flags = 0;
movementInfo.flags2 = 0;
movementInfo.time = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
onTaxiFlight_ = false;
taxiMountActive_ = false;
taxiActivatePending_ = false;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
taxiStartGrace_ = 0.0f;
currentMountDisplayId_ = 0;
taxiMountDisplayId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
// Send CMSG_SET_ACTIVE_MOVER (required by some servers)
if (playerGuid != 0 && socket) {
@ -1705,9 +1748,17 @@ void GameHandler::sendMovement(Opcode opcode) {
return;
}
// Block manual movement while taxi is active/mounted, but still allow heartbeat packets.
if ((onTaxiFlight_ || taxiMountActive_) && opcode != Opcode::CMSG_MOVE_HEARTBEAT) return;
if (resurrectPending_) return;
// Block manual movement while taxi is active/mounted, but always allow
// stop/heartbeat opcodes so stuck states can be recovered.
bool taxiAllowed =
(opcode == Opcode::CMSG_MOVE_HEARTBEAT) ||
(opcode == Opcode::CMSG_MOVE_STOP) ||
(opcode == Opcode::CMSG_MOVE_STOP_STRAFE) ||
(opcode == Opcode::CMSG_MOVE_STOP_TURN) ||
(opcode == Opcode::CMSG_MOVE_STOP_SWIM) ||
(opcode == Opcode::CMSG_MOVE_FALL_LAND);
if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return;
if (resurrectPending_ && !taxiAllowed) return;
// Use real millisecond timestamp (server validates for anti-cheat)
static auto startTime = std::chrono::steady_clock::now();
@ -1817,6 +1868,45 @@ void GameHandler::sendMovement(Opcode opcode) {
socket->send(packet);
}
void GameHandler::forceClearTaxiAndMovementState() {
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
taxiStartGrace_ = 0.0f;
onTaxiFlight_ = false;
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0);
}
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
currentMountDisplayId_ = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
playerDead_ = false;
releasedSpirit_ = false;
repopPending_ = false;
pendingSpiritHealerGuid_ = 0;
resurrectCasterGuid_ = 0;
movementInfo.flags = 0;
movementInfo.flags2 = 0;
movementInfo.transportGuid = 0;
clearPlayerTransport();
if (socket && state == WorldState::IN_WORLD) {
sendMovement(Opcode::CMSG_MOVE_STOP);
sendMovement(Opcode::CMSG_MOVE_STOP_STRAFE);
sendMovement(Opcode::CMSG_MOVE_STOP_TURN);
sendMovement(Opcode::CMSG_MOVE_STOP_SWIM);
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
}
LOG_INFO("Force-cleared taxi/movement state");
}
void GameHandler::setPosition(float x, float y, float z) {
movementInfo.x = x;
movementInfo.y = y;
@ -2001,6 +2091,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100;
if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) {
onTaxiFlight_ = true;
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
applyTaxiMountForCurrentNode();
}
}
@ -2559,6 +2650,15 @@ void GameHandler::handleDestroyObject(network::Packet& packet) {
LOG_INFO("Ignoring destroy for transport entity: 0x", std::hex, data.guid, std::dec);
return;
}
// Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal.
auto entity = entityManager.getEntity(data.guid);
if (entity) {
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(data.guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(data.guid);
}
}
entityManager.removeEntity(data.guid);
LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec,
" (", (data.isDeath ? "death" : "despawn"), ")");
@ -4114,7 +4214,20 @@ void GameHandler::handleAttackStop(network::Packet& packet) {
}
void GameHandler::dismount() {
if (!isMounted() || !socket) return;
if (!socket) return;
if (!isMounted()) {
// Local/server desync guard: clear visual mount even when server says unmounted.
if (mountCallback_) {
mountCallback_(0);
}
currentMountDisplayId_ = 0;
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
onTaxiFlight_ = false;
taxiActivatePending_ = false;
taxiClientActive_ = false;
LOG_INFO("Dismount desync recovery: force-cleared local mount state");
}
network::Packet pkt(static_cast<uint16_t>(Opcode::CMSG_CANCEL_MOUNT_AURA));
socket->send(pkt);
LOG_INFO("Sent CMSG_CANCEL_MOUNT_AURA");
@ -4144,20 +4257,27 @@ void GameHandler::handleForceRunSpeedChange(network::Packet& packet) {
if (guid != playerGuid) return;
// Always ACK the speed change to prevent server stall
// Always ACK the speed change to prevent server stall.
// Packet format mirrors movement packets: packed guid + counter + movement info + new speed.
if (socket) {
network::Packet ack(static_cast<uint16_t>(Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK));
ack.writeUInt64(playerGuid);
MovementPacket::writePackedGuid(ack, playerGuid);
ack.writeUInt32(counter);
// MovementInfo (minimal — no flags set means no optional fields)
ack.writeUInt32(0); // moveFlags
ack.writeUInt16(0); // moveFlags2
ack.writeUInt32(movementTime);
ack.writeFloat(movementInfo.x);
ack.writeFloat(movementInfo.y);
ack.writeFloat(movementInfo.z);
ack.writeFloat(movementInfo.orientation);
ack.writeUInt32(0); // fallTime
MovementInfo wire = movementInfo;
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
wire.x = serverPos.x;
wire.y = serverPos.y;
wire.z = serverPos.z;
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
glm::vec3 serverTransport =
core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ));
wire.transportX = serverTransport.x;
wire.transportY = serverTransport.y;
wire.transportZ = serverTransport.z;
}
MovementPacket::writeMovementPayload(ack, wire);
ack.writeFloat(newSpeed);
socket->send(ack);
}
@ -5899,6 +6019,21 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
movementInfo.z = canonical.z;
movementInfo.orientation = orientation;
movementInfo.flags = 0;
movementInfo.flags2 = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
onTaxiFlight_ = false;
taxiMountActive_ = false;
taxiActivatePending_ = false;
taxiClientActive_ = false;
taxiClientPath_.clear();
taxiRecoverPending_ = false;
taxiStartGrace_ = 0.0f;
currentMountDisplayId_ = 0;
taxiMountDisplayId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
// Clear world state for the new map
entityManager.clear();
@ -6359,6 +6494,13 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
return;
}
// Guard against stray/mis-mapped packets being treated as taxi replies.
// Only honor taxi replies when a taxi flow is actually active.
if (!taxiActivatePending_ && !taxiWindowOpen_ && !onTaxiFlight_) {
LOG_DEBUG("Ignoring stray taxi reply: result=", data.result);
return;
}
if (data.result == 0) {
// Some cores can emit duplicate success replies (e.g. basic + express activate).
// Ignore repeats once taxi is already active and no activation is pending.
@ -6366,6 +6508,7 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
return;
}
onTaxiFlight_ = true;
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
taxiWindowOpen_ = false;
taxiActivatePending_ = false;
taxiActivateTimer_ = 0.0f;
@ -6391,6 +6534,12 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
void GameHandler::closeTaxi() {
taxiWindowOpen_ = false;
// Closing the taxi UI must not cancel an active/pending flight.
// The window can auto-close due distance checks while takeoff begins.
if (taxiActivatePending_ || onTaxiFlight_ || taxiClientActive_) {
return;
}
// If we optimistically mounted during node selection, dismount now
if (taxiMountActive_ && mountCallback_) {
mountCallback_(0); // Dismount
@ -6444,6 +6593,11 @@ uint32_t GameHandler::getTaxiCostTo(uint32_t destNodeId) const {
void GameHandler::activateTaxi(uint32_t destNodeId) {
if (!socket || state != WorldState::IN_WORLD) return;
// One-shot taxi activation until server replies or timeout.
if (taxiActivatePending_ || onTaxiFlight_) {
return;
}
uint32_t startNode = currentTaxiData_.nearestNode;
if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return;
@ -6514,13 +6668,14 @@ void GameHandler::activateTaxi(uint32_t destNodeId) {
auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId);
socket->send(basicPkt);
// Others accept express with a full node path + cost.
auto pkt = ActivateTaxiExpressPacket::build(taxiNpcGuid_, totalCost, path);
socket->send(pkt);
// AzerothCore in this setup rejects/misparses CMSG_ACTIVATETAXIEXPRESS (0x312),
// so keep taxi activation on the basic packet only.
// Optimistically start taxi visuals; server will correct if it denies.
taxiWindowOpen_ = false;
taxiActivatePending_ = true;
taxiActivateTimer_ = 0.0f;
taxiStartGrace_ = 2.0f;
if (!onTaxiFlight_) {
onTaxiFlight_ = true;
applyTaxiMountForCurrentNode();

View file

@ -371,7 +371,7 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float
if (debugFrameCount++ % 120 == 0) {
// Log canonical position AND render position to check coordinate conversion
glm::vec3 renderPos = core::coords::canonicalToRender(transport.position);
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec,
" pathTime=", pathTimeMs, "ms / ", path.durationMs, "ms",
" canonicalPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")",
" renderPos=(", renderPos.x, ", ", renderPos.y, ", ", renderPos.z, ")",

View file

@ -523,23 +523,12 @@ bool PongParser::parse(network::Packet& packet, PongData& data) {
return true;
}
network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, uint64_t playerGuid) {
network::Packet packet(static_cast<uint16_t>(opcode));
// Movement packet format (WoW 3.3.5a):
// packed GUID
// uint32 flags
// uint16 flags2
// uint32 time
// float x, y, z
// float orientation
// Write packed GUID
void MovementPacket::writePackedGuid(network::Packet& packet, uint64_t guid) {
uint8_t mask = 0;
uint8_t guidBytes[8];
int guidByteCount = 0;
for (int i = 0; i < 8; i++) {
uint8_t byte = static_cast<uint8_t>((playerGuid >> (i * 8)) & 0xFF);
uint8_t byte = static_cast<uint8_t>((guid >> (i * 8)) & 0xFF);
if (byte != 0) {
mask |= (1 << i);
guidBytes[guidByteCount++] = byte;
@ -549,6 +538,15 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u
for (int i = 0; i < guidByteCount; i++) {
packet.writeUInt8(guidBytes[i]);
}
}
void MovementPacket::writeMovementPayload(network::Packet& packet, const MovementInfo& info) {
// Movement packet format (WoW 3.3.5a) payload:
// uint32 flags
// uint16 flags2
// uint32 time
// float x, y, z
// float orientation
// Write movement flags
packet.writeUInt32(info.flags);
@ -617,6 +615,15 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u
packet.writeBytes(reinterpret_cast<const uint8_t*>(&info.jumpCosAngle), sizeof(float));
packet.writeBytes(reinterpret_cast<const uint8_t*>(&info.jumpXYSpeed), sizeof(float));
}
}
network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, uint64_t playerGuid) {
network::Packet packet(static_cast<uint16_t>(opcode));
// Movement packet format (WoW 3.3.5a):
// packed GUID + movement payload
writePackedGuid(packet, playerGuid);
writeMovementPayload(packet, info);
// Detailed hex dump for debugging
static int mvLog = 5;
@ -828,16 +835,27 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
block.z = packet.readFloat();
block.onTransport = (transportGuid != 0);
block.transportGuid = transportGuid;
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
float tx = packet.readFloat();
float ty = packet.readFloat();
float tz = packet.readFloat();
if (block.onTransport) {
block.transportX = tx;
block.transportY = ty;
block.transportZ = tz;
} else {
block.transportX = 0.0f;
block.transportY = 0.0f;
block.transportZ = 0.0f;
}
block.orientation = packet.readFloat();
/*float corpseOrientation =*/ packet.readFloat();
block.hasMovement = true;
LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec,
" pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation,
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
if (block.onTransport) {
LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec,
" pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation,
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
}
}
else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) {
// Simple stationary position (4 floats)
@ -895,9 +913,9 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
}
uint32_t fieldsCapacity = blockCount * 32;
LOG_INFO(" UPDATE MASK PARSE:");
LOG_INFO(" maskBlockCount = ", (int)blockCount);
LOG_INFO(" fieldsCapacity (blocks * 32) = ", fieldsCapacity);
LOG_DEBUG(" UPDATE MASK PARSE:");
LOG_DEBUG(" maskBlockCount = ", (int)blockCount);
LOG_DEBUG(" fieldsCapacity (blocks * 32) = ", fieldsCapacity);
// Read update mask
std::vector<uint32_t> updateMask(blockCount);
@ -932,11 +950,11 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
size_t bytesUsed = endPos - startPos;
size_t bytesRemaining = packet.getSize() - endPos;
LOG_INFO(" highestSetBitIndex = ", highestSetBit);
LOG_INFO(" valuesReadCount = ", valuesReadCount);
LOG_INFO(" bytesUsedForFields = ", bytesUsed);
LOG_INFO(" bytesRemainingInPacket = ", bytesRemaining);
LOG_INFO(" Parsed ", block.fields.size(), " fields");
LOG_DEBUG(" highestSetBitIndex = ", highestSetBit);
LOG_DEBUG(" valuesReadCount = ", valuesReadCount);
LOG_DEBUG(" bytesUsedForFields = ", bytesUsed);
LOG_DEBUG(" bytesRemainingInPacket = ", bytesRemaining);
LOG_DEBUG(" Parsed ", block.fields.size(), " fields");
return true;
}
@ -1009,9 +1027,9 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data)
// Read block count
data.blockCount = packet.readUInt32();
LOG_INFO("SMSG_UPDATE_OBJECT:");
LOG_INFO(" objectCount = ", data.blockCount);
LOG_INFO(" packetSize = ", packet.getSize());
LOG_DEBUG("SMSG_UPDATE_OBJECT:");
LOG_DEBUG(" objectCount = ", data.blockCount);
LOG_DEBUG(" packetSize = ", packet.getSize());
// Check for out-of-range objects first
if (packet.getReadPos() + 1 <= packet.getSize()) {

View file

@ -163,14 +163,23 @@ std::vector<uint8_t> AssetManager::readFile(const std::string& path) const {
fileCacheHits++;
return it->second.data;
}
fileCacheMisses++;
}
// Cache miss - decompress from MPQ.
// Keep MPQ reads serialized, but do not block cache-hit readers on this mutex.
// Cache miss path: serialize MPQ reads. Before reading, re-check cache while holding
// readMutex so only one thread performs decompression per hot path at a time.
std::vector<uint8_t> data;
{
std::lock_guard<std::mutex> readLock(readMutex);
{
std::lock_guard<std::mutex> cacheLock(cacheMutex);
auto it = fileCache.find(normalized);
if (it != fileCache.end()) {
it->second.lastAccessTime = ++fileCacheAccessCounter;
fileCacheHits++;
return it->second.data;
}
fileCacheMisses++;
}
data = mpqManager.readFile(normalized);
}
if (data.empty()) {

View file

@ -735,9 +735,45 @@ void CameraController::update(float deltaTime) {
}
// ===== WoW-style orbit camera =====
// Pivot point at upper chest/neck
// Pivot point at upper chest/neck.
float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f;
glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset);
float pivotLift = 0.0f;
if (terrainManager && !externalFollow_) {
float moved = glm::length(glm::vec2(targetPos.x - lastPivotLiftQueryPos_.x,
targetPos.y - lastPivotLiftQueryPos_.y));
float distDelta = std::abs(currentDistance - lastPivotLiftDistance_);
bool queryLift = (++pivotLiftQueryCounter_ >= PIVOT_LIFT_QUERY_INTERVAL) ||
(moved >= PIVOT_LIFT_POS_THRESHOLD) ||
(distDelta >= PIVOT_LIFT_DIST_THRESHOLD);
if (queryLift) {
pivotLiftQueryCounter_ = 0;
lastPivotLiftQueryPos_ = targetPos;
lastPivotLiftDistance_ = currentDistance;
// Estimate where camera sits horizontally and ensure enough terrain clearance.
glm::vec3 probeCam = targetPos + (-forward3D) * currentDistance;
auto terrainAtCam = terrainManager->getHeightAt(probeCam.x, probeCam.y);
auto terrainAtPivot = terrainManager->getHeightAt(targetPos.x, targetPos.y);
float desiredLift = 0.0f;
if (terrainAtCam) {
// Keep pivot high enough so near-hill camera rays don't cut through terrain.
constexpr float kMinRayClearance = 2.0f;
float basePivotZ = targetPos.z + PIVOT_HEIGHT + mountedOffset;
float rayClearance = basePivotZ - *terrainAtCam;
if (rayClearance < kMinRayClearance) {
desiredLift = std::clamp(kMinRayClearance - rayClearance, 0.0f, 1.4f);
}
}
// If character is already below local terrain sample, avoid lifting aggressively.
if (terrainAtPivot && targetPos.z < *terrainAtPivot - 0.2f) {
desiredLift = 0.0f;
}
cachedPivotLift_ = desiredLift;
}
pivotLift = cachedPivotLift_;
}
glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset + pivotLift);
// Camera direction from yaw/pitch (already computed as forward3D)
glm::vec3 camDir = -forward3D; // Camera looks at pivot, so it's behind

View file

@ -177,7 +177,7 @@ void Celestial::renderSun(const Camera& camera, float timeOfDay,
// Create model matrix
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, sunPos);
model = glm::scale(model, glm::vec3(500.0f, 500.0f, 1.0f)); // Large and visible
model = glm::scale(model, glm::vec3(95.0f, 95.0f, 1.0f)); // Match WotLK-like apparent size
// Set uniforms
glm::mat4 view = camera.getViewMatrix();

View file

@ -986,7 +986,7 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
static int logCounter = 0;
if (++logCounter >= 300) { // Log every 10 seconds at 30fps
LOG_INFO("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (",
LOG_DEBUG("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (",
instances.size() - updatedCount, " culled)");
logCounter = 0;
}
@ -1584,7 +1584,17 @@ void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) {
}
void CharacterRenderer::removeInstance(uint32_t instanceId) {
instances.erase(instanceId);
auto it = instances.find(instanceId);
if (it == instances.end()) return;
// Remove child attachments first (helmets/weapons), otherwise they leak as
// orphan render instances when the parent creature despawns.
auto attachments = it->second.weaponAttachments;
for (const auto& wa : attachments) {
removeInstance(wa.weaponInstanceId);
}
instances.erase(it);
}
bool CharacterRenderer::getAnimationState(uint32_t instanceId, uint32_t& animationId,

View file

@ -2053,7 +2053,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
static int frameCounter = 0;
if (++frameCounter >= 120) {
frameCounter = 0;
LOG_INFO("M2 Render: ", totalMs, " ms (culling/sort: ", cullingSortMs,
LOG_DEBUG("M2 Render: ", totalMs, " ms (culling/sort: ", cullingSortMs,
" ms, draw: ", drawLoopMs, " ms) | ", sortedVisible_.size(), " visible | ",
totalBatchesDrawn, " batches | ", boneMatrixUploads, " bone uploads");
}

View file

@ -2143,7 +2143,7 @@ void Renderer::update(float deltaTime) {
// Log renderer profiling every 60 frames
if (++rendProfileCounter >= 60) {
LOG_INFO("RENDERER UPDATE PROFILE (60 frames): camera=", camTime / 60.0f,
LOG_DEBUG("RENDERER UPDATE PROFILE (60 frames): camera=", camTime / 60.0f,
"ms light=", lightTime / 60.0f, "ms charAnim=", charAnimTime / 60.0f,
"ms terrain=", terrainTime / 60.0f, "ms sky=", skyTime / 60.0f,
"ms charRend=", charRendTime / 60.0f, "ms audio=", audioTime / 60.0f,

View file

@ -30,9 +30,58 @@
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <cctype>
#include <unordered_set>
namespace {
std::string trim(const std::string& s) {
size_t first = s.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return "";
size_t last = s.find_last_not_of(" \t\r\n");
return s.substr(first, last - first + 1);
}
std::string toLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return s;
}
bool isPortBotTarget(const std::string& target) {
std::string t = toLower(trim(target));
return t == "portbot" || t == "gmbot" || t == "telebot";
}
std::string buildPortBotCommand(const std::string& rawInput) {
std::string input = trim(rawInput);
if (input.empty()) return "";
std::string lower = toLower(input);
if (lower == "help" || lower == "?") {
return "__help__";
}
if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) {
return input;
}
if (lower.rfind("xyz ", 0) == 0) {
return ".go " + input;
}
if (lower == "sw" || lower == "stormwind") return ".tele stormwind";
if (lower == "if" || lower == "ironforge") return ".tele ironforge";
if (lower == "darn" || lower == "darnassus") return ".tele darnassus";
if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar";
if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff";
if (lower == "uc" || lower == "undercity") return ".tele undercity";
if (lower == "shatt" || lower == "shattrath") return ".tele shattrath";
if (lower == "dal" || lower == "dalaran") return ".tele dalaran";
return ".tele " + input;
}
bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) {
glm::vec3 oc = ray.origin - center;
float b = glm::dot(oc, ray.direction);
@ -1200,13 +1249,42 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return;
}
// /who command
if (cmdLower == "who") {
std::string playerName;
// /who commands
if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") {
std::string query;
if (spacePos != std::string::npos) {
playerName = command.substr(spacePos + 1);
query = command.substr(spacePos + 1);
// Trim leading/trailing whitespace
size_t first = query.find_first_not_of(" \t\r\n");
if (first == std::string::npos) {
query.clear();
} else {
size_t last = query.find_last_not_of(" \t\r\n");
query = query.substr(first, last - first + 1);
}
}
gameHandler.queryWho(playerName);
if ((cmdLower == "whois") && query.empty()) {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /whois <playerName>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
if (cmdLower == "who" && (query == "help" || query == "?")) {
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Who commands: /who [name/filter], /whois <name>, /online";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
gameHandler.queryWho(query);
chatInputBuffer[0] = '\0';
return;
}
@ -1953,6 +2031,26 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
}
}
// Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands.
if (type == game::ChatType::WHISPER && isPortBotTarget(target)) {
std::string cmd = buildPortBotCommand(message);
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
if (cmd.empty() || cmd == "__help__") {
msg.message = "PortBot: /w PortBot <dest>. Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'.";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
gameHandler.sendChatMessage(game::ChatType::SAY, cmd, "");
msg.message = "PortBot executed: " + cmd;
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// Validate whisper has a target
if (type == game::ChatType::WHISPER && target.empty()) {
game::MessageChatData msg;