fix: stabilize turtle world entry session handling

This commit is contained in:
Kelsi 2026-03-15 01:21:23 -07:00
parent 4dba20b757
commit b0fafe5efa
20 changed files with 2283 additions and 1380 deletions

View file

@ -249,6 +249,7 @@
"SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC",
"SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED",
"CMSG_BATTLEMASTER_JOIN": "0x2EE",
"SMSG_ADDON_INFO": "0x2EF",
"CMSG_EMOTE": "0x102",
"SMSG_EMOTE": "0x103",
"CMSG_TEXT_EMOTE": "0x104",

View file

@ -224,6 +224,7 @@ private:
std::future<PreparedCreatureModel> future;
};
std::vector<AsyncCreatureLoad> asyncCreatureLoads_;
std::unordered_set<uint32_t> asyncCreatureDisplayLoads_; // displayIds currently loading in background
void processAsyncCreatureResults(bool unlimited = false);
static constexpr int MAX_ASYNC_CREATURE_LOADS = 4; // concurrent background loads
std::unordered_set<uint64_t> deadCreatureGuids_; // GUIDs that should spawn in corpse/death pose
@ -280,7 +281,17 @@ private:
float z = 0.0f;
float orientation = 0.0f;
};
struct PendingTransportRegistration {
uint64_t guid = 0;
uint32_t entry = 0;
uint32_t displayId = 0;
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
float orientation = 0.0f;
};
std::unordered_map<uint64_t, PendingTransportMove> pendingTransportMoves_; // guid -> latest pre-registration move
std::deque<PendingTransportRegistration> pendingTransportRegistrations_;
uint32_t nextGameObjectModelId_ = 20000;
uint32_t nextGameObjectWmoModelId_ = 40000;
bool testTransportSetup_ = false;
@ -433,6 +444,7 @@ private:
};
std::vector<PendingTransportDoodadBatch> pendingTransportDoodadBatches_;
static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4;
void processPendingTransportRegistrations();
void processPendingTransportDoodads();
// Quest marker billboard sprites (above NPCs)

View file

@ -7,6 +7,7 @@
#include "game/inventory.hpp"
#include "game/spell_defines.hpp"
#include "game/group_defines.hpp"
#include "network/packet.hpp"
#include <glm/glm.hpp>
#include <memory>
#include <string>
@ -2089,6 +2090,15 @@ private:
* Handle incoming packet from world server
*/
void handlePacket(network::Packet& packet);
void enqueueIncomingPacket(const network::Packet& packet);
void enqueueIncomingPacketFront(network::Packet&& packet);
void processQueuedIncomingPackets();
void enqueueUpdateObjectWork(UpdateObjectData&& data);
void processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start,
float budgetMs);
void processOutOfRangeObjects(const std::vector<uint64_t>& guids);
void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated);
void finalizeUpdateObjectBatch(bool newItemCreated);
/**
* Handle SMSG_AUTH_CHALLENGE from server
@ -2413,6 +2423,14 @@ private:
// Network
std::unique_ptr<network::WorldSocket> socket;
std::deque<network::Packet> pendingIncomingPackets_;
struct PendingUpdateObjectWork {
UpdateObjectData data;
size_t nextBlockIndex = 0;
bool outOfRangeProcessed = false;
bool newItemCreated = false;
};
std::deque<PendingUpdateObjectWork> pendingUpdateObjectWork_;
// State
WorldState state = WorldState::DISCONNECTED;

View file

@ -49,12 +49,14 @@ public:
/** Number of mapped opcodes. */
size_t size() const { return logicalToWire_.size(); }
/** Get canonical enum name for a logical opcode. */
static const char* logicalToName(LogicalOpcode op);
private:
std::unordered_map<uint16_t, uint16_t> logicalToWire_; // LogicalOpcode → wire
std::unordered_map<uint16_t, uint16_t> wireToLogical_; // wire → LogicalOpcode
static std::optional<LogicalOpcode> nameToLogical(const std::string& name);
static const char* logicalToName(LogicalOpcode op);
};
/**

View file

@ -451,6 +451,7 @@ public:
class TurtlePacketParsers : public ClassicPacketParsers {
public:
uint8_t movementFlags2Size() const override { return 0; }
bool parseUpdateObject(network::Packet& packet, UpdateObjectData& data) override;
bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override;
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override;
};

View file

@ -7,7 +7,13 @@
#include "auth/vanilla_crypt.hpp"
#include <functional>
#include <vector>
#include <deque>
#include <cstdint>
#include <chrono>
#include <thread>
#include <mutex>
#include <atomic>
#include <string>
namespace wowee {
namespace network {
@ -66,6 +72,8 @@ public:
*/
void initEncryption(const std::vector<uint8_t>& sessionKey, uint32_t build = 12340);
void tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason);
/**
* Check if header encryption is enabled
*/
@ -76,11 +84,23 @@ private:
* Try to parse complete packets from receive buffer
*/
void tryParsePackets();
void pumpNetworkIO();
void dispatchQueuedPackets();
void asyncPumpLoop();
void startAsyncPump();
void stopAsyncPump();
void closeSocketNoJoin();
socket_t sockfd = INVALID_SOCK;
bool connected = false;
bool encryptionEnabled = false;
bool useVanillaCrypt = false; // true = XOR cipher, false = RC4
bool useAsyncPump_ = true;
std::thread asyncPumpThread_;
std::atomic<bool> asyncPumpStop_{false};
std::atomic<bool> asyncPumpRunning_{false};
mutable std::mutex ioMutex_;
mutable std::mutex callbackMutex_;
// WotLK RC4 ciphers for header encryption/decryption
auth::RC4 encryptCipher;
@ -94,6 +114,8 @@ private:
size_t receiveReadOffset_ = 0;
// Optional reused packet queue (feature-gated) to reduce per-update allocations.
std::vector<Packet> parsedPacketsScratch_;
// Parsed packets waiting for callback dispatch; drained with a strict per-update budget.
std::deque<Packet> pendingPacketCallbacks_;
// Runtime-gated network optimization toggles (default off).
bool useFastRecvAppend_ = false;
@ -105,6 +127,9 @@ private:
// Debug-only tracing window for post-auth packet framing verification.
int headerTracePacketsLeft = 0;
std::chrono::steady_clock::time_point packetTraceStart_{};
std::chrono::steady_clock::time_point packetTraceUntil_{};
std::string packetTraceReason_;
// Packet callback
std::function<void(const Packet&)> packetCallback;

View file

@ -296,7 +296,9 @@ private:
std::unordered_map<VkTexture*, bool> textureColorKeyBlackByPtr_;
std::unordered_map<std::string, VkTexture*> compositeCache_; // key → texture for reuse
std::unordered_set<std::string> failedTextureCache_; // negative cache for budget exhaustion
std::unordered_map<std::string, uint64_t> failedTextureRetryAt_;
std::unordered_set<std::string> loggedTextureLoadFails_; // dedup warning logs
uint64_t textureLookupSerial_ = 0;
size_t textureCacheBytes_ = 0;
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024;

View file

@ -477,7 +477,9 @@ private:
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024;
std::unordered_set<std::string> failedTextureCache_;
std::unordered_map<std::string, uint64_t> failedTextureRetryAt_;
std::unordered_set<std::string> loggedTextureLoadFails_;
uint64_t textureLookupSerial_ = 0;
uint32_t textureBudgetRejectWarnings_ = 0;
std::unique_ptr<VkTexture> whiteTexture_;
std::unique_ptr<VkTexture> glowTexture_;

View file

@ -671,7 +671,9 @@ private:
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 8192ull * 1024 * 1024; // 8 GB default, overridden at init
std::unordered_set<std::string> failedTextureCache_;
std::unordered_map<std::string, uint64_t> failedTextureRetryAt_;
std::unordered_set<std::string> loggedTextureLoadFails_;
uint64_t textureLookupSerial_ = 0;
uint32_t textureBudgetRejectWarnings_ = 0;
// Default white texture

View file

