All five force-ACK handlers (speed, root, flag, collision-height,
knockback) repeated the same ~25-line GUID+counter+movementInfo+coord-
conversion+send sequence. Extracted into buildForceAck() which returns
a ready-to-send packet with the movement payload already written.
This also fixes a transport coordinate conversion bug: the collision-
height handler was the only one that omitted the ONTRANSPORT check,
causing position desync when riding boats/zeppelins. buildForceAck
handles transport coords uniformly for all callers.
Net -80 lines.
The auto-refresh after successful bid/buyout was gated on
lastAuctionSearch_.name.length() > 0, so a browse-all search (empty
name) would never refresh. Replaced with a hasAuctionSearch_ flag
that's set on any search regardless of the name filter.
Icon==6 and text=="GOSSIP_OPTION_BANKER" both sent BANKER_ACTIVATE
independently. Banking NPCs match both, so the packet was sent twice —
some servers toggle the bank window open then closed. Added sentBanker
guard so only one packet is sent.
Also extracts classifyGossipQuests() from two identical 30-line blocks
in handleGossipMessage and handleQuestgiverQuestList. The icon→status
mapping (5/6/10=completable, 3/4=incomplete, 2/7/8=available) is now
in one place with a why-comment explaining these are protocol-defined.
selectCharacter had 30+ if(spellHandler_) guards reaching into
SpellHandler internals (knownSpells_, spellCooldowns_, playerAuras_,
etc.) to clear per-character state. Consolidated into resetAllState()
so SpellHandler owns its own reset logic and new state fields don't
require editing GameHandler.
The 10s silence warning used a one-shot bool guard, but the 15s warning
used a 500ms time window — firing every frame (~30 times at 60fps).
Added rxSilence15sLogged_ guard consistent with the 10s pattern.
SMSG_PET_GUIDS, SMSG_PET_DISMISS_SOUND, and SMSG_PET_ACTION_SOUND were
registered with the same handler as SMSG_PET_UNLEARN_CONFIRM. Their
different formats (GUID lists, sound IDs with position) were misread as
unlearn cost, potentially triggering a bogus unlearn confirmation dialog.
Also extracts resetWardenState() from 13 lines duplicated verbatim
between connect() and disconnect().
Only ASCENDING was cleared — the DESCENDING flag was never toggled,
so outgoing movement packets during flight descent had incorrect flags.
Also clears DESCENDING on start-ascend and stop-ascend for symmetry.
Replaces static heartbeat log counter with member variable (was shared
across instances and not thread-safe) and demotes to LOG_DEBUG.
SMSG_ENVIRONMENTALDAMAGELOG (alias) registration at line 173 silently
overwrote the canonical SMSG_ENVIRONMENTAL_DAMAGE_LOG handler at line
108. The alias handler discarded envType (fall/lava/drowning), so the
UI couldn't differentiate environmental damage sources. Removed the
dead alias handler and its method; the canonical inline handler with
envType forwarding is now the sole registration.
The packet only contains uint8 count + count×uint64 GUIDs, but the
handler called readString() after each GUID. This consumed raw bytes of
subsequent GUIDs as a string, corrupting all entries after the first.
Now stores GUIDs in ignoreListGuids_ and resolves names asynchronously
via SMSG_NAME_QUERY_RESPONSE, matching the friends list pattern.
Also fixes unsafe static_pointer_cast in ready check (no type guard)
and removes redundant packetHasRemaining wrapper (duplicates Packet API).
Both the health==0 and dynFlags UNIT_DYNFLAG_DEAD paths duplicated the
same corpse-position caching and death-state logic with a subtle
asymmetry (only health path called stopAutoAttack). Extracted into
markPlayerDead() so coordinate swapping and state changes happen in one
place. stopAutoAttack remains at the health==0 call site since the
dynFlags path doesn't need it.
Three identical copies (game_handler.cpp, spell_handler.cpp,
quest_handler.cpp) plus two forward declarations (inventory_handler.cpp,
social_handler.cpp) replaced with a single inline definition in
game_utils.hpp. All affected files already include this header, so
quality color table changes now propagate from one source of truth.
Two fixes for item name resolution:
1. Clear entry from pendingItemQueries_ even when response parsing fails.
Previously a malformed response left the entry stuck in pending forever,
blocking all retries so the UI permanently showed "Item 12345".
2. Add 5-second periodic cleanup of pendingItemQueries_ so lost/dropped
responses don't permanently block item info resolution.
SpellHandler::updateTimers() was never called after PR #23 extraction,
so cast bar timers, spell cooldowns, and unit cast state timers never
ticked. Also removes duplicate cast/queue/spell members left in
GameHandler that shadowed the SpellHandler versions, and fixes
MovementHandler writing to those stale members on world portal.
Demotes SMSG_SPELL_START/CAST_RESULT debug logs to LOG_DEBUG.
- Restore 0x02→0x80 Classic harmful-to-WotLK debuff bit mapping in
syncClassicAurasFromFields so downstream checks work across expansions
- Extract handleDisplayIdChange helper to deduplicate identical logic
in onValuesUpdateUnit and onValuesUpdatePlayer
- Remove unused newItemCreated parameter from handleValuesUpdate
- Fix indentation on PLAYER_DEAD/PLAYER_ALIVE/PLAYER_UNGHOST emit calls
- split applyUpdateObjectBlock into handleCreateObject,
handleValuesUpdate, handleMovementUpdate
- extract concern helpers — createEntityFromBlock,
applyPlayerTransportState, applyUnitFieldsOnCreate/OnUpdate,
applyPlayerStatFields, dispatchEntitySpawn, trackItemOnCreate,
updateItemOnValuesUpdate, syncClassicAurasFromFields,
detectPlayerMountChange, updateNonPlayerTransportAttachment
- UnitFieldIndices, PlayerFieldIndices, UnitFieldUpdateResult
structs with static resolve() — eliminate repeated fieldIndex() calls
- IObjectTypeHandler strategy interface; concrete handlers
UnitTypeHandler, PlayerTypeHandler, GameObjectTypeHandler,
ItemTypeHandler, CorpseTypeHandler registered in typeHandlers_ map;
handleCreateObject and handleValuesUpdate now dispatch via
getTypeHandler() — adding a new object type requires zero changes
to existing handler methods
- PendingEvents member bus; all 27 inline owner_.fireAddonEvent()
calls in the update path replaced with pendingEvents_.emit(); events
flushed via flushPendingEvents() at the end of each handler, decoupling
field-parse logic from the addon callback system
entity_controller.cpp: 1520-line monolith → longest method ~200 lines,
cyclomatic complexity ~180 → ~5; zero duplicated CREATE/VALUES blocks
Moves entity lifecycle, name/creature/game-object caches, transport GUID
tracking, and the entire update-object pipeline out of GameHandler into a
new EntityController class (friend-class pattern, same as CombatHandler
et al.).
What moved:
- applyUpdateObjectBlock() — 1,520-line core of all entity creation,
field updates, and movement application
- processOutOfRangeObjects() / finalizeUpdateObjectBatch()
- handleUpdateObject() / handleCompressedUpdateObject() / handleDestroyObject()
- handleNameQueryResponse() / handleCreatureQueryResponse()
- handleGameObjectQueryResponse() / handleGameObjectPageText()
- handlePageTextQueryResponse()
- enqueueUpdateObjectWork() / processPendingUpdateObjectWork()
- playerNameCache, playerClassRaceCache_, pendingNameQueries
- creatureInfoCache, pendingCreatureQueries
- gameObjectInfoCache_, pendingGameObjectQueries_
- transportGuids_, serverUpdatedTransportGuids_
- EntityManager (accessed by other handlers via getEntityManager())
8 opcodes re-registered by EntityController::registerOpcodes():
SMSG_UPDATE_OBJECT, SMSG_COMPRESSED_UPDATE_OBJECT, SMSG_DESTROY_OBJECT,
SMSG_NAME_QUERY_RESPONSE, SMSG_CREATURE_QUERY_RESPONSE,
SMSG_GAMEOBJECT_QUERY_RESPONSE, SMSG_GAMEOBJECT_PAGETEXT,
SMSG_PAGE_TEXT_QUERY_RESPONSE
Other handler files (combat, movement, social, spell, inventory, quest,
chat) updated to access EntityManager via getEntityManager() and the
name cache via getPlayerNameCache() — no logic changes.
Also included:
- .clang-tidy: add modernize-use-nodiscard,
modernize-use-designated-initializers; set -std=c++20 in ExtraArgs
- test.sh: prepend clang's own resource include dir before GCC's to
silence xmmintrin.h / ia32intrin.h conflicts during clang-tidy runs
Line counts:
entity_controller.hpp 147 lines (new)
entity_controller.cpp 2172 lines (new)
game_handler.cpp 8095 lines (was 10143, −2048)
Build: 0 errors, 0 warnings.
RAW FIELDS dump shows item entries at odd indices: 283, 285, 287, 289...
With base=283, stride=2: 17 of 19 slots have valid item IDs (14200,
12020, 14378, etc). Slots 12-13 (trinkets) correctly empty.
With base=284: only 5 entries, and values are enchant IDs (913, 905, 904)
— these are the field AFTER each entry, confirming base was off by 1.
Stormwind WMO collision takes 25+ seconds to fully load. The warmup
ground check couldn't detect the WMO floor because collision data
wasn't finalized yet. Player spawned and immediately fell through
the unloaded WMO floor into the terrain below (Dun Morogh).
New approach: suspendGravityFor(10s) after world entry. Gravity is
disabled (Z position frozen) until either:
1. A floor is detected by the collision system (gravity resumes instantly)
2. The 10-second timer expires (gravity resumes as fallback)
This handles the case where WMO collision loads during the first few
seconds of gameplay — the player hovers at spawn Z until the floor
appears, then lands normally.
Also fixes faction language for chat (ORCISH for Horde, COMMON for
Alliance) and adds SMSG_MESSAGECHAT diagnostic logging.
hasPendingGroupInvite() and getPendingInviterName() were inline getters
reading GameHandler's stale copies. SocialHandler owns the canonical
pendingGroupInvite/pendingInviterName state. Players were auto-added to
groups without seeing the accept/decline popup.
Now delegates to socialHandler_.
Action bar hearthstone: the slot was type SPELL (spell 8690) not ITEM.
castSpell sends CMSG_CAST_SPELL which the server rejects for item-use
spells. Now detects item-use spells via getItemIdForSpell() and routes
through useItemById() instead, sending CMSG_USE_ITEM correctly.
Far same-map teleport: hearthstone on the same continent (e.g., Westfall
→ Stormwind on Azeroth) skipped the loading screen, so the player fell
through unloaded terrain. Now triggers a full world reload with loading
screen for teleports > 500 units, with the warmup ground check ensuring
WMO floors are loaded before spawning.
4 more stale getters from PR #23 split:
- isGossipWindowOpen() — QuestHandler owns gossipWindowOpen_
- getCurrentGossip() — QuestHandler owns currentGossip_
- isQuestDetailsOpen() — QuestHandler owns questDetailsOpen_
- getQuestDetails() — QuestHandler owns currentQuestDetails_
Also fix GameHandler::update() distance-close checks to use delegating
getters instead of stale member variables for vendor/gossip/taxi/trainer.
Map state (currentMapId_, worldStateZoneId_, exploredZones_) confirmed
NOT stale — domain handlers write via owner_. reference to GameHandler's
members. Those getters are correct as-is.
GameHandler::hasPendingTradeRequest() and all trade getters were reading
GameHandler's own tradeStatus_/tradeSlots_ which are never written after
the PR #23 split. InventoryHandler owns the canonical trade state.
Delegate all trade getters to InventoryHandler:
- getTradeStatus, hasPendingTradeRequest, isTradeOpen, getTradePeerName
- getMyTradeSlots, getPeerTradeSlots, getMyTradeGold, getPeerTradeGold
Also fix InventoryHandler::isTradeOpen() to include Accepted state.
Camera Stiffness (default 20, range 5-100): controls how tightly the
camera follows the player. Higher values = less sway/lag. Users who
experience motion sickness can increase this to reduce floaty camera.
Camera Pivot Height (default 1.8, range 0-3): height of the camera
orbit point above the player's feet. Lower values reduce the
"detached/floating" feel that can cause nausea. Setting to 0 puts the
pivot at foot level (ground-locked camera).
Both settings saved to settings file and applied via sliders in the
Gameplay tab of the Settings window.
Stride 4 was wrong — the raw dump shows entries at 284, 288, 292 which
are slots 0, 2, 4 with stride 2 (slot 1=NECK is zero because necks are
invisible). Stride 2 with base 284 correctly maps 19 equipment slots.
Added WARNING-level log when item query responses trigger equipment
re-emit for other players, to confirm the re-emit chain works.
The falling-through-world issue is likely terrain chunks not loading
fast enough — the terrain streaming stalls are still present.
RAW FIELDS dump shows equipment entries at indices 284, 288, 292, 296, 300
— stride 4, not 2. Each visible item slot occupies 4 fields (entry +
enchant + 2 padding), not 2 as previously assumed.
Field dump evidence:
[284]=3817(Reinforced Buckler) [288]=3808(Double Mail Boots)
[292]=3252 [296]=3823 [300]=3845 [312]=3825 [314]=3827
With stride 2, slots 0-18 read indices 284,286,288,290... which interleaves
entries with enchant/padding values, producing mostly zeros for equipment.
With stride 4, slots correctly map to entry-only fields.
PLAYER_VISIBLE_ITEM_1_ENTRYID = PLAYER_FIELD_INV_SLOT_HEAD(324) - 19*2
= 286. The previous value of 408 landed far past inventory slots in
string/name data, producing garbage entry IDs (ASCII fragments like
"mant", "alk ", "ryan") that the server rejected as invalid items.
Derivation: 19 visible item slots × 2 fields (entry + enchant) = 38
fields immediately before PLAYER_FIELD_INV_SLOT_HEAD at index 324.
PLAYER_VISIBLE_ITEM_1_ENTRYID for WotLK 3.3.5a is at UNIT_END(148) + 260
= field index 408 with stride 2. The previous default of 284 (UNIT_END+136)
was in the quest log field range, causing item IDs like "Lesser Invisibility
Potion" and "Deathstalker Report" to be read as equipment entries.
This was the root cause of other players appearing naked — item queries
returned valid responses but for the WRONG items (quest log entries instead
of equipment), so displayInfoIds were consumable/quest item appearances.
The heuristic auto-detection still overrides for Classic/TBC (different
stride per expansion), so this only affects the WotLK default before
detection runs.
Also filter addon whispers (GearScore GS_*, DBM, oRA, BigWigs, tab-prefixed)
from chat display — these are invisible in the real WoW client.
Extract domain-specific logic from the monolithic GameHandler into
dedicated handler classes, each owning its own opcode registration,
state, and packet parsing:
- CombatHandler: combat, XP, kill, PvP, loot roll (~26 methods)
- SpellHandler: spells, auras, pet stable, talent (~3+ methods)
- SocialHandler: friends, guild, groups, BG, RAF, PvP AFK (~14+ methods)
- ChatHandler: chat messages, channels, GM tickets, server messages,
defense/area-trigger messages (~7+ methods)
- InventoryHandler: items, trade, loot, mail, vendor, equipment sets,
read item (~3+ methods)
- QuestHandler: gossip, quests, completed quest response (~5+ methods)
- MovementHandler: movement, follow, transport (~2 methods)
- WardenHandler: Warden anti-cheat module
Each handler registers its own dispatch table entries via
registerOpcodes(DispatchTable&), called from
GameHandler::registerOpcodeHandlers(). GameHandler retains core
orchestration: auth/session handshake, update-object parsing,
opcode routing, and cross-handler coordination.
game_handler.cpp reduced from ~10,188 to ~9,432 lines.
Also add a POST_BUILD CMake step to symlink Data/ next to the
executable so expansion profiles and opcode tables are found at
runtime when running from build/bin/.
Entity storage: std::map<uint64_t, shared_ptr<Entity>> → unordered_map for
O(1) entity lookups instead of O(log n). No code depends on GUID ordering.
Player skills: std::map<uint32_t, PlayerSkill> → unordered_map.
DBC ID cache: std::map<uint32_t, uint32_t> → unordered_map.
Warden: apiHandlers_ and allocations_ → unordered_map (freeBlocks_ kept
as std::map since its coalescing logic requires ordered iteration).
Contacts: handleFriendStatus() did 3 separate O(n) find_if scans per
packet. Consolidated to single find_if with iterator reuse. O(3n) → O(n).
M2 renderer: move 3 per-frame local containers to member variables:
- particleGroups_ (unordered_map): reuse bucket structure across frames
- ribbonDraws_ (vector): reuse draw call buffer
- shadowTexSetCache_ (unordered_map): reuse descriptor cache
Eliminates ~3 heap allocations per frame in particle/ribbon/shadow passes.
UI polish:
- Nameplate hover tooltip showing level, class (players), guild name
- Bag window titles show slot counts: "Backpack (12/16)"
Player report: CMSG_COMPLAIN packet builder and reportPlayer() method.
"Report Player" option in target frame right-click menu for other players.
Server response handler (SMSG_COMPLAIN_RESULT) was already implemented.
Equipment: removed the visibleItemLayoutVerified_ gate from
updateOtherPlayerVisibleItems(). The default WotLK field layout (base=284,
stride=2) is correct and should be used immediately. The verification
heuristic was silently blocking ALL other-player equipment rendering by
queuing for auto-inspect (which doesn't return items in WotLK anyway).
Follow: auto-follow now uses run speed (autoRunning) instead of walk speed.
Also uses squared distance for the distance checks.
Commands: /quit, /exit aliases for /logout; /difficulty normal/heroic/25/25heroic
sends CMSG_CHANGEPLAYER_DIFFICULTY.
Inspect: CMSG_INSPECT was writing full uint64 GUID instead of packed GUID.
Server silently rejected the malformed packet. Fixed both InspectPacket and
QueryInspectAchievementsPacket to use writePackedGuid().
Follow: was a no-op (only stored GUID). Added client-side auto-follow system:
camera controller walks toward followed entity, faces target, cancels on
WASD/mouse input, stops within 3 units, cancels at 40+ units distance.
Party commands:
- /lootmethod (ffa/roundrobin/master/group/nbg) sends CMSG_LOOT_METHOD
- /lootthreshold (0-5 or quality name) sets minimum loot quality
- /raidconvert converts party to raid (leader only)
Equipment diagnostic logging still active for debugging naked players.
Mail: change money/COD fields from uint32 to uint64 in CMSG_SEND_MAIL and
SMSG_MAIL_LIST_RESULT for WotLK 3.3.5a. Classic keeps uint32 on the wire.
Fixes money truncation and packet misalignment causing mail failures.
Other-player capes: add cape texture loading to setOnlinePlayerEquipment().
The cape geoset was enabled but no texture was loaded, leaving capes blank.
Now mirrors the local-player path: looks up ItemDisplayInfo.dbc, finds cape
texture candidates, applies via setGroupTextureOverride/setTextureSlotOverride.
Zone toasts: suppress duplicate zone toast when the zone text overlay is
already showing the same zone name. Fixes double "Entering: Stormwind City".
Network: enable TCP_NODELAY on both auth and world sockets after connect(),
disabling Nagle's algorithm to eliminate up to 200ms buffering delay on
small packets (movement, spell casts, chat).
Rendering: track material and bone descriptor sets in M2 renderer to skip
redundant vkCmdBindDescriptorSets calls between batches sharing same textures.
- Replace count()+operator[] double lookups with find() or try_emplace()
in gameObjectInstances_, playerTextureSlotsByModelId_, onlinePlayerAppearance_
- Add Entity::isUnit() helper; replace 5 dynamic_cast<Unit*> in per-frame
UI rendering (nameplates, combat text, pet frame) with isUnit()+static_cast
- Add constexpr kInv255 reciprocal for per-pixel normal map generation loops
in character_renderer and wmo_renderer
Spline parsing: remove Classic format fallback from the WotLK parser. The
PacketParsers hierarchy already dispatches to expansion-specific parsers
(Classic/TBC/WotLK/Turtle), so the WotLK parseMovementBlock should only
attempt WotLK spline format. The Classic fallback could false-positive when
durationMod bytes resembled a valid point count, corrupting downstream parsing.
Preload DBC caches: call loadSpellNameCache() and 5 other lazy DBC caches
during handleLoginVerifyWorld() on initial world entry. This moves the ~170ms
Spell.csv load from the first SMSG_SPELL_GO handler to the loading screen,
eliminating the mid-gameplay stall.
WMO portal culling: move per-instance portalVisibleGroups vector and
portalVisibleGroupSet to reusable member variables, eliminating heap
allocations per WMO instance per frame.
Spline auto-detection: try WotLK format before Classic to prevent false-positive
matches where durationMod float bytes resemble a valid Classic pointCount. This
caused the movement block to consume wrong byte count, corrupting the update mask
read (maskBlockCount=57/129/203 instead of ~5) and silently dropping NPC spawns.
Terrain latency: bound WMO liquid group loading to 4 groups per advanceFinalization
call. Large WMOs (e.g., Stormwind canals with 40+ liquid groups) previously loaded
all groups in one unbounded loop, blowing past the 8ms frame budget and causing
stalls up to 1300ms. Now yields back to processReadyTiles() after 4 groups so the
time budget check can break out.
- Hoist DBC field index lookups before loops in game_handler (7 DBC iteration loops)
- Cache getSkybox()/getPosition() calls instead of redundant per-frame queries
- Merge textureHasAlphaByPtr_ + textureColorKeyBlackByPtr_ into single map
- Add constexpr for DEG_TO_RAD, reciprocal constants, physics delta
- Add reserve() for WMO/M2 collision grid queries and portal BFS
- Frustum plane normalize: inversesqrt instead of length+divide
- M2 particle emission: inversesqrt for direction normalization
- Parse creature display IDs from query response
- UI: show spell names/IDs as fallback instead of "Unknown"
Squared distance optimizations across 30 files:
- Convert glm::length() comparisons to glm::dot() (no sqrt)
- Use glm::inversesqrt() for check-then-normalize patterns (1 rsqrt vs 2 sqrt)
- Defer sqrt to after early-out checks in collision/movement code
- Hottest paths: camera_controller (21), weather particles, WMO collision,
transport movement, creature interpolation, nameplate culling
Container and algorithm improvements:
- std::map<string> → std::unordered_map for asset/DBC/MPQ/warden caches
- std::mutex → std::shared_mutex for asset_manager and mpq_manager caches
- std::sort → std::partial_sort in lighting_manager (top-2 of N volumes)
- Double-lookup find()+operator[] → insert_or_assign in game_handler
- Add reserve() for per-frame vectors: weather, swim_effects, WMO/M2 collision
Threading and synchronization:
- Replace 1ms busy-wait polling with condition_variable in character_renderer
- Move timestamp capture before mutex in logger
- Use memory_order_acquire/release for normal map completion signaling
API additions:
- DBC getStringView()/getStringViewByOffset() for zero-copy string access
- Parse creature display IDs from SMSG_CREATURE_QUERY_SINGLE_RESPONSE
Add [[nodiscard]] to VkShaderModule::loadFromFile, Shader::loadFromFile/
loadFromSource, AssetManifest::load, DbcLoader::load — all return bool
indicating success/failure that callers should check.
Suppress with (void) at 17 call sites where validity is checked via
isValid() after loading rather than the return value (m2_renderer
recreatePipelines, swim_effects recreatePipelines).
Add kCastGreen (interruptible cast bar, 5 uses) and kQueueGreen
(queue status / talent met, 7 uses across game_screen + talent_screen).
Remove commented-out renderQuestMarkers call (replaced by 3D billboards).
The findMemType/findMemoryType helper in auth_screen, loading_screen,
and vk_context returned 0 on failure — a valid memory type index.
Changed to return UINT32_MAX and log an error, so vkAllocateMemory
receives an invalid index and fails cleanly rather than silently
using the wrong memory type.
Add [[nodiscard]] to VkBuffer::uploadToGPU/createMapped and
VkContext::initialize/recreateSwapchain so callers that ignore
failure are flagged at compile time. Suppress with (void) cast at
3 call sites where failure is non-actionable (resize best-effort).
- Move itemKeys/spellKeys/thrKeys to shared kItemSetItemKeys/
kItemSetSpellKeys/kItemSetThresholdKeys in ui_colors.hpp, removing
5 identical local definitions across game_screen and inventory_screen
- Widen totem timer snprintf buffer from 8 to 16 bytes (defensive)
- Promote kStatTooltips to constexpr
Deduplicate class/race bitmask arrays (3 copies each → 1 shared) and
socket type definitions (3 copies → 1 shared). Eliminates ~80 lines of
repeated struct definitions across game_screen.cpp and inventory_screen.cpp.