@ -824,6 +824,7 @@ void Application::logoutToLogin() {
if (load.future.valid()) load.future.wait();
}
asyncCreatureLoads_.clear();
asyncCreatureDisplayLoads_.clear();
// --- Creature spawn queues ---
pendingCreatureSpawns_.clear();
@ -842,6 +843,7 @@ void Application::logoutToLogin() {
gameObjectInstances_.clear();
pendingGameObjectSpawns_.clear();
pendingTransportMoves_.clear();
pendingTransportRegistrations_.clear();
pendingTransportDoodadBatches_.clear();
world.reset();
@ -1053,6 +1055,7 @@ void Application::update(float deltaTime) {
updateCheckpoint = "in_game: gameobject/transport queues";
runInGameStage("gameobject/transport queues", [&] {
processGameObjectSpawnQueue();
processPendingTransportRegistrations();
processPendingTransportDoodads();
});
inGameStep = "pending mount";
@ -1725,6 +1728,19 @@ void Application::update(float deltaTime) {
break;
}
if (pendingWorldEntry_ && !loadingWorld_ && state != AppState::DISCONNECTED) {
auto entry = *pendingWorldEntry_;
pendingWorldEntry_.reset();
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(1.0f);
}
loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z);
}
// Update renderer (camera, etc.) only when in-game
updateCheckpoint = "renderer update";
if (renderer && state == AppState::IN_GAME) {
@ -2032,17 +2048,12 @@ void Application::setupUICallbacks() {
return;
}
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
// Stop any movement that was active before the teleport
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(1.0f);
}
loadOnlineWorldTerrain(mapId, x, y, z);
// loadedMapId_ is set inside loadOnlineWorldTerrain (including
// any deferred entries it processes), so we must NOT override it here.
// Full world loads are expensive and `loadOnlineWorldTerrain()` itself
// drives `gameHandler->update()` during warmup. Queue the load here so
// it runs after the current packet handler returns instead of recursing
// from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`.
LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")");
pendingWorldEntry_ = {mapId, x, y, z};
});
auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional<float> {
@ -2712,133 +2723,28 @@ void Application::setupUICallbacks() {
// Transport spawn callback (online mode) - register transports with TransportManager
gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
auto* transportManager = gameHandler->getTransportManager();
if (!transportManager || !renderer) return;
if (!renderer) return;
// Get the WMO instance ID from the GameObject spawn
// Get the GameObject instance now so late queue processing can rely on stable IDs.
auto it = gameObjectInstances_.find(guid);
if (it == gameObjectInstances_.end()) {
LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec);
return;
}
uint32_t wmoInstanceId = it->second.instanceId;
LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId,
" pos=(", x, ", ", y, ", ", z, ")");
// TransportAnimation.dbc is indexed by GameObject entry
uint32_t pathId = entry;
const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid);
bool clientAnim = transportManager->isClientSideAnimation();
LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim,
" guid=0x", std::hex, guid, std::dec, " entry=", entry, " pathId=", pathId,
" preferServer=", preferServerData);
// Coordinates are already canonical (converted in game_handler.cpp when entity was created)
glm::vec3 canonicalSpawnPos(x, y, z);
// Check if we have a real path from TransportAnimation.dbc (indexed by entry).
// AzerothCore transport entries are not always 1:1 with DBC path ids.
const bool shipOrZeppelinDisplay =
(displayId == 3015 || displayId == 3031 || displayId == 7546 ||
displayId == 7446 || displayId == 1587 || displayId == 2454 ||
displayId == 807 || displayId == 808);
bool hasUsablePath = transportManager->hasPathForEntry(entry);
if (shipOrZeppelinDisplay) {
// For true transports, reject tiny XY tracks that effectively look stationary.
hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f);
}
LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath,
" preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay);
if (preferServerData) {
// Strict server-authoritative mode: do not infer/remap fallback routes.
if (!hasUsablePath) {
std::vector<glm::vec3> path = { canonicalSpawnPos };
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x",
std::hex, guid, std::dec, " entry=", entry);
auto pendingIt = std::find_if(
pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(),
[guid](const PendingTransportRegistration& pending) { return pending.guid == guid; });
if (pendingIt != pendingTransportRegistrations_.end()) {
pendingIt->entry = entry;
pendingIt->displayId = displayId;
pendingIt->x = x;
pendingIt->y = y;
pendingIt->z = z;
pendingIt->orientation = orientation;
} else {
LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry);
}
} else if (!hasUsablePath) {
// Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids.
// For elevators (TB lift platforms), we must allow z-only paths here.
bool allowZOnly = (displayId == 455 || 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 ", entry);
} else {
uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId);
if (remappedPath != 0) {
pathId = remappedPath;
LOG_WARNING("Using remapped fallback transport path ", pathId,
" for entry ", entry, " displayId=", displayId,
" (usableEntryPath=", transportManager->hasPathForEntry(entry), ")");
} else {
LOG_WARNING("No TransportAnimation.dbc path for entry ", entry,
" - transport will be stationary");
// Fallback: Stationary at spawn point (wait for server to send real position)
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 ", entry);
}
// Register the transport with spawn position (prevents rendering at origin until server update)
transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry);
// Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer
if (!it->second.isWmo) {
if (auto* tr = transportManager->getTransport(guid)) {
tr->isM2 = true;
}
}
// Server-authoritative movement - set initial position from spawn data
glm::vec3 canonicalPos(x, y, z);
transportManager->updateServerTransport(guid, canonicalPos, orientation);
// If a move packet arrived before registration completed, replay latest now.
auto pendingIt = pendingTransportMoves_.find(guid);
if (pendingIt != pendingTransportMoves_.end()) {
const PendingTransportMove pending = pendingIt->second;
transportManager->updateServerTransport(guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation);
LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, guid, std::dec,
" pos=(", pending.x, ", ", pending.y, ", ", pending.z, ") orientation=", pending.orientation);
pendingTransportMoves_.erase(pendingIt);
}
// For MO_TRANSPORT at (0,0,0): check if GO data is already cached with a taxiPathId
if (glm::length(canonicalSpawnPos) < 1.0f && gameHandler) {
auto goData = gameHandler->getCachedGameObjectInfo(entry);
if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) {
uint32_t taxiPathId = goData->data[0];
if (transportManager->hasTaxiPath(taxiPathId)) {
transportManager->assignTaxiPathToTransport(entry, taxiPathId);
LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", entry,
" taxiPathId=", taxiPathId);
}
}
}
if (auto* tr = transportManager->getTransport(guid); tr) {
LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId,
" pathId=", tr->pathId,
" mode=", (tr->useClientAnimation ? "client" : "server"),
" serverUpdates=", tr->serverUpdateCount);
} else {
LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId, " (TransportManager instance missing)");
pendingTransportRegistrations_.push_back(
PendingTransportRegistration{guid, entry, displayId, x, y, z, orientation});
}
});
@ -2853,6 +2759,15 @@ void Application::setupUICallbacks() {
return;
}
auto pendingRegIt = std::find_if(
pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(),
[guid](const PendingTransportRegistration& pending) { return pending.guid == guid; });
if (pendingRegIt != pendingTransportRegistrations_.end()) {
pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation};
LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec);
return;
}
// Check if transport exists - if not, treat this as a late spawn (reconnection/server restart)
if (!transportManager->getTransport(guid)) {
LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec,
@ -4155,6 +4070,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
deferredEquipmentQueue_.clear();
pendingGameObjectSpawns_.clear();
pendingTransportMoves_.clear();
pendingTransportRegistrations_.clear();
pendingTransportDoodadBatches_.clear();
if (renderer) {
@ -4210,6 +4126,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
if (load.future.valid()) load.future.wait();
}
asyncCreatureLoads_.clear();
asyncCreatureDisplayLoads_.clear();
playerInstances_.clear();
onlinePlayerAppearance_.clear();
@ -4866,25 +4783,23 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
if (world) world->update(1.0f / 60.0f);
processPlayerSpawnQueue();
// During load screen warmup: lift per-frame budgets so GPU uploads
// and spawns happen in bulk while the loading screen is still visible.
processCreatureSpawnQueue(true);
processAsyncNpcCompositeResults(true);
// Process equipment queue more aggressively during warmup (multiple per iteration)
for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) {
// Keep warmup bounded: unbounded queue draining can stall the main thread
// long enough to trigger socket timeouts.
processCreatureSpawnQueue(false);
processAsyncNpcCompositeResults(false);
// Process equipment queue with a small bounded burst during warmup.
for (int i = 0; i < 2 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) {
processDeferredEquipmentQueue();
}
if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) {
cr->processPendingNormalMaps(INT_MAX);
cr->processPendingNormalMaps(4);
}
// Process ALL pending game object spawns.
while (!pendingGameObjectSpawns_.empty()) {
auto& s = pendingGameObjectSpawns_.front();
spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale);
pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin());
}
// Keep warmup responsive: process gameobject queue with the same bounded
// budget logic used in-world instead of draining everything in one tick.
processGameObjectSpawnQueue();
processPendingTransportRegistrations();
processPendingTransportDoodads();
processPendingMount();
updateQuestMarkers();
@ -7437,12 +7352,23 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
void Application::processAsyncCreatureResults(bool unlimited) {
// Check completed async model loads and finalize on main thread (GPU upload + instance creation).
// Limit GPU model uploads per frame to avoid spikes, but always drain cheap bookkeeping.
// In unlimited mode (load screen), process all pending uploads without cap.
static constexpr int kMaxModelUploadsPerFrame = 1;
// 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;
@ -7451,12 +7377,13 @@ void Application::processAsyncCreatureResults(bool unlimited) {
// Peek: if this result needs a NEW model upload (not cached) and we've hit
// the upload budget, defer to next frame without consuming the future.
if (!unlimited && modelUploads >= kMaxModelUploadsPerFrame) {
if (modelUploads >= maxUploadsThisTick) {
break;
}
auto result = it->future.get();
it = asyncCreatureLoads_.erase(it);
asyncCreatureDisplayLoads_.erase(result.displayId);
if (result.permanent_failure) {
nonRenderableCreatureDisplayIds_.insert(result.displayId);
@ -7471,6 +7398,27 @@ void Application::processAsyncCreatureResults(bool unlimited) {
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;
}
// Model parsed on background thread — upload to GPU on main thread.
auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr;
if (!charRenderer) {
@ -7478,6 +7426,10 @@ void Application::processAsyncCreatureResults(bool unlimited) {
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();
@ -7504,8 +7456,6 @@ void Application::processAsyncCreatureResults(bool unlimited) {
displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures);
}
displayIdModelCache_[result.displayId] = result.modelId;
modelUploads++;
pendingCreatureSpawnGuids_.erase(result.guid);
creatureSpawnRetryCounts_.erase(result.guid);
@ -7659,6 +7609,14 @@ void Application::processCreatureSpawnQueue(bool unlimited) {
// 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
@ -7904,6 +7862,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) {
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();
@ -8304,6 +8263,151 @@ void Application::processGameObjectSpawnQueue() {
}
}
void Application::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;
LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec,
" 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);
}
LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath,
" preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay);
if (preferServerData) {
if (!hasUsablePath) {
std::vector<glm::vec3> path = { canonicalSpawnPos };
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x",
std::hex, pending.guid, std::dec, " entry=", pending.entry);
} else {
LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry);
}
} 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::length(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) {
LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec,
" 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 Application::processPendingTransportDoodads() {
if (pendingTransportDoodadBatches_.empty()) return;
if (!renderer || !assetManager) return;

View file

@ -100,6 +100,53 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) {
raw[0] == 'n' || raw[0] == 'N');
}
int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
char* end = nullptr;
long parsed = std::strtol(raw, &end, 10);
if (end == raw) return defaultValue;
return static_cast<int>(std::clamp<long>(parsed, minValue, maxValue));
}
int incomingPacketsBudgetPerUpdate(WorldState state) {
static const int inWorldBudget =
parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512);
static const int loginBudget =
parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512);
return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget;
}
float incomingPacketBudgetMs(WorldState state) {
static const int inWorldBudgetMs =
parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50);
static const int loginBudgetMs =
parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50);
return static_cast<float>(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs);
}
int updateObjectBlocksBudgetPerUpdate(WorldState state) {
static const int inWorldBudget =
parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048);
static const int loginBudget =
parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096);
return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget;
}
float slowPacketLogThresholdMs() {
static const int thresholdMs =
parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000);
return static_cast<float>(thresholdMs);
}
float slowUpdateObjectBlockLogThresholdMs() {
static const int thresholdMs =
parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000);
return static_cast<float>(thresholdMs);
}
constexpr size_t kMaxQueuedInboundPackets = 4096;
bool hasFullPackedGuid(const network::Packet& packet) {
if (packet.getReadPos() >= packet.getSize()) {
return false;
@ -659,8 +706,7 @@ bool GameHandler::connect(const std::string& host,
// Set up packet callback
socket->setPacketCallback([this](const network::Packet& packet) {
network::Packet mutablePacket = packet;
handlePacket(mutablePacket);
enqueueIncomingPacket(packet);
});
// Connect to world server
@ -712,6 +758,8 @@ void GameHandler::disconnect() {
wardenModuleSize_ = 0;
wardenModuleData_.clear();
wardenLoadedModule_.reset();
pendingIncomingPackets_.clear();
pendingUpdateObjectWork_.clear();
// Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects.
entityManager.clear();
setState(WorldState::DISCONNECTED);
@ -778,12 +826,27 @@ void GameHandler::update(float deltaTime) {
}
}
{
auto packetStart = std::chrono::steady_clock::now();
processQueuedIncomingPackets();
float packetMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - packetStart).count();
if (packetMs > 3.0f) {
LOG_WARNING("SLOW queued packet handling: ", packetMs, "ms");
}
}
// Detect server-side disconnect (socket closed during update)
if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) {
if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) {
LOG_WARNING("Server closed connection in state: ", worldStateName(state));
disconnect();
return;
}
LOG_DEBUG("World socket closed with ", pendingIncomingPackets_.size(),
" queued packet(s) and ", pendingUpdateObjectWork_.size(),
" update-object batch(es) pending dispatch");
}
// Post-gate visibility: determine whether server goes silent or closes after Warden requirement.
if (wardenGateSeen_ && socket && socket->isConnected()) {
@ -971,7 +1034,9 @@ void GameHandler::update(float deltaTime) {
timeSinceLastPing += deltaTime;
timeSinceLastMoveHeartbeat_ += deltaTime;
if (timeSinceLastPing >= pingInterval) {
const float currentPingInterval =
(isClassicLikeExpansion() || isActiveExpansion("tbc")) ? 10.0f : pingInterval;
if (timeSinceLastPing >= currentPingInterval) {
if (socket) {
sendPing();
}
@ -7420,6 +7485,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
size_t dataLen = pdata.size();
size_t pos = packet.getReadPos();
static uint32_t multiPktWarnCount = 0;
std::vector<network::Packet> subPackets;
while (pos + 4 <= dataLen) {
uint16_t subSize = static_cast<uint16_t>(
(static_cast<uint16_t>(pdata[pos]) << 8) | pdata[pos + 1]);
@ -7436,10 +7502,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
(static_cast<uint16_t>(pdata[pos + 3]) << 8);
std::vector<uint8_t> subPayload(pdata.begin() + pos + 4,
pdata.begin() + pos + 4 + payloadLen);
network::Packet subPacket(subOpcode, std::move(subPayload));
handlePacket(subPacket);
subPackets.emplace_back(subOpcode, std::move(subPayload));
pos += 4 + payloadLen;
}
for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) {
enqueueIncomingPacketFront(std::move(*it));
}
packet.setReadPos(packet.getSize());
break;
}
@ -8168,6 +8236,159 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
}
void GameHandler::enqueueIncomingPacket(const network::Packet& packet) {
if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) {
LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(),
" packets); dropping oldest packet to preserve responsiveness");
pendingIncomingPackets_.pop_front();
}
pendingIncomingPackets_.push_back(packet);
}
void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) {
if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) {
LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(),
" packets); dropping newest queued packet to preserve ordering");
pendingIncomingPackets_.pop_back();
}
pendingIncomingPackets_.emplace_front(std::move(packet));
}
void GameHandler::enqueueUpdateObjectWork(UpdateObjectData&& data) {
pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)});
}
void GameHandler::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start,
float budgetMs) {
if (pendingUpdateObjectWork_.empty()) {
return;
}
const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(state);
int processedBlocks = 0;
while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) {
float elapsedMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - start).count();
if (elapsedMs >= budgetMs) {
break;
}
auto& work = pendingUpdateObjectWork_.front();
if (!work.outOfRangeProcessed) {
auto outOfRangeStart = std::chrono::steady_clock::now();
processOutOfRangeObjects(work.data.outOfRangeGuids);
float outOfRangeMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - outOfRangeStart).count();
if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) {
LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs,
"ms guidCount=", work.data.outOfRangeGuids.size());
}
work.outOfRangeProcessed = true;
}
while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) {
elapsedMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - start).count();
if (elapsedMs >= budgetMs) {
break;
}
const UpdateBlock& block = work.data.blocks[work.nextBlockIndex];
auto blockStart = std::chrono::steady_clock::now();
applyUpdateObjectBlock(block, work.newItemCreated);
float blockMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - blockStart).count();
if (blockMs > slowUpdateObjectBlockLogThresholdMs()) {
LOG_WARNING("SLOW update-object block apply: ", blockMs,
"ms index=", work.nextBlockIndex,
" type=", static_cast<int>(block.updateType),
" guid=0x", std::hex, block.guid, std::dec,
" objectType=", static_cast<int>(block.objectType),
" fieldCount=", block.fields.size(),
" hasMovement=", block.hasMovement ? 1 : 0);
}
++work.nextBlockIndex;
++processedBlocks;
}
if (work.nextBlockIndex >= work.data.blocks.size()) {
finalizeUpdateObjectBatch(work.newItemCreated);
pendingUpdateObjectWork_.pop_front();
continue;
}
break;
}
if (!pendingUpdateObjectWork_.empty()) {
const auto& work = pendingUpdateObjectWork_.front();
LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=",
pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex,
"/", work.data.blocks.size(), ", state=", worldStateName(state), ")");
}
}
void GameHandler::processQueuedIncomingPackets() {
if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) {
return;
}
const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state);
const float budgetMs = incomingPacketBudgetMs(state);
const auto start = std::chrono::steady_clock::now();
int processed = 0;
while (processed < maxPacketsThisUpdate) {
float elapsedMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - start).count();
if (elapsedMs >= budgetMs) {
break;
}
if (!pendingUpdateObjectWork_.empty()) {
processPendingUpdateObjectWork(start, budgetMs);
if (!pendingUpdateObjectWork_.empty()) {
break;
}
continue;
}
if (pendingIncomingPackets_.empty()) {
break;
}
network::Packet packet = std::move(pendingIncomingPackets_.front());
pendingIncomingPackets_.pop_front();
const uint16_t wireOp = packet.getOpcode();
const auto logicalOp = opcodeTable_.fromWire(wireOp);
auto packetHandleStart = std::chrono::steady_clock::now();
handlePacket(packet);
float packetMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - packetHandleStart).count();
if (packetMs > slowPacketLogThresholdMs()) {
const char* logicalName = logicalOp
? OpcodeTable::logicalToName(*logicalOp)
: "UNKNOWN";
LOG_WARNING("SLOW packet handler: ", packetMs,
"ms wire=0x", std::hex, wireOp, std::dec,
" logical=", logicalName,
" size=", packet.getSize(),
" state=", worldStateName(state));
}
++processed;
}
if (!pendingUpdateObjectWork_.empty()) {
return;
}
if (!pendingIncomingPackets_.empty()) {
LOG_DEBUG("GameHandler packet budget reached (processed=", processed,
", remaining=", pendingIncomingPackets_.size(),
", state=", worldStateName(state), ")");
}
}
void GameHandler::handleAuthChallenge(network::Packet& packet) {
LOG_INFO("Handling SMSG_AUTH_CHALLENGE");
@ -8643,9 +8864,29 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
return;
}
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z));
const bool alreadyInWorld = (state == WorldState::IN_WORLD);
const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId);
const float dxCurrent = movementInfo.x - canonical.x;
const float dyCurrent = movementInfo.y - canonical.y;
const float dzCurrent = movementInfo.z - canonical.z;
const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent;
// Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already
// in-world. Re-running full world-entry handling here can trigger an expensive
// same-map reload/reset path and starve networking for tens of seconds.
if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) {
LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=",
data.mapId, " dist=", std::sqrt(distSqCurrent));
return;
}
// Successfully entered the world (or teleported)
currentMapId_ = data.mapId;
setState(WorldState::IN_WORLD);
if (socket) {
socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world");
}
LOG_INFO("========================================");
LOG_INFO(" SUCCESSFULLY ENTERED WORLD!");
@ -8656,7 +8897,6 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
LOG_INFO("Player is now in the game world");
// Initialize movement info with world entry position (server → canonical)
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z));
LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId);
movementInfo.x = canonical.x;
@ -8695,49 +8935,30 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
encounterUnitGuids_.fill(0);
raidTargetGuids_.fill(0);
// Clear inspect caches on world entry to avoid showing stale data
inspectedPlayerAchievements_.clear();
// Reset talent initialization so the first SMSG_TALENTS_INFO after login
// correctly sets the active spec (static locals don't reset across logins)
talentsInitialized_ = false;
learnedTalents_[0].clear();
learnedTalents_[1].clear();
learnedGlyphs_[0].fill(0);
learnedGlyphs_[1].fill(0);
unspentTalentPoints_[0] = 0;
unspentTalentPoints_[1] = 0;
activeTalentSpec_ = 0;
// Suppress area triggers on initial login — prevents exit portals from
// immediately firing when spawning inside a dungeon/instance.
activeAreaTriggers_.clear();
areaTriggerCheckTimer_ = -5.0f;
areaTriggerSuppressFirst_ = true;
// Send CMSG_SET_ACTIVE_MOVER (required by some servers)
// Notify application to load terrain for this map/position (online mode)
if (worldEntryCallback_) {
worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry);
}
// Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers.
if (playerGuid != 0 && socket) {
auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid);
socket->send(activeMoverPacket);
LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec);
}
// Notify application to load terrain for this map/position (online mode)
if (worldEntryCallback_) {
worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry);
}
// Auto-join default chat channels
autoJoinDefaultChannels();
// Auto-query guild info on login
const Character* activeChar = getActiveCharacter();
if (activeChar && activeChar->hasGuild() && socket) {
auto gqPacket = GuildQueryPacket::build(activeChar->guildId);
socket->send(gqPacket);
auto grPacket = GuildRosterPacket::build();
socket->send(grPacket);
LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")");
// Kick the first keepalive immediately on world entry. Classic-like realms
// can close the session before our default 30s ping cadence fires.
timeSinceLastPing = 0.0f;
if (socket) {
LOG_WARNING("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD");
sendPing();
}
// If we disconnected mid-taxi, attempt to recover to destination after login.
@ -8755,6 +8976,33 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
}
if (initialWorldEntry) {
// Clear inspect caches on world entry to avoid showing stale data.
inspectedPlayerAchievements_.clear();
// Reset talent initialization so the first SMSG_TALENTS_INFO after login
// correctly sets the active spec (static locals don't reset across logins).
talentsInitialized_ = false;
learnedTalents_[0].clear();
learnedTalents_[1].clear();
learnedGlyphs_[0].fill(0);
learnedGlyphs_[1].fill(0);
unspentTalentPoints_[0] = 0;
unspentTalentPoints_[1] = 0;
activeTalentSpec_ = 0;
// Auto-join default chat channels only on first world entry.
autoJoinDefaultChannels();
// Auto-query guild info on login.
const Character* activeChar = getActiveCharacter();
if (activeChar && activeChar->hasGuild() && socket) {
auto gqPacket = GuildQueryPacket::build(activeChar->guildId);
socket->send(gqPacket);
auto grPacket = GuildRosterPacket::build();
socket->send(grPacket);
LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")");
}
pendingQuestAcceptTimeouts_.clear();
pendingQuestAcceptNpcGuids_.clear();
pendingQuestQueryIds_.clear();
@ -8763,11 +9011,18 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
completedQuests_.clear();
LOG_INFO("Queued quest log resync for login (from server quest slots)");
// Request completed quest IDs from server (populates completedQuests_ when response arrives)
// Request completed quest IDs when the expansion supports it. Classic-like
// opcode tables do not define this packet, and sending 0xFFFF during world
// entry can desync the early session handshake.
if (socket) {
network::Packet cqcPkt(wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED));
const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED);
if (queryCompletedWire != 0xFFFF) {
network::Packet cqcPkt(queryCompletedWire);
socket->send(cqcPkt);
LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED");
} else {
LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion");
}
}
}
}
@ -9131,6 +9386,19 @@ void GameHandler::handleWardenData(network::Packet& packet) {
size_t moduleImageSize = wardenLoadedModule_->getModuleSize();
const auto& decompressedData = wardenLoadedModule_->getDecompressedData();
if (!moduleImage || moduleImageSize == 0) {
LOG_WARNING("Warden: Loaded module has no executable image — using raw module hash fallback");
std::vector<uint8_t> fallbackReply =
!wardenModuleData_.empty() ? auth::Crypto::sha1(wardenModuleData_) : std::vector<uint8_t>(20, 0);
std::vector<uint8_t> resp;
resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT
resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end());
sendWardenResponse(resp);
applyWardenSeedRekey(seed);
wardenState_ = WardenState::WAIT_CHECKS;
break;
}
// --- Empirical test: try multiple SHA1 computations and check against first CR entry ---
if (!wardenCREntries_.empty()) {
const auto& firstCR = wardenCREntries_[0];
@ -9721,8 +9989,8 @@ void GameHandler::sendPing() {
// Increment sequence number
pingSequence++;
LOG_DEBUG("Sending CMSG_PING (heartbeat)");
LOG_DEBUG(" Sequence: ", pingSequence);
LOG_WARNING("Sending CMSG_PING: sequence=", pingSequence,
" latencyHintMs=", lastLatency);
// Record send time for RTT measurement
pingTimestamp_ = std::chrono::steady_clock::now();
@ -9772,7 +10040,7 @@ void GameHandler::sendMinimapPing(float wowX, float wowY) {
}
void GameHandler::handlePong(network::Packet& packet) {
LOG_DEBUG("Handling SMSG_PONG");
LOG_WARNING("Handling SMSG_PONG");
PongData data;
if (!PongParser::parse(packet, data)) {
@ -9792,7 +10060,8 @@ void GameHandler::handlePong(network::Packet& packet) {
lastLatency = static_cast<uint32_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(rtt).count());
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ", latency: ", lastLatency, "ms)");
LOG_WARNING("SMSG_PONG acknowledged: sequence=", data.sequence,
" latencyMs=", lastLatency);
}
uint32_t GameHandler::nextMovementTimestampMs() {
@ -10105,7 +10374,6 @@ void GameHandler::setOrientation(float orientation) {
}
void GameHandler::handleUpdateObject(network::Packet& packet) {
static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false);
UpdateObjectData data;
if (!packetParsers_->parseUpdateObject(packet, data)) {
static int updateObjErrors = 0;
@ -10115,6 +10383,61 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
// Fall through: process any blocks that were successfully parsed before the failure.
}
enqueueUpdateObjectWork(std::move(data));
}
void GameHandler::processOutOfRangeObjects(const std::vector<uint64_t>& guids) {
// Process out-of-range objects first
for (uint64_t guid : guids) {
auto entity = entityManager.getEntity(guid);
if (!entity) continue;
const bool isKnownTransport = transportGuids_.count(guid) > 0;
if (isKnownTransport) {
// Keep transports alive across out-of-range flapping.
// Boats/zeppelins are global movers and removing them here can make
// them disappear until a later movement snapshot happens to recreate them.
const bool playerAboardNow = (playerTransportGuid_ == guid);
const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f);
const bool movementSaysAboard = (movementInfo.transportGuid == guid);
LOG_INFO("Preserving transport on out-of-range: 0x",
std::hex, guid, std::dec,
" now=", playerAboardNow,
" sticky=", stickyAboard,
" movement=", movementSaysAboard);
continue;
}
LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec);
// Trigger despawn callbacks before removing entity
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(guid);
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
playerDespawnCallback_(guid);
otherPlayerVisibleItemEntries_.erase(guid);
otherPlayerVisibleDirty_.erase(guid);
otherPlayerMoveTimeMs_.erase(guid);
inspectedPlayerItemEntries_.erase(guid);
pendingAutoInspect_.erase(guid);
// Clear pending name query so the query is re-sent when this player
// comes back into range (entity is recreated as a new object).
pendingNameQueries.erase(guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(guid);
}
transportGuids_.erase(guid);
serverUpdatedTransportGuids_.erase(guid);
clearTransportAttachment(guid);
if (playerTransportGuid_ == guid) {
clearPlayerTransport();
}
entityManager.removeEntity(guid);
}
}
void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) {
static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false);
auto extractPlayerAppearance = [&](const std::map<uint16_t, uint32_t>& fields,
uint8_t& outRace,
uint8_t& outGender,
@ -10236,56 +10559,6 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
pendingMoneyDeltaTimer_ = 0.0f;
};
// Process out-of-range objects first
for (uint64_t guid : data.outOfRangeGuids) {
auto entity = entityManager.getEntity(guid);
if (!entity) continue;
const bool isKnownTransport = transportGuids_.count(guid) > 0;
if (isKnownTransport) {
// Keep transports alive across out-of-range flapping.
// Boats/zeppelins are global movers and removing them here can make
// them disappear until a later movement snapshot happens to recreate them.
const bool playerAboardNow = (playerTransportGuid_ == guid);
const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f);
const bool movementSaysAboard = (movementInfo.transportGuid == guid);
LOG_INFO("Preserving transport on out-of-range: 0x",
std::hex, guid, std::dec,
" now=", playerAboardNow,
" sticky=", stickyAboard,
" movement=", movementSaysAboard);
continue;
}
LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec);
// Trigger despawn callbacks before removing entity
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(guid);
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
playerDespawnCallback_(guid);
otherPlayerVisibleItemEntries_.erase(guid);
otherPlayerVisibleDirty_.erase(guid);
otherPlayerMoveTimeMs_.erase(guid);
inspectedPlayerItemEntries_.erase(guid);
pendingAutoInspect_.erase(guid);
// Clear pending name query so the query is re-sent when this player
// comes back into range (entity is recreated as a new object).
pendingNameQueries.erase(guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(guid);
}
transportGuids_.erase(guid);
serverUpdatedTransportGuids_.erase(guid);
clearTransportAttachment(guid);
if (playerTransportGuid_ == guid) {
clearPlayerTransport();
}
entityManager.removeEntity(guid);
}
// Process update blocks
bool newItemCreated = false;
for (const auto& block : data.blocks) {
switch (block.updateType) {
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2: {
@ -11453,8 +11726,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
default:
break;
}
}
}
void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) {
tabCycleStale = true;
// Entity count logging disabled
@ -20653,6 +20927,9 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
}
currentMapId_ = mapId;
if (socket) {
socket->tracePacketsFor(std::chrono::seconds(12), "new_world");
}
// Update player position
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ));
@ -20706,6 +20983,12 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK");
}
timeSinceLastPing = 0.0f;
if (socket) {
LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK");
sendPing();
}
// Reload terrain at new position.
// Pass isSameMap as isInitialEntry so the application despawns and
// re-registers renderer instances before the server resends CREATE_OBJECTs.

View file

@ -441,6 +441,18 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
if (rawHitCount > 128) {
LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")");
}
// Packed GUIDs are variable length, but each target needs at least 1 byte (mask).
// Require the minimum bytes before entering per-target parsing loops.
if (rem() < static_cast<size_t>(rawHitCount) + 1u) { // +1 for mandatory missCount byte
static uint32_t badHitCountTrunc = 0;
++badHitCountTrunc;
if (badHitCountTrunc <= 10 || (badHitCountTrunc % 100) == 0) {
LOG_WARNING("[Classic] Spell go: invalid hitCount/remaining (hits=", (int)rawHitCount,
" remaining=", rem(), " occurrence=", badHitCountTrunc, ")");
}
packet.setReadPos(startPos);
return false;
}
const uint8_t storedHitLimit = std::min<uint8_t>(rawHitCount, 128);
data.hitTargets.reserve(storedHitLimit);
bool truncatedTargets = false;
@ -472,6 +484,17 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
if (rawMissCount > 128) {
LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")");
}
// Each miss entry needs at least packed-guid mask (1) + missType (1).
if (rem() < static_cast<size_t>(rawMissCount) * 2u) {
static uint32_t badMissCountTrunc = 0;
++badMissCountTrunc;
if (badMissCountTrunc <= 10 || (badMissCountTrunc % 100) == 0) {
LOG_WARNING("[Classic] Spell go: invalid missCount/remaining (misses=", (int)rawMissCount,
" remaining=", rem(), " occurrence=", badMissCountTrunc, ")");
}
packet.setReadPos(startPos);
return false;
}
const uint8_t storedMissLimit = std::min<uint8_t>(rawMissCount, 128);
data.missTargets.reserve(storedMissLimit);
for (uint16_t i = 0; i < rawMissCount; ++i) {
@ -1810,6 +1833,173 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
return true;
}
bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectData& data) {
constexpr uint32_t kMaxReasonableUpdateBlocks = 4096;
auto parseWithLayout = [&](bool withHasTransportByte, UpdateObjectData& out) -> bool {
out = UpdateObjectData{};
const size_t start = packet.getReadPos();
if (packet.getSize() - start < 4) return false;
out.blockCount = packet.readUInt32();
if (out.blockCount > kMaxReasonableUpdateBlocks) {
packet.setReadPos(start);
return false;
}
if (withHasTransportByte) {
if (packet.getReadPos() >= packet.getSize()) {
packet.setReadPos(start);
return false;
}
/*uint8_t hasTransport =*/ packet.readUInt8();
}
if (packet.getReadPos() + 1 <= packet.getSize()) {
uint8_t firstByte = packet.readUInt8();
if (firstByte == static_cast<uint8_t>(UpdateType::OUT_OF_RANGE_OBJECTS)) {
if (packet.getReadPos() + 4 > packet.getSize()) {
packet.setReadPos(start);
return false;
}
uint32_t count = packet.readUInt32();
if (count > kMaxReasonableUpdateBlocks) {
packet.setReadPos(start);
return false;
}
for (uint32_t i = 0; i < count; ++i) {
if (packet.getReadPos() >= packet.getSize()) {
packet.setReadPos(start);
return false;
}
out.outOfRangeGuids.push_back(UpdateObjectParser::readPackedGuid(packet));
}
} else {
packet.setReadPos(packet.getReadPos() - 1);
}
}
out.blocks.reserve(out.blockCount);
for (uint32_t i = 0; i < out.blockCount; ++i) {
if (packet.getReadPos() >= packet.getSize()) {
packet.setReadPos(start);
return false;
}
const size_t blockStart = packet.getReadPos();
uint8_t updateTypeVal = packet.readUInt8();
if (updateTypeVal > static_cast<uint8_t>(UpdateType::NEAR_OBJECTS)) {
packet.setReadPos(start);
return false;
}
const UpdateType updateType = static_cast<UpdateType>(updateTypeVal);
UpdateBlock block;
block.updateType = updateType;
bool ok = false;
auto parseMovementVariant = [&](auto&& movementParser, const char* layoutName) -> bool {
packet.setReadPos(blockStart + 1);
block = UpdateBlock{};
block.updateType = updateType;
switch (updateType) {
case UpdateType::MOVEMENT:
block.guid = UpdateObjectParser::readPackedGuid(packet);
if (!movementParser(packet, block)) return false;
LOG_DEBUG("[Turtle] Parsed MOVEMENT block via ", layoutName, " layout");
return true;
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2:
block.guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getReadPos() >= packet.getSize()) return false;
block.objectType = static_cast<ObjectType>(packet.readUInt8());
if (!movementParser(packet, block)) return false;
if (!UpdateObjectParser::parseUpdateFields(packet, block)) return false;
LOG_DEBUG("[Turtle] Parsed CREATE block via ", layoutName, " layout");
return true;
default:
return false;
}
};
switch (updateType) {
case UpdateType::VALUES:
block.guid = UpdateObjectParser::readPackedGuid(packet);
ok = UpdateObjectParser::parseUpdateFields(packet, block);
break;
case UpdateType::MOVEMENT:
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2:
ok = parseMovementVariant(
[this](network::Packet& p, UpdateBlock& b) {
return this->TurtlePacketParsers::parseMovementBlock(p, b);
}, "turtle");
if (!ok) {
ok = parseMovementVariant(
[this](network::Packet& p, UpdateBlock& b) {
return this->ClassicPacketParsers::parseMovementBlock(p, b);
}, "classic");
}
if (!ok) {
ok = parseMovementVariant(
[this](network::Packet& p, UpdateBlock& b) {
return this->TbcPacketParsers::parseMovementBlock(p, b);
}, "tbc");
}
break;
case UpdateType::OUT_OF_RANGE_OBJECTS:
case UpdateType::NEAR_OBJECTS:
ok = true;
break;
default:
ok = false;
break;
}
if (!ok) {
packet.setReadPos(start);
return false;
}
out.blocks.push_back(std::move(block));
}
return true;
};
const size_t startPos = packet.getReadPos();
UpdateObjectData parsed;
if (parseWithLayout(true, parsed)) {
data = std::move(parsed);
return true;
}
packet.setReadPos(startPos);
if (parseWithLayout(false, parsed)) {
LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback");
data = std::move(parsed);
return true;
}
packet.setReadPos(startPos);
if (ClassicPacketParsers::parseUpdateObject(packet, parsed)) {
LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full classic fallback");
data = std::move(parsed);
return true;
}
packet.setReadPos(startPos);
if (TbcPacketParsers::parseUpdateObject(packet, parsed)) {
LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full TBC fallback");
data = std::move(parsed);
return true;
}
packet.setReadPos(startPos);
return false;
}
bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) {
// Turtle realms can emit both vanilla-like and WotLK-like monster move bodies.
// Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout.

View file

@ -272,9 +272,28 @@ bool WardenMemory::readMemory(uint32_t va, uint8_t length, uint8_t* outBuf) cons
return true;
}
// PE image range
if (!loaded_ || va < imageBase_) return false;
uint32_t offset = va - imageBase_;
if (!loaded_) return false;
// Warden MEM_CHECK offsets are seen in multiple forms:
// 1) Absolute VA (e.g. 0x00401337)
// 2) RVA (e.g. 0x000139A9)
// 3) Tiny module-relative offsets (e.g. 0x00000229, 0x00000008)
// Accept all three to avoid fallback-to-zeros on Classic/Turtle.
uint32_t offset = 0;
if (va >= imageBase_) {
// Absolute VA.
offset = va - imageBase_;
} else if (va < imageSize_) {
// RVA into WoW.exe image.
offset = va;
} else {
// Tiny relative offsets frequently target fake Warden runtime globals.
constexpr uint32_t kFakeWardenBase = 0xCE8000;
const uint32_t remappedVa = kFakeWardenBase + va;
if (remappedVa < imageBase_) return false;
offset = remappedVa - imageBase_;
}
if (static_cast<uint64_t>(offset) + length > imageSize_) return false;
std::memcpy(outBuf, image_.data() + offset, length);

View file

@ -59,15 +59,14 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
// Step 1: Verify MD5 hash
if (!verifyMD5(moduleData, md5Hash)) {
std::cerr << "[WardenModule] MD5 verification failed!" << '\n';
return false;
std::cerr << "[WardenModule] MD5 verification failed; continuing in compatibility mode" << '\n';
}
std::cout << "[WardenModule] ✓ MD5 verified" << '\n';
// Step 2: RC4 decrypt (Warden protocol-required legacy RC4; server-mandated, cannot be changed)
if (!decryptRC4(moduleData, rc4Key, decryptedData_)) { // codeql[cpp/weak-cryptographic-algorithm]
std::cerr << "[WardenModule] RC4 decryption failed!" << '\n';
return false;
std::cerr << "[WardenModule] RC4 decryption failed; using raw module bytes fallback" << '\n';
decryptedData_ = moduleData;
}
std::cout << "[WardenModule] ✓ RC4 decrypted (" << decryptedData_.size() << " bytes)" << '\n';
@ -85,20 +84,18 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
dataWithoutSig = decryptedData_;
}
if (!decompressZlib(dataWithoutSig, decompressedData_)) {
std::cerr << "[WardenModule] zlib decompression failed!" << '\n';
return false;
std::cerr << "[WardenModule] zlib decompression failed; using decrypted bytes fallback" << '\n';
decompressedData_ = decryptedData_;
}
// Step 5: Parse custom executable format
if (!parseExecutableFormat(decompressedData_)) {
std::cerr << "[WardenModule] Executable format parsing failed!" << '\n';
return false;
std::cerr << "[WardenModule] Executable format parsing failed; continuing with minimal module image" << '\n';
}
// Step 6: Apply relocations
if (!applyRelocations()) {
std::cerr << "[WardenModule] Address relocations failed!" << '\n';
return false;
std::cerr << "[WardenModule] Address relocations failed; continuing with unrelocated image" << '\n';
}
// Step 7: Bind APIs
@ -109,8 +106,7 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
// Step 8: Initialize module
if (!initializeModule()) {
std::cerr << "[WardenModule] Module initialization failed!" << '\n';
return false;
std::cerr << "[WardenModule] Module initialization failed; continuing with stub callbacks" << '\n';
}
// Module loading pipeline complete!

View file

@ -1344,8 +1344,10 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
}
bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) {
constexpr uint32_t kMaxReasonableUpdateBlocks = 4096;
constexpr uint32_t kMaxReasonableOutOfRangeGuids = 16384;
// Keep worst-case packet parsing bounded. Extremely large counts are typically
// malformed/desynced and can stall a frame long enough to trigger disconnects.
constexpr uint32_t kMaxReasonableUpdateBlocks = 1024;
constexpr uint32_t kMaxReasonableOutOfRangeGuids = 4096;
// Read block count
data.blockCount = packet.readUInt32();

View file

@ -1,6 +1,7 @@
#include "network/world_socket.hpp"
#include "network/packet.hpp"
#include "network/net_platform.hpp"
#include "game/opcode_table.hpp"
#include "auth/crypto.hpp"
#include "core/logger.hpp"
#include <iomanip>
@ -9,10 +10,49 @@
#include <fstream>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <thread>
namespace {
constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024;
constexpr int kMaxParsedPacketsPerUpdate = 220;
constexpr int kDefaultMaxParsedPacketsPerUpdate = 16;
constexpr int kAbsoluteMaxParsedPacketsPerUpdate = 220;
constexpr int kMinParsedPacketsPerUpdate = 8;
constexpr int kDefaultMaxPacketCallbacksPerUpdate = 6;
constexpr int kAbsoluteMaxPacketCallbacksPerUpdate = 64;
constexpr int kMinPacketCallbacksPerUpdate = 1;
constexpr int kMaxRecvCallsPerUpdate = 64;
constexpr size_t kMaxRecvBytesPerUpdate = 512 * 1024;
constexpr size_t kMaxQueuedPacketCallbacks = 4096;
constexpr int kAsyncPumpSleepMs = 2;
inline int parsedPacketsBudgetPerUpdate() {
static int budget = []() {
const char* raw = std::getenv("WOWEE_NET_MAX_PARSED_PACKETS");
if (!raw || !*raw) return kDefaultMaxParsedPacketsPerUpdate;
char* end = nullptr;
long parsed = std::strtol(raw, &end, 10);
if (end == raw) return kDefaultMaxParsedPacketsPerUpdate;
if (parsed < kMinParsedPacketsPerUpdate) return kMinParsedPacketsPerUpdate;
if (parsed > kAbsoluteMaxParsedPacketsPerUpdate) return kAbsoluteMaxParsedPacketsPerUpdate;
return static_cast<int>(parsed);
}();
return budget;
}
inline int packetCallbacksBudgetPerUpdate() {
static int budget = []() {
const char* raw = std::getenv("WOWEE_NET_MAX_PACKET_CALLBACKS");
if (!raw || !*raw) return kDefaultMaxPacketCallbacksPerUpdate;
char* end = nullptr;
long parsed = std::strtol(raw, &end, 10);
if (end == raw) return kDefaultMaxPacketCallbacksPerUpdate;
if (parsed < kMinPacketCallbacksPerUpdate) return kMinPacketCallbacksPerUpdate;
if (parsed > kAbsoluteMaxPacketCallbacksPerUpdate) return kAbsoluteMaxPacketCallbacksPerUpdate;
return static_cast<int>(parsed);
}();
return budget;
}
inline bool isLoginPipelineSmsg(uint16_t opcode) {
switch (opcode) {
@ -49,6 +89,14 @@ inline bool envFlagEnabled(const char* key, bool defaultValue = false) {
return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' ||
raw[0] == 'n' || raw[0] == 'N');
}
const char* opcodeNameForTrace(uint16_t wireOpcode) {
const auto* table = wowee::game::getActiveOpcodeTable();
if (!table) return "UNKNOWN";
auto logical = table->fromWire(wireOpcode);
if (!logical) return "UNKNOWN";
return wowee::game::OpcodeTable::logicalToName(*logical);
}
} // namespace
namespace wowee {
@ -71,6 +119,7 @@ WorldSocket::WorldSocket() {
receiveBuffer.reserve(64 * 1024);
useFastRecvAppend_ = envFlagEnabled("WOWEE_NET_FAST_RECV_APPEND", true);
useParseScratchQueue_ = envFlagEnabled("WOWEE_NET_PARSE_SCRATCH", false);
useAsyncPump_ = envFlagEnabled("WOWEE_NET_ASYNC_PUMP", true);
if (useParseScratchQueue_) {
LOG_WARNING("WOWEE_NET_PARSE_SCRATCH is temporarily disabled (known unstable); forcing off");
useParseScratchQueue_ = false;
@ -79,7 +128,10 @@ WorldSocket::WorldSocket() {
parsedPacketsScratch_.reserve(64);
}
LOG_INFO("WorldSocket net opts: fast_recv_append=", useFastRecvAppend_ ? "on" : "off",
" parse_scratch=", useParseScratchQueue_ ? "on" : "off");
" async_pump=", useAsyncPump_ ? "on" : "off",
" parse_scratch=", useParseScratchQueue_ ? "on" : "off",
" max_parsed_packets=", parsedPacketsBudgetPerUpdate(),
" max_packet_callbacks=", packetCallbacksBudgetPerUpdate());
}
WorldSocket::~WorldSocket() {
@ -89,6 +141,8 @@ WorldSocket::~WorldSocket() {
bool WorldSocket::connect(const std::string& host, uint16_t port) {
LOG_INFO("Connecting to world server: ", host, ":", port);
stopAsyncPump();
// Create socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == INVALID_SOCK) {
@ -165,32 +219,59 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) {
connected = true;
LOG_INFO("Connected to world server: ", host, ":", port);
startAsyncPump();
return true;
}
void WorldSocket::disconnect() {
if (sockfd != INVALID_SOCK) {
net::closeSocket(sockfd);
sockfd = INVALID_SOCK;
}
connected = false;
stopAsyncPump();
{
std::lock_guard<std::mutex> lock(ioMutex_);
closeSocketNoJoin();
encryptionEnabled = false;
useVanillaCrypt = false;
receiveBuffer.clear();
receiveReadOffset_ = 0;
parsedPacketsScratch_.clear();
headerBytesDecrypted = 0;
packetTraceStart_ = {};
packetTraceUntil_ = {};
packetTraceReason_.clear();
}
{
std::lock_guard<std::mutex> lock(callbackMutex_);
pendingPacketCallbacks_.clear();
}
LOG_INFO("Disconnected from world server");
}
void WorldSocket::tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason) {
std::lock_guard<std::mutex> lock(ioMutex_);
packetTraceStart_ = std::chrono::steady_clock::now();
packetTraceUntil_ = packetTraceStart_ + duration;
packetTraceReason_ = reason;
LOG_WARNING("WS TRACE enabled: reason='", packetTraceReason_,
"' durationMs=", duration.count());
}
bool WorldSocket::isConnected() const {
std::lock_guard<std::mutex> lock(ioMutex_);
return connected;
}
void WorldSocket::closeSocketNoJoin() {
if (sockfd != INVALID_SOCK) {
net::closeSocket(sockfd);
sockfd = INVALID_SOCK;
}
connected = false;
}
void WorldSocket::send(const Packet& packet) {
if (!connected) return;
static const bool kLogCharCreatePayload = envFlagEnabled("WOWEE_NET_LOG_CHAR_CREATE", false);
static const bool kLogSwapItemPackets = envFlagEnabled("WOWEE_NET_LOG_SWAP_ITEM", false);
std::lock_guard<std::mutex> lock(ioMutex_);
if (!connected || sockfd == INVALID_SOCK) return;
const auto& data = packet.getData();
uint16_t opcode = packet.getOpcode();
@ -254,6 +335,17 @@ void WorldSocket::send(const Packet& packet) {
LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]");
}
const auto traceNow = std::chrono::steady_clock::now();
if (packetTraceUntil_ > traceNow) {
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
traceNow - packetTraceStart_).count();
LOG_WARNING("WS TRACE TX +", elapsedMs, "ms opcode=0x",
std::hex, opcode, std::dec,
" logical=", opcodeNameForTrace(opcode),
" payload=", payloadLen,
" reason='", packetTraceReason_, "'");
}
// WotLK 3.3.5 CMSG header (6 bytes total):
// - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG)
// - opcode (4 bytes, little-endian)
@ -317,7 +409,46 @@ void WorldSocket::send(const Packet& packet) {
}
void WorldSocket::update() {
if (!connected) return;
if (!useAsyncPump_) {
pumpNetworkIO();
}
dispatchQueuedPackets();
}
void WorldSocket::startAsyncPump() {
if (!useAsyncPump_ || asyncPumpRunning_.load(std::memory_order_acquire)) {
return;
}
asyncPumpStop_.store(false, std::memory_order_release);
asyncPumpThread_ = std::thread(&WorldSocket::asyncPumpLoop, this);
}
void WorldSocket::stopAsyncPump() {
asyncPumpStop_.store(true, std::memory_order_release);
if (asyncPumpThread_.joinable()) {
asyncPumpThread_.join();
}
asyncPumpRunning_.store(false, std::memory_order_release);
}
void WorldSocket::asyncPumpLoop() {
asyncPumpRunning_.store(true, std::memory_order_release);
while (!asyncPumpStop_.load(std::memory_order_acquire)) {
pumpNetworkIO();
{
std::lock_guard<std::mutex> lock(ioMutex_);
if (!connected) {
break;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(kAsyncPumpSleepMs));
}
asyncPumpRunning_.store(false, std::memory_order_release);
}
void WorldSocket::pumpNetworkIO() {
std::lock_guard<std::mutex> lock(ioMutex_);
if (!connected || sockfd == INVALID_SOCK) return;
auto bufferedBytes = [&]() -> size_t {
return (receiveBuffer.size() >= receiveReadOffset_)
? (receiveBuffer.size() - receiveReadOffset_)
@ -343,7 +474,8 @@ void WorldSocket::update() {
bool receivedAny = false;
size_t bytesReadThisTick = 0;
int readOps = 0;
while (connected) {
while (connected && readOps < kMaxRecvCallsPerUpdate &&
bytesReadThisTick < kMaxRecvBytesPerUpdate) {
uint8_t buffer[4096];
ssize_t received = net::portableRecv(sockfd, buffer, sizeof(buffer));
@ -362,7 +494,7 @@ void WorldSocket::update() {
LOG_ERROR("World socket receive buffer would overflow (buffered=", liveBytes,
" incoming=", receivedSize, " max=", kMaxReceiveBufferBytes,
"). Disconnecting to recover framing.");
disconnect();
closeSocketNoJoin();
return;
}
const size_t oldSize = receiveBuffer.size();
@ -375,7 +507,7 @@ void WorldSocket::update() {
if (newCap < needed) {
LOG_ERROR("World socket receive buffer capacity growth failed (needed=", needed,
" max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing.");
disconnect();
closeSocketNoJoin();
return;
}
receiveBuffer.reserve(newCap);
@ -387,7 +519,7 @@ void WorldSocket::update() {
if (bufferedBytes() > kMaxReceiveBufferBytes) {
LOG_ERROR("World socket receive buffer overflow (", bufferedBytes(),
" bytes). Disconnecting to recover framing.");
disconnect();
closeSocketNoJoin();
return;
}
continue;
@ -409,7 +541,7 @@ void WorldSocket::update() {
}
LOG_ERROR("Receive failed: ", net::errorString(err));
disconnect();
closeSocketNoJoin();
return;
}
@ -434,10 +566,15 @@ void WorldSocket::update() {
}
}
if (connected && (readOps >= kMaxRecvCallsPerUpdate || bytesReadThisTick >= kMaxRecvBytesPerUpdate)) {
LOG_DEBUG("World socket recv budget reached (calls=", readOps,
", bytes=", bytesReadThisTick, "), deferring remaining socket drain");
}
if (sawClose) {
LOG_INFO("World server connection closed (receivedAny=", receivedAny,
" buffered=", bufferedBytes(), ")");
disconnect();
closeSocketNoJoin();
return;
}
}
@ -462,7 +599,8 @@ void WorldSocket::tryParsePackets() {
} else {
parsedPacketsLocal.reserve(32);
}
while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < kMaxParsedPacketsPerUpdate) {
const int maxParsedThisTick = parsedPacketsBudgetPerUpdate();
while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < maxParsedThisTick) {
uint8_t rawHeader[4] = {0, 0, 0, 0};
std::memcpy(rawHeader, receiveBuffer.data() + parseOffset, 4);
@ -491,7 +629,7 @@ void WorldSocket::tryParsePackets() {
static_cast<int>(rawHeader[2]), " ",
static_cast<int>(rawHeader[3]), std::dec,
" enc=", encryptionEnabled, ". Disconnecting to recover stream.");
disconnect();
closeSocketNoJoin();
return;
}
constexpr uint16_t kMaxWorldPacketSize = 0x4000;
@ -503,7 +641,7 @@ void WorldSocket::tryParsePackets() {
static_cast<int>(rawHeader[2]), " ",
static_cast<int>(rawHeader[3]), std::dec,
" enc=", encryptionEnabled, ". Disconnecting to recover stream.");
disconnect();
closeSocketNoJoin();
return;
}
@ -535,6 +673,16 @@ void WorldSocket::tryParsePackets() {
" buffered=", (receiveBuffer.size() - parseOffset),
" enc=", encryptionEnabled ? "yes" : "no");
}
const auto traceNow = std::chrono::steady_clock::now();
if (packetTraceUntil_ > traceNow) {
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
traceNow - packetTraceStart_).count();
LOG_WARNING("WS TRACE RX +", elapsedMs, "ms opcode=0x",
std::hex, opcode, std::dec,
" logical=", opcodeNameForTrace(opcode),
" payload=", payloadLen,
" reason='", packetTraceReason_, "'");
}
if ((receiveBuffer.size() - parseOffset) < totalSize) {
// Not enough data yet - header stays decrypted in buffer
@ -555,7 +703,7 @@ void WorldSocket::tryParsePackets() {
" payload=", payloadLen, " buffered=", receiveBuffer.size(),
" parseOffset=", parseOffset, " what=", e.what(),
". Disconnecting to recover.");
disconnect();
closeSocketNoJoin();
return;
}
parseOffset += totalSize;
@ -578,23 +726,57 @@ void WorldSocket::tryParsePackets() {
}
headerBytesDecrypted = localHeaderBytesDecrypted;
if (packetCallback) {
for (const auto& packet : *parsedPackets) {
if (!connected) break;
packetCallback(packet);
// Queue parsed packets for main-thread dispatch.
if (!parsedPackets->empty()) {
std::lock_guard<std::mutex> callbackLock(callbackMutex_);
for (auto& packet : *parsedPackets) {
pendingPacketCallbacks_.push_back(std::move(packet));
}
if (pendingPacketCallbacks_.size() > kMaxQueuedPacketCallbacks) {
LOG_ERROR("World socket callback queue overflow (", pendingPacketCallbacks_.size(),
" packets). Disconnecting to recover.");
pendingPacketCallbacks_.clear();
closeSocketNoJoin();
return;
}
}
const size_t buffered = (receiveBuffer.size() >= receiveReadOffset_)
? (receiveBuffer.size() - receiveReadOffset_)
: 0;
if (parsedThisTick >= kMaxParsedPacketsPerUpdate && buffered >= 4) {
if (parsedThisTick >= maxParsedThisTick && buffered >= 4) {
LOG_DEBUG("World socket parse budget reached (", parsedThisTick,
" packets); deferring remaining buffered data=", buffered, " bytes");
}
}
void WorldSocket::dispatchQueuedPackets() {
std::deque<Packet> localPackets;
{
std::lock_guard<std::mutex> lock(callbackMutex_);
if (!packetCallback || pendingPacketCallbacks_.empty()) {
return;
}
const int maxCallbacksThisTick = packetCallbacksBudgetPerUpdate();
for (int i = 0; i < maxCallbacksThisTick && !pendingPacketCallbacks_.empty(); ++i) {
localPackets.push_back(std::move(pendingPacketCallbacks_.front()));
pendingPacketCallbacks_.pop_front();
}
if (!pendingPacketCallbacks_.empty()) {
LOG_DEBUG("World socket callback budget reached (", localPackets.size(),
" callbacks); deferring ", pendingPacketCallbacks_.size(),
" queued packet callbacks");
}
}
while (!localPackets.empty()) {
packetCallback(localPackets.front());
localPackets.pop_front();
}
}
void WorldSocket::initEncryption(const std::vector<uint8_t>& sessionKey, uint32_t build) {
std::lock_guard<std::mutex> lock(ioMutex_);
if (sessionKey.size() != 40) {
LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)");
return;

View file

@ -343,6 +343,8 @@ void CharacterRenderer::shutdown() {
// Clean up composite cache
compositeCache_.clear();
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
textureLookupSerial_ = 0;
whiteTexture_.reset();
transparentTexture_.reset();
@ -430,6 +432,8 @@ void CharacterRenderer::clear() {
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
loggedTextureLoadFails_.clear();
failedTextureRetryAt_.clear();
textureLookupSerial_ = 0;
// Clear composite and failed caches
compositeCache_.clear();
@ -604,6 +608,7 @@ CharacterRenderer::NormalMapResult CharacterRenderer::generateNormalHeightMapCPU
}
VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
constexpr uint64_t kFailedTextureRetryLookups = 512;
// Skip empty or whitespace-only paths (type-0 textures have no filename)
if (path.empty()) return whiteTexture_.get();
bool allWhitespace = true;
@ -619,6 +624,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
return key;
};
std::string key = normalizeKey(path);
const uint64_t lookupSerial = ++textureLookupSerial_;
auto containsToken = [](const std::string& haystack, const char* token) {
return haystack.find(token) != std::string::npos;
};
@ -634,6 +640,10 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
it->second.lastUse = ++textureCacheCounter_;
return it->second.texture.get();
}
auto failIt = failedTextureRetryAt_.find(key);
if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) {
return whiteTexture_.get();
}
if (!assetManager || !assetManager->isInitialized()) {
return whiteTexture_.get();
@ -652,8 +662,9 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
blpImage = assetManager->loadTexture(key);
}
if (!blpImage.isValid()) {
// Return white fallback but don't cache the failure — allow retry
// on next character load in case the asset becomes available.
// Cache misses briefly to avoid repeated expensive MPQ/disk probes.
failedTextureCache_.insert(key);
failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups;
if (loggedTextureLoadFails_.insert(key).second) {
core::Logger::getInstance().warning("Failed to load texture: ", path);
}
@ -666,6 +677,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
if (failedTextureCache_.size() < kMaxFailedTextureCache) {
// Budget is saturated; avoid repeatedly decoding/uploading this texture.
failedTextureCache_.insert(key);
failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups;
}
if (textureBudgetRejectWarnings_ < 3) {
core::Logger::getInstance().warning(
@ -724,6 +736,8 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
textureHasAlphaByPtr_[texPtr] = hasAlpha;
textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint;
textureCache[key] = std::move(e);
failedTextureCache_.erase(key);
failedTextureRetryAt_.erase(key);
core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")");
return texPtr;

View file

@ -714,7 +714,9 @@ void M2Renderer::shutdown() {
textureHasAlphaByPtr_.clear();
textureColorKeyBlackByPtr_.clear();
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
loggedTextureLoadFails_.clear();
textureLookupSerial_ = 0;
textureBudgetRejectWarnings_ = 0;
whiteTexture_.reset();
glowTexture_.reset();
@ -4251,6 +4253,7 @@ void M2Renderer::cleanupUnusedModels() {
}
VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
constexpr uint64_t kFailedTextureRetryLookups = 512;
auto normalizeKey = [](std::string key) {
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
@ -4258,6 +4261,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
return key;
};
std::string key = normalizeKey(path);
const uint64_t lookupSerial = ++textureLookupSerial_;
// Check cache
auto it = textureCache.find(key);
@ -4265,7 +4269,10 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
it->second.lastUse = ++textureCacheCounter_;
return it->second.texture.get();
}
// No negative cache check — allow retries for transiently missing textures
auto failIt = failedTextureRetryAt_.find(key);
if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) {
return whiteTexture_.get();
}
auto containsToken = [](const std::string& haystack, const char* token) {
return haystack.find(token) != std::string::npos;
@ -4296,8 +4303,9 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
blp = assetManager->loadTexture(key);
}
if (!blp.isValid()) {
// Return white fallback but don't cache the failure — MPQ reads can
// fail transiently during streaming; allow retry on next model load.
// Cache misses briefly to avoid repeated expensive MPQ/disk probes.
failedTextureCache_.insert(key);
failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups;
if (loggedTextureLoadFails_.insert(key).second) {
LOG_WARNING("M2: Failed to load texture: ", path);
}
@ -4312,6 +4320,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
// Cache budget-rejected keys too; without this we repeatedly decode/load
// the same textures every frame once budget is saturated.
failedTextureCache_.insert(key);
failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups;
}
if (textureBudgetRejectWarnings_ < 3) {
LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024),
@ -4350,6 +4359,8 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) {
e.lastUse = ++textureCacheCounter_;
textureCacheBytes_ += e.approxBytes;
textureCache[key] = std::move(e);
failedTextureCache_.erase(key);
failedTextureRetryAt_.erase(key);
textureHasAlphaByPtr_[texPtr] = hasAlpha;
textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint;
LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")");

View file

@ -54,9 +54,11 @@ int computeTerrainWorkerCount() {
unsigned hc = std::thread::hardware_concurrency();
if (hc > 0) {
// Use most cores for loading — leave 1-2 for render/update threads.
const unsigned reserved = (hc >= 8u) ? 2u : 1u;
const unsigned targetWorkers = std::max(4u, hc - reserved);
// Keep terrain workers conservative by default. Over-subscribing loader
// threads can starve main-thread networking/render updates on large-core CPUs.
const unsigned reserved = (hc >= 16u) ? 4u : ((hc >= 8u) ? 2u : 1u);
const unsigned maxDefaultWorkers = 8u;
const unsigned targetWorkers = std::max(4u, std::min(maxDefaultWorkers, hc - reserved));
return static_cast<int>(targetWorkers);
}
return 4; // Fallback
@ -896,6 +898,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) {
continue;
}
if (!m2Renderer->hasModel(p.modelId)) {
continue;
}
uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale);
if (instId) {
ft.m2InstanceIds.push_back(instId);
@ -961,6 +966,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
continue;
}
if (!wmoRenderer->isModelLoaded(wmoReady.modelId)) {
continue;
}
uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation);
if (wmoInstId) {
ft.wmoInstanceIds.push_back(wmoInstId);

View file

@ -307,7 +307,9 @@ void WMORenderer::shutdown() {
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
loggedTextureLoadFails_.clear();
textureLookupSerial_ = 0;
textureBudgetRejectWarnings_ = 0;
// Free white texture and flat normal texture
@ -1087,7 +1089,9 @@ void WMORenderer::clearAll() {
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
loggedTextureLoadFails_.clear();
textureLookupSerial_ = 0;
textureBudgetRejectWarnings_ = 0;
precomputedFloorGrid.clear();
@ -2237,6 +2241,7 @@ std::unique_ptr<VkTexture> WMORenderer::generateNormalHeightMap(
}
VkTexture* WMORenderer::loadTexture(const std::string& path) {
constexpr uint64_t kFailedTextureRetryLookups = 512;
if (!assetManager || !vkCtx_) {
return whiteTexture_.get();
}
@ -2312,7 +2317,19 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) {
}
}
const auto& attemptedCandidates = uniqueCandidates;
const uint64_t lookupSerial = ++textureLookupSerial_;
std::vector<std::string> attemptedCandidates;
attemptedCandidates.reserve(uniqueCandidates.size());
for (const auto& c : uniqueCandidates) {
auto fit = failedTextureRetryAt_.find(c);
if (fit != failedTextureRetryAt_.end() && lookupSerial < fit->second) {
continue;
}
attemptedCandidates.push_back(c);
}
if (attemptedCandidates.empty()) {
return whiteTexture_.get();
}
// Try loading all candidates until one succeeds
// Check pre-decoded BLP cache first (populated by background worker threads)
@ -2339,6 +2356,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) {
}
}
if (!blp.isValid()) {
for (const auto& c : attemptedCandidates) {
failedTextureCache_.insert(c);
failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups;
}
if (loggedTextureLoadFails_.insert(key).second) {
core::Logger::getInstance().warning("WMO: Failed to load texture: ", path);
}
@ -2353,6 +2374,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) {
size_t base = static_cast<size_t>(blp.width) * static_cast<size_t>(blp.height) * 4ull;
size_t approxBytes = base + (base / 3);
if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) {
for (const auto& c : attemptedCandidates) {
failedTextureCache_.insert(c);
failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups;
}
if (textureBudgetRejectWarnings_ < 3) {
core::Logger::getInstance().warning(
"WMO texture cache full (", textureCacheBytes_ / (1024 * 1024),
@ -2394,8 +2419,12 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) {
textureCacheBytes_ += e.approxBytes;
if (!resolvedKey.empty()) {
textureCache[resolvedKey] = std::move(e);
failedTextureCache_.erase(resolvedKey);
failedTextureRetryAt_.erase(resolvedKey);
} else {
textureCache[key] = std::move(e);
failedTextureCache_.erase(key);
failedTextureRetryAt_.erase(key);
}
core::Logger::getInstance().debug("WMO: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")");