Compare commits

...

56 commits

Author SHA1 Message Date
Kelsi
6dd7213083 Fix zombie renderer instances on same-map SMSG_NEW_WORLD teleports
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
When SMSG_NEW_WORLD fires with the same map ID (dungeon wing teleporters,
GM teleports, etc.), entityManager.clear() was called but renderer
instances in creatureInstances_/playerInstances_/gameObjectInstances_
were never despawned.  Fresh CREATE_OBJECTs from the server hit the
early-return guard (guid already in creatureInstances_) and were skipped,
leaving entities in the entity manager without matching renderer state.

Fix: pass isSameMap as isInitialEntry to the world-entry callback.  This
routes same-map SMSG_NEW_WORLD through the reconnect path which properly
despawns all renderer instances before the server resends CREATE_OBJECTs.
2026-03-11 19:59:42 -07:00
Kelsi
7dbf950323 Fix talent tab hang when switching trees by rate-limiting icon uploads
Switching from Arms to Fury (or any previously-unseen tab) caused a
multi-frame stall because getSpellIcon() loaded and uploaded all ~20
BLP textures synchronously in a single frame.  Limit new icon GPU
uploads to 4 per frame; uncached icons return null and are loaded on
subsequent frames, spreading the cost over ~5 frames with no visible
hang.
2026-03-11 19:48:00 -07:00
Kelsi
711cb966ef Fix chest interaction and measure server RTT for latency meter
Chests (and lockboxes, coffers, etc.) failed to open because CMSG_LOOT
was only sent on Classic/Turtle expansions, and only when GO type was
already cached as type 3.  Fix: always send CMSG_LOOT after
CMSG_GAMEOBJ_USE (server silently ignores it for non-lootable objects).
Also broaden CMSG_GAMEOBJ_REPORT_USE to all non-mailbox WotLK GOs.

Latency meter: record pingTimestamp_ in sendPing() and compute RTT in
handlePong(); add toggleable "Show Latency Meter" checkbox in Interface
settings (saved to settings.cfg).
2026-03-11 19:45:03 -07:00
Kelsi
14f672ab6a Pre-query vendor item information to avoid placeholder display
Proactively call ensureItemInfo() for both vendor items and buyback items
during rendering. This ensures item names and stats are available before
display, eliminating "Item <id>" placeholders and providing instant tooltip
info, matching the pattern used for quest rewards and action bar items.
2026-03-11 19:29:10 -07:00
Kelsi
b5291d1883 Revert quest reward window delay that caused dialog to hang
The delayed-opening logic conflicted with quest details' use of the same
questDetailsOpenTime variable, causing the reward dialog to never appear.
Reverted to immediately opening the window. Item info queries are still
triggered, but will populate asynchronously with placeholders shown initially.
2026-03-11 19:11:02 -07:00
Kelsi
510370dc7b Delay quest reward window opening to load item icons/names
Add 100ms delay before opening the quest offer reward dialog, giving item
info queries time to complete. Prevents "Item X" placeholders where players
can't see item names or icons needed to choose rewards. Reuses the existing
questDetailsOpenTime mechanism with delayed flag check in isQuestOfferRewardOpen().
2026-03-11 18:55:13 -07:00
Kelsi
9b9d56543c Re-query player names during rendering to resolve pending queries
When a player nameplate is about to render with an empty name (showing
"Player (level)" placeholder), actively re-request the name query. Since
queryPlayerName() is idempotent (won't duplicate pending queries), this
ensures that slow network responses don't cause players to permanently
display as "Player (67)" even after the response arrives. Rendering code
now triggers name queries to completion before falling back to placeholders.
2026-03-11 18:53:23 -07:00
Kelsi
182b6686ac Pre-query action bar item information to avoid placeholder display
When items are placed on the action bar, pre-fetch their ItemDef information
so the action bar displays the item name instead of a generic "Item" placeholder.
This ensures item names are available when the action bar is rendered, consistent
with the fix applied to quest reward items display.

Calls queryItemInfo() when an item is assigned to an action bar slot.
2026-03-11 18:36:32 -07:00
Kelsi
68a379610e Fix animation timing precision loss by replacing fmod with iterative subtraction
Floating-point fmod() loses precision with large accumulated time values, causing
subtle jumps/hitches in animation loops. Replace with iterative duration subtraction
to keep animationTime bounded and maintain precision, consistent with the fix
applied to character_renderer.cpp.

Applies to:
- M2 creature/object animation loops (main update)
- M2 particle-only instance wrapping (3333ms limit)
- M2 global sequence timing resolution
- M2 animated particle tile indexing
- Mount bobbing motion (sinusoidal rider motion)
- Character footstep trigger timing
- Mount footstep trigger timing

All timing computations now use the same precision-preserving approach.
2026-03-11 18:14:25 -07:00
Kelsi
f6f072a957 Increase lava/magma particle emission rate from 32 to 48 per second
Addresses sparseness in lava/magma effects noted in status documentation.
Higher emission rate (48 vs 32 per second) makes lava/slime areas visually
denser and more immersive while staying within GPU budget constraints.
2026-03-11 17:51:55 -07:00
Kelsi
eef269ffb8 Fix quest reward items showing as 'Item {number}' on first frame
Quest reward items (both in details and offer-reward windows) were showing as "Item {itemId}"
placeholders because the window opened immediately after receiving SMSG_QUESTGIVER_QUEST_DETAILS,
before the item query responses from pre-fetched queries had time to arrive.

Solution: Delay opening the quest details window by 100ms to allow item queries to complete
and be cached before the window first renders. Uses std::chrono::steady_clock for timing.

- Add questDetailsOpenTime field to track delayed opening timestamp
- Modify isQuestDetailsOpen() to check timer and open window when time expires
- Reset timer whenever quest details window closes
- Updated comment to clarify pre-fetch benefits both details and offer-reward windows
2026-03-11 17:27:23 -07:00
Kelsi
b5a48729b8 Add diagnostic logging for player appearance extraction failures
Log a warning when player model spawn fails due to appearance data extraction failure.
This helps diagnose why players appear invisible (missing field indices or malformed
update packets). Logs at both CREATE_OBJECT and VALUES update points.
2026-03-11 17:12:05 -07:00
Kelsi
b7479cbb50 Fix running animation hitching by using duration subtraction instead of fmod
Replace floating-point fmod() with iterative duration subtraction to preserve precision.
When animation time accumulates over many loops, fmod() loses precision with large values,
causing subtle jumps/hitches in looping animations. Subtracting the duration instead keeps
animationTime bounded in [0, duration) and avoids precision loss.
2026-03-11 17:02:15 -07:00
Kelsi
eb3cdbcc5f Fix stacked item count display in bags after looting
Move onlineItems_ lookup outside field iteration to ensure consistent item
reference when updating stack count/durability. This fixes an issue where
stacked item counts in open bags wouldn't update immediately when looting
additional items until the item was moved to another slot.
2026-03-11 16:58:36 -07:00
Kelsi
f7c752a316 Hide nameplates/health bars for corpses except when selected
Corpses no longer display nameplates or health bars unless they are the current
target (selected for loot or skinning). When selected, corpses show a minimal
grey nameplate with no health fill.
2026-03-11 16:54:30 -07:00
Kelsi
4d0eef1f6f Skip tab-targeting empty looted corpses
Dead creatures with no remaining loot items are now excluded from tab-targeting
cycle. Prevents cycling through empty corpses when looking for targetable enemies.
Corpses with available loot remain targetable.
2026-03-11 16:52:53 -07:00
Kelsi
bfeb978eff Fix character screen weapon slot positioning regression
Weapon slots were positioned too far right (at rightColX) causing overlap with right
column equipment. Repositioned to center column area (contentStartX + slotSize + 8px),
after left column. 3D preview renders on top, no visual conflict.
2026-03-11 16:49:27 -07:00
Kelsi
0c8fb94f0c Increase lava/magma smoke particle emission rate from 16 to 32 per second
Double the smoke particle emission rate to create visually richer lava and magma
effects. Current implementation emitted only 16 particles/sec per emitter (~88 in
steady state), which appeared sparse especially in multi-emitter lava areas.
Increasing to 32/sec provides denser steam/smoke effects (~176 in steady state)
while remaining well under the 1000 particle cap. This tuning opportunity was
documented in status.md as a known gap in visual completeness.
2026-03-11 16:47:07 -07:00
Kelsi
3f0e19970e Fix character screen weapon slots layout positioning
Position weapon slots (main hand, off hand, ranged) to align with the right
column instead of appearing in the left column where they crowd the main
equipment slots (tabbard, bracers, etc.). Weapons now positioned consistently
with the 3-column layout at rightColX instead of appearing at the default left
cursor position.
2026-03-11 16:32:30 -07:00
Kelsi
047b9157ad Validate transport registration before player attachment
Add upfront validation to setPlayerOnTransport to ensure the transport
GUID is registered in transportGuids_ before attaching the player. This
prevents transport desyncs when movement packets reference transports
that haven't been spawned/registered yet.
2026-03-11 16:06:36 -07:00
Kelsi
e2e049b718 docs: add Getting Started guide for new users 2026-03-11 15:36:44 -07:00
Kelsi
17bf963f3e docs: add comprehensive troubleshooting guide for users 2026-03-11 15:36:02 -07:00
Kelsi
2b8bb76d7a docs: add comprehensive multi-expansion architecture guide 2026-03-11 15:35:23 -07:00
Kelsi
1598766b1e docs: add Graphics & Performance guide with quality presets documentation 2026-03-11 15:34:16 -07:00
Kelsi
c77bd15538 docs: remove outdated 3D positional audio note - feature is implemented 2026-03-11 15:31:42 -07:00
Kelsi
90e7d61b6d docs: update for graphics presets and accurate shadow status 2026-03-11 15:30:45 -07:00
Kelsi
6f7c57d975 feat: add graphics quality presets system
Implement quick-access quality presets (Low, Medium, High, Ultra) that adjust multiple graphics settings at once for better user experience. Each preset configures:
- Shadow rendering and distance
- Anti-aliasing (MSAA) level
- Normal mapping and parallax mapping
- Ground clutter density

The system automatically detects when settings deviate from a preset and marks them as "Custom". Presets are persisted to settings.cfg for consistency across sessions. Users can quickly switch between performance and quality modes or tweak individual settings as needed.
2026-03-11 15:21:48 -07:00
Kelsi
6a8939d420 Harden final 8 parsers against truncated packets (100% coverage)
Remaining parsers now have upfront bounds checking:
- CharCreateResponseParser: validate 1 byte minimum
- QueryTimeResponseParser: validate 8 bytes (serverTime + offset)
- PlayedTimeParser: validate 9 bytes (totalTime + levelTime + flag)
- FriendStatusParser: validate 9 bytes + conditional string/flag
- LogoutResponseParser: validate 5 bytes (result + instant)
- RandomRollParser: validate 28 bytes (two GUIDs + three UInt32s)
- XpGainParser: validate 13 bytes base + conditional kill XP fields
- GroupInviteResponseParser: validate 1 byte + string (safe)

Packet parser hardening now at 100% coverage (all 106 parsers)
2026-03-11 15:08:48 -07:00
Kelsi
80c4e77c12 Harden GuildQueryResponseParser against truncated packets
Add bounds validation before reading guild name and 10 rank names.
Gracefully handle missing emblem data with safe defaults.
2026-03-11 14:46:44 -07:00
Kelsi
1979aa926b Harden TrainerListParser against truncated packets
Add upfront validation for header fields and per-spell bounds checking
before reading trainer spell data. Gracefully handle truncated greeting.
2026-03-11 14:44:52 -07:00
Kelsi
26f1a2d606 Harden GuildBankListParser against truncated packets
Cap tabCount to 8, add bounds validation before each tab and item read.
Gracefully handle truncated tab names, icons, and enchant data.
2026-03-11 14:43:03 -07:00
Kelsi
3849ad75ce Harden GuildRosterParser against unbounded memory allocation
Cap numMembers to 1000 and rankCount to 20 to prevent OOM attacks.
Add per-field bounds checking for member data with graceful truncation.
2026-03-11 14:42:09 -07:00
Kelsi
2c67331bc3 Harden MotdParser and UpdateObjectParser against truncated packets
- MotdParser: cap lineCount to 64 to prevent unbounded memory allocation,
  add bounds check before each string read
- UpdateObjectParser: add bounds validation before each update mask block
  and field value read to prevent reading past packet boundary
2026-03-11 14:41:25 -07:00
Kelsi
6fa1e49cb2 Harden CharEnumParser against truncated packets
Add upfront validation and per-field bounds checking to prevent
undefined behavior when parsing truncated SMSG_CHAR_ENUM packets.
Gracefully handle missing character data with safe defaults.
2026-03-11 14:40:07 -07:00
Kelsi
9892d82c52 Add upfront validation to group-related parsers
SMSG_PARTY_COMMAND_RESULT improvements:
- Validate 8-byte minimum for command + result + name string
- Graceful handling of truncated result field

SMSG_GROUP_DECLINE improvements:
- Validate 1-byte minimum for playerName CString
- Prevent reading from empty packets

Ensures consistent error handling for group system packets.
2026-03-11 14:38:11 -07:00
Kelsi
b699557597 Cap auction count in AuctionListResultParser
SMSG_AUCTION_LIST_RESULT (Classic/TBC/WotLK) improvements:
- Cap auction count to 256 (prevents unbounded memory allocation)
- Each entry is 80-104 bytes depending on expansion
- Prevents DoS from servers sending huge auction lists
- Log warning when cap is reached

Prevents memory exhaustion from malformed auction house packets.
2026-03-11 14:37:27 -07:00
Kelsi
6e94a3345f Add upfront validation to CastFailedParser
SMSG_CAST_FAILED (3.3.5a) improvements:
- Validate 6-byte minimum for castCount + spellId + result
- Prevent reading from truncated packets

Ensures consistent error handling for spell failure feedback.
2026-03-11 14:35:29 -07:00
Kelsi
4f3e817913 Harden GossipMessageParser against malformed packets
SMSG_GOSSIP_MESSAGE (3.3.5a) improvements:
- Validate 20-byte minimum for npcGuid + menuId + titleTextId + optionCount
- Cap optionCount to 64 (prevents unbounded memory allocation)
- Validate 12-byte minimum before each option read (fixed fields + 2 strings)
- Cap questCount to 64 (prevents unbounded memory allocation)
- Validate 18-byte minimum before each quest read (fixed fields + title string)
- Graceful truncation with partial list support

Prevents DoS from servers sending malformed gossip menus with huge option/quest lists.
2026-03-11 14:34:20 -07:00
Kelsi
efc394ce9e Cap spell cooldown entries in SpellCooldownParser
SMSG_SPELL_COOLDOWN (3.3.5a) improvements:
- Validate 9-byte minimum for guid + flags
- Cap cooldown entries to 512 (each entry is 8 bytes: spellId + ms)
- Prevent unbounded memory allocation from malformed packets
- Log warning when cap is reached with remaining data ignored

Prevents DoS from servers sending malformed cooldown lists.
2026-03-11 14:33:02 -07:00
Kelsi
1d4f69add3 Harden combat log parsers against malformed packets
SMSG_ATTACKERSTATEUPDATE (3.3.5a) improvements:
- Validate 13-byte minimum for hitInfo + GUIDs + totalDamage + count
- Cap subDamageCount to 64 (each entry is 20 bytes)
- Validate 20-byte minimum before each sub-damage entry read
- Validate 8-byte minimum before victimState/overkill read
- Validate 4-byte minimum before blocked amount read (optional field)

SMSG_SPELLDAMAGELOG (3.3.5a) improvements:
- Validate 30-byte minimum for all required fields
- Validate core fields before reading (21-byte check)
- Validate trailing fields (10-byte check) before reading flags/crit

SMSG_SPELLHEALLOG (3.3.5a) improvements:
- Validate 21-byte minimum for all required fields
- Validate remaining fields (17-byte check) before reading heal data
- Graceful truncation with field initialization

Prevents DoS and undefined behavior from high-frequency combat log packets.
2026-03-11 14:32:03 -07:00
Kelsi
68b3cef0fe Harden AuraUpdateParser against malformed packets
WotLK SMSG_AURA_UPDATE (3.3.5a) improvements:
- Cap entry count to 512 (isAll) or 1 (single) to prevent unbounded loop DoS
- Validate 5-byte minimum before each slot+spellId read
- Validate 3-byte minimum before flags/level/charges read
- Validate space before casterGuid packed GUID read
- Validate 8-byte minimum before duration field reads
- Validate 4-byte minimum before each effect amount read
- Graceful truncation with field initialization and partial read support
- Log all truncation events with entry index information

Prevents DoS and undefined behavior from high-frequency aura update packets.
2026-03-11 14:30:57 -07:00
Kelsi
7034bc5f63 Cap hit/miss counts in Classic and TBC spell parsers
Add DoS protection to Classic and TBC parseSpellGo implementations:
- Cap hitCount and missCount to 128 each (prevents OOM from huge arrays)
- Track actual reads vs expected counts
- Log truncation warnings with index information
- Graceful truncation with count updates

Ensures consistent hardening across all expansion variants (Vanilla/TBC/WotLK).
2026-03-11 14:29:37 -07:00
Kelsi
164124783b Harden SpellStart and SpellGo parsers against malformed packets
WotLK SMSG_SPELL_START (3.3.5a) improvements:
- Validate 22-byte minimum for packed GUIDs + fixed fields
- Validate targetFlags read (4 bytes)
- Validate targetGuid packed read with size check

WotLK SMSG_SPELL_GO (3.3.5a) improvements:
- Validate 24-byte minimum for core fields
- Cap hitCount to 128 to prevent OOM from huge target lists
- Cap missCount to 128 with same protection
- In-loop validation: check 8 bytes before each hit GUID read
- In-loop validation: check 2 bytes minimum before each miss entry (packed GUID + type)
- Graceful truncation with partial read support and count updates

Prevents DoS and undefined behavior from servers sending malformed combat packets.
2026-03-11 14:28:41 -07:00
Kelsi
98739c1610 Harden NameQueryResponseParser against malformed packets
Add upfront and in-loop validation for the WotLK variant of name query responses:
- Validate packed GUID and found flag reads (minimum 2 bytes)
- Validate strings can be read before attempting parse
- Validate 3 final uint8 fields (race/gender/class) exist before reading
- Graceful truncation handling with field initialization

Prevents undefined behavior from servers sending truncated/malformed packets.
2026-03-11 14:27:39 -07:00
Kelsi
2f1b142e14 Add packet size validation to SMSG_CREATURE_QUERY_RESPONSE parsing
Improve robustness of creature query response parsing by adding defensive
size checks to both WotLK/TBC and Classic variants:

- WotLK/TBC (world_packets.cpp): Add upfront validation for entry field,
  validate minimum size (16 bytes) before reading fixed fields
  (typeFlags, creatureType, family, rank), graceful truncation handling
- Classic (packet_parsers_classic.cpp): Add upfront entry validation,
  enhance existing truncation check with default field initialization,
  improve logging consistency
- Both variants now initialize fields to 0 on truncation and log warnings
  with entry context

Part of ongoing Tier 2 work to improve multi-expansion packet parsing robustness
against malformed or truncated server packets.
2026-03-11 14:19:58 -07:00
Kelsi
e464300346 Add pointCount cap to SMSG_MONSTER_MOVE spline parsing
Improve robustness of monster move spline parsing by capping the pointCount
field to prevent excessive iteration from malformed or malicious packets.

- WotLK: Cap pointCount to 1000 waypoints (realistic maximum for movement)
- Vanilla (Turtle): Reduce existing cap from 16384 to 1000 and add warning
  logging when cap is applied
- Both variants now log warnings when cap is exceeded, including guid context

A malicious or corrupted server sending an unrealistic pointCount value (e.g.
uint32_max) could previously cause the client to allocate excessive memory or
iterate excessively. The 1000-waypoint cap aligns with realistic movement
paths while protecting against DoS vectors.

Part of ongoing Tier 2 work to improve multi-expansion packet parsing robustness.
2026-03-11 14:13:09 -07:00
Kelsi
73abbc2a08 Add packet size validation to SMSG_GAMEOBJECT_QUERY_RESPONSE parsing
Improve robustness of game object query response parsing by adding defensive
size checks to both WotLK/TBC and Classic variants:

- WotLK/TBC (world_packets.cpp): Add upfront validation for entry, type,
  displayId fields, and improved in-loop handling for variable-length data
  array with partial data graceful degradation
- Classic (packet_parsers_classic.cpp): Add upfront validation for entry,
  type, displayId fields, and enhanced in-loop data array read with
  truncation detection
- Both variants now log warnings when data fields are truncated

Part of ongoing Tier 2 work to improve multi-expansion packet parsing robustness
against malformed or truncated server packets.
2026-03-11 14:11:45 -07:00
Kelsi
d1414b6a46 Add packet size validation to SMSG_INITIAL_SPELLS parsing
Improve robustness of initial spells parsing by adding defensive size checks:

- Validate minimum packet size for header (talentSpec + spellCount)
- Cap spellCount to max 256 spells to prevent excessive iteration
- Add in-loop size checks for each spell entry before reading (4 bytes
  vanilla, 6 bytes TBC/WotLK)
- Validate minimum size for cooldownCount field (optional, gracefully
  handles truncation before it)
- Cap cooldownCount to max 256 cooldowns to prevent excessive iteration
- Add in-loop size checks for each cooldown entry before reading (14 bytes
  vanilla, 16 bytes TBC/WotLK)
- Log warnings on packet truncation with clear context

Applies to both vanilla format (Classic) and TBC/WotLK format variants.
Part of ongoing Tier 2 work to improve multi-expansion packet parsing
robustness against malformed or truncated server packets.
2026-03-11 14:10:20 -07:00
Kelsi
f472ee3be8 Add packet size validation to SMSG_ITEM_QUERY_SINGLE_RESPONSE parsing
Improve robustness of item query response parsing across all three expansions
by adding defensive size checks and bounds validation:

- WotLK (world_packets.cpp): Add upfront validation for fixed-size fields,
  bounds cap on statsCount (max 10), in-loop size checks for stat pairs,
  and improved logging for truncation detection
- Classic (packet_parsers_classic.cpp): Add upfront validation for fixed fields,
  in-loop checks for 10 fixed stat pairs and 5 damage entries, and graceful
  truncation handling
- TBC (packet_parsers_tbc.cpp): Add upfront validation, statsCount bounds cap,
  and in-loop size checks for variable-length stats and fixed damage entries

All changes are backward compatible and log warnings on packet truncation.
This is part of ongoing Tier 2 work to improve multi-expansion packet parsing
robustness against malformed or truncated server packets.
2026-03-11 14:08:59 -07:00
Kelsi
d7e1a3773c Add validation caps and in-loop size checks to gossip message parsing
Improve gossip message parser robustness by:
- Adding count caps (max 256 options/quests) to prevent excessive memory allocation
- Adding in-loop size validation to detect truncated packets
- Gracefully breaking loops instead of reading garbage when packet runs out
- Logging warnings when packet truncation is detected

Applies to both Classic and TBC parseGossipMessage implementations.
Part of Tier 1/2 work to improve parser robustness across multi-expansion support.
2026-03-11 13:56:16 -07:00
Kelsi
d14f82cb7c Add packet size validation to character enum and movement parsing
Improve parser robustness by adding defensive size checks to prevent reading
beyond packet boundaries. Specifically:

- parseCharEnum (Classic/TBC): Add packet size validation and character count cap
  (max 32 chars) to prevent truncated packets from silently parsing garbage data
- parseMovementBlock (Classic/TBC): Add early validation for minimum packet size
  before reading updateFlags to catch empty packets early
- All changes are backward compatible and log warnings on truncation

This is part of Tier 1/2 work to improve multi-expansion packet parsing robustness
and prevent undefined behavior from malformed or truncated server packets.
2026-03-11 13:55:20 -07:00
Kelsi
fe2987dae1 feat: add frustum culling to quest marker rendering for consistency
Add view-frustum intersection testing to QuestMarkerRenderer::render() using
Frustum::intersectsSphere(), bringing quest marker culling in line with the
character instance and WMO group frustum culling improvements. Reduces marker
visibility testing overhead in scenes with many off-screen quest givers.
2026-03-11 13:30:07 -07:00
Kelsi
2c25e08a25 feat: upgrade character instance culling from distance/backface-check to frustum-sphere testing
Replace ad-hoc cone-based backface culling with proper view-frustum intersection
testing using Frustum::intersectsSphere(). Characters are now culled based on
visibility within the view frustum, improving accuracy in complex scenes and
reducing overdraw. Maintains distance-based culling for broad radius filtering.
2026-03-11 13:10:44 -07:00
Kelsi
a10e3e86fb feat: upgrade WMO group frustum culling from basic forward-check to proper frustum-AABB testing
Replace the basic forward-vector culling (which only culls when all AABB
corners are behind the camera) with proper frustum-AABB intersection testing
for more accurate and aggressive visibility culling. This reduces overdraw
and improves rendering performance in WMO-heavy scenes (dungeons, buildings).
2026-03-11 12:43:22 -07:00
Kelsi
508b7e839b feat: enable shadow rendering in character preview for visual depth
Enable shadows in character preview with 0.5 strength for a subtle
lighting effect that improves visual accuracy. Removes clearShadowMap()
call and enables shadowParams in preview UBO. Enhances character
appearance fidelity when viewing equipment and customization options.
2026-03-11 12:21:07 -07:00
Kelsi
6426bde7ea feat: enhance NPC tabard rendering with ItemDisplayInfo.dbc variant lookup
Look up tabard display ID from CreatureDisplayInfoExtra and map to
geoset variant via ItemDisplayInfo.dbc to select correct tabard
meshes. Falls back to hardcoded 1201 if DBC lookup unavailable.
Improves NPC appearance variety with proper scope handling.
2026-03-11 12:01:45 -07:00
21 changed files with 2150 additions and 182 deletions

126
EXPANSION_GUIDE.md Normal file
View file

@ -0,0 +1,126 @@
# Multi-Expansion Architecture Guide
WoWee supports three World of Warcraft expansions in a unified codebase using an expansion profile system. This guide explains how the multi-expansion support works.
## Supported Expansions
- **Vanilla (Classic) 1.12** - Original World of Warcraft
- **The Burning Crusade (TBC) 2.4.3** - First expansion
- **Wrath of the Lich King (WotLK) 3.3.5a** - Second expansion
## Architecture Overview
The multi-expansion support is built on the **Expansion Profile** system:
1. **ExpansionProfile** (`include/game/expansion_profile.hpp`) - Metadata about each expansion
- Defines protocol version, data paths, asset locations
- Specifies which packet parsers to use
2. **Packet Parsers** - Expansion-specific message handling
- `packet_parsers_classic.cpp` - Vanilla 1.12 message parsing
- `packet_parsers_tbc.cpp` - TBC 2.4.3 message parsing
- `packet_parsers_wotlk.cpp` (default) - WotLK 3.3.5a message parsing
3. **Update Fields** - Expansion-specific entity data layout
- Loaded from `update_fields.json` in expansion data directory
- Defines UNIT_END, OBJECT_END, field indices for stats/health/mana
## How to Use Different Expansions
### At Startup
WoWee auto-detects the expansion based on:
1. Realm list response (protocol version)
2. Server build number
3. Update field count
### Manual Selection
Set environment variable:
```bash
WOWEE_EXPANSION=tbc ./wowee # Force TBC
WOWEE_EXPANSION=classic ./wowee # Force Classic
```
## Key Differences Between Expansions
### Packet Format Differences
#### SMSG_SPELL_COOLDOWN
- **Classic**: 12 bytes per entry (spellId + itemId + cooldown, no flags)
- **TBC/WotLK**: 8 bytes per entry (spellId + cooldown) + flags byte
#### SMSG_ACTION_BUTTONS
- **Classic**: 120 slots, no mode byte
- **TBC**: 132 slots, no mode byte
- **WotLK**: 144 slots + uint8 mode byte
#### SMSG_PARTY_MEMBER_STATS
- **Classic/TBC**: Full uint64 for guid, uint16 health
- **WotLK**: PackedGuid format, uint32 health
### Data Differences
- **Talent trees**: Different spell IDs and tree structure per expansion
- **Items**: Different ItemDisplayInfo entries
- **Spells**: Different base stats, cooldowns
- **Character textures**: Expansion-specific variants for races
## Adding Support for Another Expansion
1. Create new expansion profile entry in `expansion_profile.cpp`
2. Add packet parser file (`packet_parsers_*.cpp`) for message variants
3. Create update_fields.json with correct field layout
4. Test realm connection and character loading
## Code Patterns
### Checking Current Expansion
```cpp
#include "game/expansion_profile.hpp"
// Global helper
bool isClassicLikeExpansion() {
auto profile = ExpansionProfile::getActive();
return profile && (profile->name == "Classic" || profile->name == "Vanilla");
}
// Specific check
if (GameHandler::getInstance().isActiveExpansion("tbc")) {
// TBC-specific code
}
```
### Expansion-Specific Packet Parsing
```cpp
// In packet_parsers_*.cpp, implement expansion-specific logic
bool parseXxxPacket(BitStream& data, ...) {
// Custom logic for this expansion's packet format
}
```
## Common Issues
### "Update fields mismatch" Error
- Ensure `update_fields.json` matches server's field layout
- Check OBJECT_END and UNIT_END values
- Verify field indices for your target expansion
### "Unknown packet" Warnings
- Expansion-specific opcodes may not be registered
- Check packet parser registration in `game_handler.cpp`
- Verify expansion profile is active
### Packet Parsing Failures
- Each expansion has different struct layouts
- Always read data size first, then upfront validate
- Use size capping (e.g., max 100 items in list)
## References
- `include/game/expansion_profile.hpp` - Expansion metadata
- `docs/status.md` - Current feature support by expansion
- `src/game/packet_parsers_*.cpp` - Format-specific parsing logic
- `docs/` directory - Additional protocol documentation

218
GETTING_STARTED.md Normal file
View file

@ -0,0 +1,218 @@
# Getting Started with WoWee
WoWee is a native C++ World of Warcraft client that connects to private servers. This guide walks you through setting up and playing WoWee.
## Prerequisites
- **World of Warcraft Game Data** (Vanilla 1.12, TBC 2.4.3, or WotLK 3.3.5a)
- **A Private Server** (AzerothCore, TrinityCore, Mangos, or Turtle WoW compatible)
- **System Requirements**: Linux, macOS, or Windows with a Vulkan-capable GPU
## Installation
### Step 1: Build WoWee
See [Building](README.md#building) section in README for detailed build instructions.
**Quick start (Linux/macOS)**:
```bash
./build.sh
cd build/bin
./wowee
```
**Quick start (Windows)**:
```powershell
.\build.ps1
cd build\bin
.\wowee.exe
```
### Step 2: Extract Game Data
WoWee needs game assets from your WoW installation:
**Using provided script (Linux/macOS)**:
```bash
./extract_assets.sh /path/to/wow/directory
```
**Using provided script (Windows)**:
```powershell
.\extract_assets.ps1 -WowDirectory "C:\Program Files\World of Warcraft"
```
**Manual extraction**:
1. Install [StormLib](https://github.com/ladislav-zezula/StormLib)
2. Extract to `./Data/`:
```
Data/
├── dbc/ # DBC files
├── map/ # World map data
├── adt/ # Terrain chunks
├── wmo/ # Building models
├── m2/ # Character/creature models
└── blp/ # Textures
```
### Step 3: Connect to a Server
1. **Start WoWee**
```bash
cd build/bin && ./wowee
```
2. **Enter Realm Information**
- Server Address: e.g., `localhost:3724` or `play.example.com:3724`
- WoWee fetches the realm list automatically
- Select your realm and click **Connect**
3. **Choose Character**
- Select existing character or create new one
- Customize appearance and settings
- Click **Enter World**
## First Steps in Game
### Default Controls
| Action | Key |
|--------|-----|
| Move Forward | W |
| Move Backward | S |
| Strafe Left | A |
| Strafe Right | D |
| Jump | Space |
| Toggle Chat | Enter |
| Interact (talk to NPC, loot) | F |
| Open Inventory | B |
| Open Spellbook | P |
| Open Talent Tree | T |
| Open Quest Log | Q |
| Open World Map | W (when not typing) |
| Toggle Minimap | M |
| Toggle Nameplates | V |
| Toggle Party Frames | F |
| Toggle Settings | Escape |
| Target Next Enemy | Tab |
| Target Previous Enemy | Shift+Tab |
### Customizing Controls
Press **Escape****Keybindings** to customize hotkeys.
## Recommended First Steps
### 1. Adjust Graphics Settings
- Press Escape → **Video Settings**
- Select appropriate **Graphics Preset** for your GPU:
- **LOW**: Low-end GPUs or when performance is priority
- **MEDIUM**: Balanced quality and performance
- **HIGH**: Good GPU with modern drivers
- **ULTRA**: High-end GPU for maximum quality
### 2. Adjust Audio
- Press Escape → **Audio Settings**
- Set **Master Volume** to preferred level
- Adjust individual audio tracks (Music, Ambient, UI, etc.)
- Toggle **Original Soundtrack** if available
### 3. Configure UI
- Press Escape → **Game Settings**
- Minimap preferences (rotation, square mode, zoom)
- Bag settings (separate windows, compact mode)
- Action bar visibility
### 4. Complete First Quest
- Talk to nearby NPCs (they have quest markers ! or ?)
- Accept quest, complete objectives, return for reward
- Level up and gain experience
## Important Notes
### Data Directory
Game data is loaded from `Data/` subdirectory:
- If running from build folder: `../../Data` (symlinked automatically)
- If running from binary folder: `./Data` (must exist)
- If running in-place: Ensure `Data/` is in correct location
### Settings
- Settings are saved to `~/.wowee/settings.cfg` (Linux/macOS)
- Or `%APPDATA%\wowee\settings.cfg` (Windows)
- Keybindings, graphics settings, and UI state persist
### Multi-Expansion Support
WoWee auto-detects expansion from server:
- **Vanilla 1.12** - Original game
- **TBC 2.4.3** - Burning Crusade
- **WotLK 3.3.5a** - Wrath of the Lich King
You can override with environment variable:
```bash
WOWEE_EXPANSION=tbc ./wowee # Force TBC
```
## Troubleshooting
### "No realm list" or "Connection Failed"
- Check server address is correct
- Verify server is running
- See [Troubleshooting Guide](TROUBLESHOOTING.md#connection-issues)
### Graphics Errors
- See [Graphics Troubleshooting](TROUBLESHOOTING.md#graphics-issues)
- Start with LOW graphics preset
- Update GPU driver
### Audio Not Working
- Check system audio is enabled
- Verify audio files are extracted
- See [Audio Troubleshooting](TROUBLESHOOTING.md#audio-issues)
### General Issues
- Comprehensive troubleshooting: See [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
- Check logs in `~/.wowee/logs/` for errors
- Verify expansion matches server requirements
## Server Configuration
### Tested Servers
- **AzerothCore** - Full support, recommended for learning
- **TrinityCore** - Full support, extensive customization
- **Mangos** - Full support, solid foundation
- **Turtle WoW** - Full support, 1.17 custom content
### Server Requirements
- Must support Vanilla, TBC, or WotLK protocol
- Warden anti-cheat supported (module execution via emulation)
- Network must allow connections to realm list and world server ports
See [Multi-Expansion Guide](EXPANSION_GUIDE.md) for protocol details.
## Next Steps
1. **Explore the World** - Travel to different zones and enjoy the landscape
2. **Join a Guild** - Find other players to group with
3. **Run Dungeons** - Experience instanced content
4. **PvP** - Engage in player-versus-player combat
5. **Twink Alt** - Create additional characters
6. **Customize Settings** - Fine-tune graphics, audio, and UI
## Getting Help
- **Game Issues**: See [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
- **Graphics Help**: See [Graphics & Performance](README.md#graphics--performance) section
- **Multi-Expansion**: See [EXPANSION_GUIDE.md](EXPANSION_GUIDE.md)
- **Building Issues**: See [README.md](README.md#building)
## Tips for Better Performance
- Start with reasonable graphics preset for your GPU
- Close other applications when testing
- Keep GPU drivers updated
- Use FSR2 (if supported) for smooth 60+ FPS on weaker hardware
- Monitor frame rate with debug overlay (if available)
## Enjoy!
WoWee is a project to experience classic World of Warcraft on a modern engine. Have fun exploring Azeroth!

View file

@ -69,7 +69,30 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**.
- **Pets** -- Pet tracking via SMSG_PET_SPELLS, action bar (10 slots with icon/autocast tinting/tooltips), dismiss button
- **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior
- **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine)
- **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)
- **UI** -- Loading screens with progress bar, settings window with graphics quality presets (LOW/MEDIUM/HIGH/ULTRA), shadow distance slider, minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)
## Graphics & Performance
### Quality Presets
WoWee includes four built-in graphics quality presets to help you quickly balance visual quality and performance:
| Preset | Shadows | MSAA | Normal Mapping | Clutter Density |
|--------|---------|------|----------------|-----------------|
| **LOW** | Off | Off | Disabled | 25% |
| **MEDIUM** | 200m distance | 2x | Basic | 60% |
| **HIGH** | 350m distance | 4x | Full (0.8x) | 100% |
| **ULTRA** | 500m distance | 8x | Enhanced (1.2x) | 150% |
Press Escape to open **Video Settings** and select a preset, or adjust individual settings for a custom configuration.
### Performance Tips
- Start with **LOW** or **MEDIUM** if you experience frame drops
- Shadows and MSAA have the largest impact on performance
- Reduce **shadow distance** if shadows cause issues
- Disable **water refraction** if you encounter GPU errors (requires FSR to be active)
- Use **FSR2** (built-in upscaling) for better frame rates on modern GPUs
## Building

186
TROUBLESHOOTING.md Normal file
View file

@ -0,0 +1,186 @@
# Troubleshooting Guide
This guide covers common issues and solutions for WoWee.
## Connection Issues
### "Authentication Failed"
- **Cause**: Incorrect server address, expired realm list, or version mismatch
- **Solution**:
1. Verify server address in realm list is correct
2. Ensure your WoW data directory is for the correct expansion (Vanilla/TBC/WotLK)
3. Check that the emulator server is running and reachable
### "Realm List Connection Failed"
- **Cause**: Server is down, firewall blocking connection, or DNS issue
- **Solution**:
1. Verify server IP/hostname is correct
2. Test connectivity: `ping realm-server-address`
3. Check firewall rules for port 3724 (auth) and 8085 (realm list)
4. Try using IP address instead of hostname (DNS issues)
### "Connection Lost During Login"
- **Cause**: Network timeout, server overload, or incompatible protocol version
- **Solution**:
1. Check your network connection
2. Reduce number of assets loading (lower graphics preset)
3. Verify server supports this expansion version
## Graphics Issues
### "VK_ERROR_DEVICE_LOST" or Client Crashes
- **Cause**: GPU driver issue, insufficient VRAM, or graphics feature incompatibility
- **Solution**:
1. **Immediate**: Disable advanced graphics features:
- Press Escape → Video Settings
- Set graphics preset to **LOW**
- Disable Water Refraction (requires FSR)
- Disable MSAA (set to Off)
2. **Medium term**: Update GPU driver to latest version
3. **Verify**: Use a graphics test tool to ensure GPU stability
4. **If persists**: Try FSR2 disabled mode - check renderer logs
### Black Screen or Rendering Issues
- **Cause**: Missing shaders, GPU memory allocation failure, or incorrect graphics settings
- **Solution**:
1. Check logs: Look in `~/.wowee/logs/` for error messages
2. Verify shaders compiled: Check for `.spv` files in `assets/shaders/compiled/`
3. Reduce shadow distance: Press Escape → Video Settings → Lower shadow distance from 300m to 100m
4. Disable shadows entirely if issues persist
### Low FPS or Frame Stuttering
- **Cause**: Too high graphics settings for your GPU, memory fragmentation, or asset loading
- **Solution**:
1. Apply lower graphics preset: Escape → LOW or MEDIUM
2. Disable MSAA: Set to "Off"
3. Reduce draw distance: Move further away from complex areas
4. Close other applications consuming GPU memory
5. Check CPU usage - if high, reduce number of visible entities
### Water/Terrain Flickering
- **Cause**: Shadow mapping artifacts, terrain LOD issues, or GPU memory pressure
- **Solution**:
1. Increase shadow distance slightly (150m to 200m)
2. Disable shadows entirely as last resort
3. Check GPU memory usage
## Audio Issues
### No Sound
- **Cause**: Audio initialization failed, missing audio data, or incorrect mixer setup
- **Solution**:
1. Check system audio is working: Test with another application
2. Verify audio files extracted: Check for `.wav` files in `Data/Audio/`
3. Unmute audio: Look for speaker icon in minimap (top-right) - click to unmute
4. Check settings: Escape → Audio Settings → Master Volume > 0
### Sound Cutting Out
- **Cause**: Audio buffer underrun, too many simultaneous sounds, or driver issue
- **Solution**:
1. Lower audio volume: Escape → Audio Settings → Reduce Master Volume
2. Disable distant ambient sounds: Reduce Ambient Volume
3. Reduce number of particle effects
4. Update audio driver
## Gameplay Issues
### Character Stuck or Not Moving
- **Cause**: Network synchronization issue, collision bug, or server desync
- **Solution**:
1. Try pressing Escape to deselect any target, then move
2. Jump (Spacebar) to test physics
3. Reload the character: Press Escape → Disconnect → Reconnect
4. Check for transport/vehicle state: Press 'R' to dismount if applicable
### Spells Not Casting or Showing "Error"
- **Cause**: Cooldown, mana insufficient, target out of range, or server desync
- **Solution**:
1. Verify spell is off cooldown (action bar shows availability)
2. Check mana/energy: Look at player frame (top-left)
3. Verify target range: Hover action bar button for range info
4. Check server logs for error messages (combat log will show reason)
### Quests Not Updating
- **Cause**: Objective already completed in different session, quest giver not found, or network desync
- **Solution**:
1. Check quest objective: Open quest log (Q key) → Verify objective requirements
2. Re-interact with NPC to trigger update packet
3. Reload character if issue persists
### Items Not Appearing in Inventory
- **Cause**: Inventory full, item filter active, or network desync
- **Solution**:
1. Check inventory space: Open inventory (B key) → Count free slots
2. Verify item isn't already there: Search inventory for item name
3. Check if bags are full: Open bag windows, consolidate items
4. Reload character if item is still missing
## Performance Optimization
### For Low-End GPUs
```
Graphics Preset: LOW
- Shadows: OFF
- MSAA: OFF
- Normal Mapping: Disabled
- Clutter Density: 25%
- Draw Distance: Minimum
- Particles: Reduced
```
### For Mid-Range GPUs
```
Graphics Preset: MEDIUM
- Shadows: 200m
- MSAA: 2x
- Normal Mapping: Basic
- Clutter Density: 60%
- FSR2: Enabled (if desired)
```
### For High-End GPUs
```
Graphics Preset: HIGH or ULTRA
- Shadows: 350-500m
- MSAA: 4-8x
- Normal Mapping: Full (1.2x strength)
- Clutter Density: 100-150%
- FSR2: Optional (for 4K smoothness)
```
## Getting Help
### Check Logs
Detailed logs are saved to:
- **Linux/macOS**: `~/.wowee/logs/`
- **Windows**: `%APPDATA%\wowee\logs\`
Include relevant log entries when reporting issues.
### Check Server Compatibility
- **AzerothCore**: Full support
- **TrinityCore**: Full support
- **Mangos**: Full support
- **Turtle WoW**: Full support (1.17)
### Report Issues
If you encounter a bug:
1. Enable logging: Watch console for error messages
2. Reproduce the issue consistently
3. Gather system info: GPU, driver version, OS
4. Check if issue is expansion-specific (Classic/TBC/WotLK)
5. Report with detailed steps to reproduce
### Clear Cache
If experiencing persistent issues, clear WoWee's cache:
```bash
# Linux/macOS
rm -rf ~/.wowee/warden_cache/
rm -rf ~/.wowee/asset_cache/
# Windows
rmdir %APPDATA%\wowee\warden_cache\ /s
rmdir %APPDATA%\wowee\asset_cache\ /s
```
Then restart WoWee to rebuild cache.

View file

@ -1,6 +1,6 @@
# Project Status
**Last updated**: 2026-03-07
**Last updated**: 2026-03-11
## What This Repo Is
@ -35,10 +35,9 @@ Implemented (working in normal use):
In progress / known gaps:
- Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain
- 3D positional audio: not implemented (mono/stereo only)
- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects)
- Interior rendering: WMO interior shadows disabled (too dark); lava steam particles sparse
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs)
- Lava steam particles: sparse in some areas (tuning opportunity)
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs); currently requires FSR to be active
## Where To Look

View file

@ -825,6 +825,11 @@ public:
glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset
TransportManager* getTransportManager() { return transportManager_.get(); }
void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) {
// Validate transport is registered before attaching player
// (defer if transport not yet registered to prevent desyncs)
if (transportGuid != 0 && !isTransportGuid(transportGuid)) {
return; // Transport not yet registered; skip attachment
}
playerTransportGuid_ = transportGuid;
playerTransportOffset_ = localOffset;
playerTransportStickyGuid_ = transportGuid;
@ -1063,7 +1068,18 @@ public:
void closeGossip();
bool isGossipWindowOpen() const { return gossipWindowOpen; }
const GossipMessageData& getCurrentGossip() const { return currentGossip; }
bool isQuestDetailsOpen() const { return questDetailsOpen; }
bool isQuestDetailsOpen() {
// Check if delayed opening timer has expired
if (questDetailsOpen) return true;
if (questDetailsOpenTime != std::chrono::steady_clock::time_point{}) {
if (std::chrono::steady_clock::now() >= questDetailsOpenTime) {
questDetailsOpen = true;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
return true;
}
}
return false;
}
const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; }
// Gossip / quest map POI markers (SMSG_GOSSIP_POI)
@ -1846,6 +1862,7 @@ private:
float timeSinceLastMoveHeartbeat_ = 0.0f; // Periodic movement heartbeat to keep server position synced
float moveHeartbeatInterval_ = 0.5f;
uint32_t lastLatency = 0; // Last measured latency (milliseconds)
std::chrono::steady_clock::time_point pingTimestamp_; // Time CMSG_PING was sent
// Player GUID and map
uint64_t playerGuid = 0;
@ -2179,6 +2196,7 @@ private:
// Quest details
bool questDetailsOpen = false;
std::chrono::steady_clock::time_point questDetailsOpenTime{}; // Delayed opening to allow item data to load
QuestDetailsData currentQuestDetails;
// Quest turn-in

View file

@ -113,6 +113,7 @@ private:
bool pendingMinimapRotate = false;
bool pendingMinimapSquare = false;
bool pendingMinimapNpcDots = false;
bool pendingShowLatencyMeter = true;
bool pendingSeparateBags = true;
bool pendingAutoLoot = false;
@ -143,11 +144,23 @@ private:
bool pendingAMDFramegen = false;
bool fsrSettingsApplied_ = false;
// Graphics quality presets
enum class GraphicsPreset : int {
CUSTOM = 0,
LOW = 1,
MEDIUM = 2,
HIGH = 3,
ULTRA = 4
};
GraphicsPreset currentGraphicsPreset = GraphicsPreset::CUSTOM;
GraphicsPreset pendingGraphicsPreset = GraphicsPreset::CUSTOM;
// UI element transparency (0.0 = fully transparent, 1.0 = fully opaque)
float uiOpacity_ = 0.65f;
bool minimapRotate_ = false;
bool minimapSquare_ = false;
bool minimapNpcDots_ = false;
bool showLatencyMeter_ = true; // Show server latency indicator
bool minimapSettingsApplied_ = false;
bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers
bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer
@ -252,6 +265,8 @@ private:
void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler);
void renderEscapeMenu();
void renderSettingsWindow();
void applyGraphicsPreset(GraphicsPreset preset);
void updateGraphicsPresetFromCurrentSettings();
void renderQuestMarkers(game::GameHandler& gameHandler);
void renderMinimapMarkers(game::GameHandler& gameHandler);
void renderQuestObjectiveTracker(game::GameHandler& gameHandler);

View file

@ -6451,7 +6451,34 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
// Show tabard mesh only when CreatureDisplayInfoExtra equips one.
if (hasGroup12 && hasEquippedTabard) {
uint16_t tabardSid = pickFromGroup(1201, 12);
uint16_t wantTabard = 1201; // Default fallback
// Try to read tabard geoset variant from ItemDisplayInfo.dbc (slot 9)
if (hasHumanoidExtra && itDisplayData != displayDataMap_.end() &&
itDisplayData->second.extraDisplayId != 0) {
auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId);
if (itExtra != humanoidExtraMap_.end()) {
uint32_t tabardDisplayId = itExtra->second.equipDisplayId[9];
if (tabardDisplayId != 0) {
auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
const auto* idiL = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
if (itemDisplayDbc && idiL) {
int32_t tabardIdx = itemDisplayDbc->findRecordById(tabardDisplayId);
if (tabardIdx >= 0) {
// Get geoset variant from ItemDisplayInfo GeosetGroup1 field
const uint32_t ggField = (*idiL)["GeosetGroup1"];
uint32_t tabardGG = itemDisplayDbc->getUInt32(static_cast<uint32_t>(tabardIdx), ggField);
if (tabardGG > 0) {
wantTabard = static_cast<uint16_t>(1200 + tabardGG);
}
}
}
}
}
}
uint16_t tabardSid = pickFromGroup(wantTabard, 12);
if (tabardSid != 0) normalizedGeosets.insert(tabardSid);
}

View file

@ -4491,6 +4491,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
if (currentQuestDetails.questId == questId) {
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
currentQuestDetails = QuestDetailsData{};
removed = true;
}
@ -7641,6 +7642,9 @@ void GameHandler::sendPing() {
LOG_DEBUG("Sending CMSG_PING (heartbeat)");
LOG_DEBUG(" Sequence: ", pingSequence);
// Record send time for RTT measurement
pingTimestamp_ = std::chrono::steady_clock::now();
// Build and send ping packet
auto packet = PingPacket::build(pingSequence, lastLatency);
socket->send(packet);
@ -7662,7 +7666,12 @@ void GameHandler::handlePong(network::Packet& packet) {
return;
}
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ")");
// Measure round-trip time
auto rtt = std::chrono::steady_clock::now() - pingTimestamp_;
lastLatency = static_cast<uint32_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(rtt).count());
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ", latency: ", lastLatency, "ms)");
}
uint32_t GameHandler::nextMovementTimestampMs() {
@ -8404,6 +8413,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
} else {
LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec,
" displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render");
}
}
} else if (creatureSpawnCallback_) {
@ -8800,6 +8812,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
} else {
LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec,
" displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render");
}
}
} else if (creatureSpawnCallback_) {
@ -8956,20 +8971,23 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY);
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
auto it = onlineItems_.find(block.guid);
bool isItemInInventory = (it != onlineItems_.end());
for (const auto& [key, val] : block.fields) {
auto it = onlineItems_.find(block.guid);
if (key == itemStackField) {
if (it != onlineItems_.end() && it->second.stackCount != val) {
if (key == itemStackField && isItemInInventory) {
if (it->second.stackCount != val) {
it->second.stackCount = val;
inventoryChanged = true;
}
} else if (key == itemDurField) {
if (it != onlineItems_.end() && it->second.curDurability != val) {
} else if (key == itemDurField && isItemInInventory) {
if (it->second.curDurability != val) {
it->second.curDurability = val;
inventoryChanged = true;
}
} else if (key == itemMaxDurField) {
if (it != onlineItems_.end() && it->second.maxDurability != val) {
} else if (key == itemMaxDurField && isItemInInventory) {
if (it->second.maxDurability != val) {
it->second.maxDurability = val;
inventoryChanged = true;
}
@ -10595,7 +10613,19 @@ void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
const uint64_t guid = e->getGuid();
auto* unit = dynamic_cast<Unit*>(e.get());
if (!unit) return false; // Not a unit (shouldn't happen after type filter)
if (unit->getHealth() == 0) return false; // Dead / corpse
if (unit->getHealth() == 0) {
// Dead corpse: only targetable if it has loot or is skinnableable
// If corpse was looted and is now empty, skip it (except for skinning)
auto lootIt = localLootState_.find(guid);
if (lootIt == localLootState_.end() || lootIt->second.data.items.empty()) {
// No loot data or all items taken; check if skinnableable
// For now, skip empty looted corpses (proper skinning check requires
// creature type data that may not be immediately available)
return false;
}
// Has unlooted items available
return true;
}
const bool hostileByFaction = unit->isHostile();
const bool hostileByCombat = isAggressiveTowardPlayer(guid);
if (!hostileByFaction && !hostileByCombat) return false;
@ -13890,6 +13920,10 @@ void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
actionBar[slot].type = type;
actionBar[slot].id = id;
// Pre-query item information so action bar displays item name instead of "Item" placeholder
if (type == ActionBarSlot::ITEM && id != 0) {
queryItemInfo(id, 0);
}
saveCharacterConfig();
}
@ -15051,10 +15085,12 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
// animation/sound and expects the client to request the mail list.
bool isMailbox = false;
bool chestLike = false;
// Chest-type game objects (type=3): on all expansions, also send CMSG_LOOT so
// the server opens the loot response. Other harvestable/interactive types rely
// on the server auto-sending SMSG_LOOT_RESPONSE after CMSG_GAMEOBJ_USE.
bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle");
// Always send CMSG_LOOT after CMSG_GAMEOBJ_USE for any gameobject that could be
// lootable. The server silently ignores CMSG_LOOT for non-lootable objects
// (doors, buttons, etc.), so this is safe. Not sending it is the main reason
// chests fail to open when their GO type is not yet cached or their name doesn't
// contain the word "chest" (e.g. lockboxes, coffers, strongboxes, caches).
bool shouldSendLoot = true;
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity);
auto* info = getCachedGameObjectInfo(go->getEntry());
@ -15070,22 +15106,20 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
refreshMailList();
} else if (info && info->type == 3) {
chestLike = true;
// Type-3 chests require CMSG_LOOT on all expansions (AzerothCore WotLK included)
shouldSendLoot = true;
} else if (turtleMode) {
// Turtle compatibility: keep eager loot open behavior.
shouldSendLoot = true;
}
}
if (!chestLike && !goName.empty()) {
std::string lower = goName;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
chestLike = (lower.find("chest") != std::string::npos);
if (chestLike) shouldSendLoot = true;
chestLike = (lower.find("chest") != std::string::npos ||
lower.find("lockbox") != std::string::npos ||
lower.find("strongbox") != std::string::npos ||
lower.find("coffer") != std::string::npos ||
lower.find("cache") != std::string::npos);
}
// For WotLK chest-like gameobjects, also send CMSG_GAMEOBJ_REPORT_USE.
if (!isMailbox && chestLike && isActiveExpansion("wotlk")) {
// For WotLK, CMSG_GAMEOBJ_REPORT_USE is required for chests (and is harmless for others).
if (!isMailbox && isActiveExpansion("wotlk")) {
network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE));
reportUse.writeUInt64(guid);
socket->send(reportUse);
@ -15330,10 +15364,11 @@ void GameHandler::handleQuestDetails(network::Packet& packet) {
break;
}
// Pre-fetch item info for all reward items so icons and names are ready
// by the time the offer-reward dialog opens (after the player turns in).
// both in this details window and later in the offer-reward dialog (after the player turns in).
for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0);
for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0);
questDetailsOpen = true;
// Delay opening the window slightly to allow item queries to complete
questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
gossipWindowOpen = false;
}
@ -15583,6 +15618,7 @@ void GameHandler::acceptQuest() {
LOG_DEBUG("Ignoring duplicate quest accept while pending: questId=", questId);
triggerQuestAcceptResync(questId, npcGuid, "duplicate-accept");
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
currentQuestDetails = QuestDetailsData{};
return;
}
@ -15592,6 +15628,7 @@ void GameHandler::acceptQuest() {
LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId,
" slot=", serverSlot);
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
currentQuestDetails = QuestDetailsData{};
return;
}
@ -15608,6 +15645,7 @@ void GameHandler::acceptQuest() {
pendingQuestAcceptNpcGuids_[questId] = npcGuid;
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
currentQuestDetails = QuestDetailsData{};
// Re-query quest giver status so marker updates (! → ?)
@ -15620,6 +15658,7 @@ void GameHandler::acceptQuest() {
void GameHandler::declineQuest() {
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
currentQuestDetails = QuestDetailsData{};
}
@ -15686,6 +15725,7 @@ void GameHandler::handleQuestRequestItems(network::Packet& packet) {
questRequestItemsOpen_ = true;
gossipWindowOpen = false;
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
// Query item names for required items
for (const auto& item : data.requiredItems) {
@ -15742,6 +15782,7 @@ void GameHandler::handleQuestOfferReward(network::Packet& packet) {
questRequestItemsOpen_ = false;
gossipWindowOpen = false;
questDetailsOpen = false;
questDetailsOpenTime = std::chrono::steady_clock::time_point{};
// Query item names for reward items
for (const auto& item : data.choiceRewards)
@ -17070,9 +17111,13 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK");
}
// Reload terrain at new position
// Reload terrain at new position.
// Pass isSameMap as isInitialEntry so the application despawns and
// re-registers renderer instances before the server resends CREATE_OBJECTs.
// Without this, same-map SMSG_NEW_WORLD (dungeon wing teleporters, etc.)
// leaves zombie renderer instances that block fresh entity spawns.
if (worldEntryCallback_) {
worldEntryCallback_(mapId, serverX, serverY, serverZ, false);
worldEntryCallback_(mapId, serverX, serverY, serverZ, isSameMap);
}
}

View file

@ -31,6 +31,12 @@ namespace ClassicMoveFlags {
// Same as TBC: u8 UpdateFlags, JUMPING=0x2000, 8 speeds, no pitchRate
// ============================================================================
bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
// Validate minimum packet size for updateFlags byte
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[Classic] Movement block packet too small (need at least 1 byte for updateFlags)");
return false;
}
// Classic: UpdateFlags is uint8 (same as TBC)
uint8_t updateFlags = packet.readUInt8();
block.updateFlags = static_cast<uint16_t>(updateFlags);
@ -385,14 +391,30 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
// Hit targets
if (rem() < 1) return true;
data.hitCount = packet.readUInt8();
// Cap hit count to prevent OOM from huge target lists
if (data.hitCount > 128) {
LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
data.hitCount = 128;
}
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) {
data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet));
}
// Check if we read all expected hits
if (data.hitTargets.size() < data.hitCount) {
LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(),
"/", (int)data.hitCount);
data.hitCount = data.hitTargets.size();
}
// Miss targets
if (rem() < 1) return true;
data.missCount = packet.readUInt8();
// Cap miss count to prevent OOM
if (data.missCount > 128) {
LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)data.missCount, ")");
data.missCount = 128;
}
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) {
SpellGoMissEntry m;
@ -401,6 +423,12 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
m.missType = packet.readUInt8();
data.missTargets.push_back(m);
}
// Check if we read all expected misses
if (data.missTargets.size() < data.missCount) {
LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)data.missTargets.size(),
"/", (int)data.missCount);
data.missCount = data.missTargets.size();
}
LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
@ -658,14 +686,40 @@ bool ClassicPacketParsers::parseCastResult(network::Packet& packet, uint32_t& sp
// - After flags: uint8 firstLogin (same as TBC)
// ============================================================================
bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& response) {
// Validate minimum packet size for count byte
if (packet.getSize() < 1) {
LOG_ERROR("[Classic] SMSG_CHAR_ENUM packet too small: ", packet.getSize(), " bytes");
return false;
}
uint8_t count = packet.readUInt8();
// Cap count to prevent excessive memory allocation
constexpr uint8_t kMaxCharacters = 32;
if (count > kMaxCharacters) {
LOG_WARNING("[Classic] Character count ", (int)count, " exceeds max ", (int)kMaxCharacters,
", capping");
count = kMaxCharacters;
}
LOG_INFO("[Classic] Parsing SMSG_CHAR_ENUM: ", (int)count, " characters");
response.characters.clear();
response.characters.reserve(count);
for (uint8_t i = 0; i < count; ++i) {
// Sanity check: ensure we have at least minimal data before reading next character
// Minimum: guid(8) + name(1) + race(1) + class(1) + gender(1) + appearance(4)
// + facialFeatures(1) + level(1) + zone(4) + map(4) + pos(12) + guild(4)
// + flags(4) + firstLogin(1) + pet(12) + equipment(20*5)
constexpr size_t kMinCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 12 + 4 + 4 + 1 + 12 + 100;
if (packet.getReadPos() + kMinCharacterSize > packet.getSize()) {
LOG_WARNING("[Classic] Character enum packet truncated at character ", (int)(i + 1),
", pos=", packet.getReadPos(), " needed=", kMinCharacterSize,
" size=", packet.getSize());
break;
}
Character character;
// GUID (8 bytes)
@ -947,6 +1001,12 @@ bool ClassicPacketParsers::parseGuildQueryResponse(network::Packet& packet, Guil
// ============================================================================
bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) {
// Validate minimum packet size: entry(4)
if (packet.getSize() < 4) {
LOG_ERROR("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
return false;
}
data.entry = packet.readUInt32();
// High bit set means gameobject not found
@ -956,6 +1016,12 @@ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet,
return true;
}
// Validate minimum size for fixed fields: type(4) + displayId(4)
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_ERROR("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")");
return false;
}
data.type = packet.readUInt32();
data.displayId = packet.readUInt32();
// 4 name strings
@ -971,6 +1037,16 @@ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet,
data.data[i] = packet.readUInt32();
}
data.hasData = true;
} else if (remaining > 0) {
// Partial data field; read what we can
uint32_t fieldsToRead = remaining / 4;
for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) {
data.data[i] = packet.readUInt32();
}
if (fieldsToRead < 24) {
LOG_WARNING("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead,
" of 24 read, entry=", data.entry, ")");
}
}
if (data.type == 15) { // MO_TRANSPORT
@ -1000,9 +1076,24 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
data.titleTextId = packet.readUInt32();
uint32_t optionCount = packet.readUInt32();
// Cap option count to reasonable maximum
constexpr uint32_t kMaxGossipOptions = 256;
if (optionCount > kMaxGossipOptions) {
LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE optionCount=", optionCount, " exceeds max ",
kMaxGossipOptions, ", capping");
optionCount = kMaxGossipOptions;
}
data.options.clear();
data.options.reserve(optionCount);
for (uint32_t i = 0; i < optionCount; ++i) {
// Sanity check: ensure minimum bytes available for option (id(4)+icon(1)+isCoded(1)+text(1))
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 7) {
LOG_WARNING("Classic gossip option ", i, " truncated (", remaining, " bytes left)");
break;
}
GossipOption opt;
opt.id = packet.readUInt32();
opt.icon = packet.readUInt8();
@ -1014,10 +1105,33 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
data.options.push_back(opt);
}
// Ensure we have at least 4 bytes for questCount
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 4) {
LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE truncated before questCount");
return data.options.size() > 0; // Return true if we got at least some options
}
uint32_t questCount = packet.readUInt32();
// Cap quest count to reasonable maximum
constexpr uint32_t kMaxGossipQuests = 256;
if (questCount > kMaxGossipQuests) {
LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE questCount=", questCount, " exceeds max ",
kMaxGossipQuests, ", capping");
questCount = kMaxGossipQuests;
}
data.quests.clear();
data.quests.reserve(questCount);
for (uint32_t i = 0; i < questCount; ++i) {
// Sanity check: ensure minimum bytes available for quest (id(4)+icon(4)+level(4)+title(1))
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 13) {
LOG_WARNING("Classic gossip quest ", i, " truncated (", remaining, " bytes left)");
break;
}
GossipQuestItem quest;
quest.questId = packet.readUInt32();
quest.questIcon = packet.readUInt32();
@ -1193,6 +1307,12 @@ network::Packet ClassicPacketParsers::buildItemQuery(uint32_t entry, uint64_t gu
}
bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) {
// Validate minimum packet size: entry(4)
if (packet.getSize() < 4) {
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)");
return false;
}
data.entry = packet.readUInt32();
// High bit set means item not found
@ -1201,6 +1321,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
return true;
}
// Validate minimum size for fixed fields: itemClass(4) + subClass(4) + 4 name strings + displayInfoId(4) + quality(4)
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before names (entry=", data.entry, ")");
return false;
}
uint32_t itemClass = packet.readUInt32();
uint32_t subClass = packet.readUInt32();
// Vanilla: NO SoundOverrideSubclass
@ -1249,6 +1375,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
data.displayInfoId = packet.readUInt32();
data.quality = packet.readUInt32();
// Validate minimum size for fixed fields: Flags(4) + BuyPrice(4) + SellPrice(4) + inventoryType(4)
if (packet.getSize() - packet.getReadPos() < 16) {
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before inventoryType (entry=", data.entry, ")");
return false;
}
packet.readUInt32(); // Flags
// Vanilla: NO Flags2
packet.readUInt32(); // BuyPrice
@ -1256,6 +1388,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
data.inventoryType = packet.readUInt32();
// Validate minimum size for remaining fixed fields: 13×4 = 52 bytes
if (packet.getSize() - packet.getReadPos() < 52) {
LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before stats (entry=", data.entry, ")");
return false;
}
packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace
data.itemLevel = packet.readUInt32();
@ -1271,8 +1409,16 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
data.maxStack = static_cast<int32_t>(packet.readUInt32()); // Stackable
data.containerSlots = packet.readUInt32();
// Vanilla: 10 stat pairs, NO statsCount prefix
// Vanilla: 10 stat pairs, NO statsCount prefix (10×8 = 80 bytes)
if (packet.getSize() - packet.getReadPos() < 80) {
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated in stats section (entry=", data.entry, ")");
// Read what we can
}
for (uint32_t i = 0; i < 10; i++) {
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")");
break;
}
uint32_t statType = packet.readUInt32();
int32_t statValue = static_cast<int32_t>(packet.readUInt32());
if (statType != 0) {
@ -1295,6 +1441,11 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
// Vanilla: 5 damage types (same count as WotLK)
bool haveWeaponDamage = false;
for (int i = 0; i < 5; i++) {
// Each damage entry is dmgMin(4) + dmgMax(4) + damageType(4) = 12 bytes
if (packet.getSize() - packet.getReadPos() < 12) {
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: damage ", i, " truncated (entry=", data.entry, ")");
break;
}
float dmgMin = packet.readFloat();
float dmgMax = packet.readFloat();
uint32_t damageType = packet.readUInt32();
@ -1308,6 +1459,11 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
}
}
// Validate minimum size for armor field (4 bytes)
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before armor (entry=", data.entry, ")");
return true; // Have core fields; armor is important but optional
}
data.armor = static_cast<int32_t>(packet.readUInt32());
// Remaining tail can vary by core. Read resistances + delay when present.
@ -1621,6 +1777,12 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u
// ============================================================================
bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet,
CreatureQueryResponseData& data) {
// Validate minimum packet size: entry(4)
if (packet.getSize() < 4) {
LOG_ERROR("Classic SMSG_CREATURE_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
return false;
}
data.entry = packet.readUInt32();
if (data.entry & 0x80000000) {
data.entry &= ~0x80000000;
@ -1635,15 +1797,19 @@ bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet,
data.subName = packet.readString();
// NOTE: NO iconName field in Classic 1.12 — goes straight to typeFlags
if (packet.getReadPos() + 16 > packet.getSize()) {
LOG_WARNING("[Classic] Creature query: truncated at typeFlags (entry=", data.entry, ")");
return true;
LOG_WARNING("Classic SMSG_CREATURE_QUERY_RESPONSE: truncated at typeFlags (entry=", data.entry, ")");
data.typeFlags = 0;
data.creatureType = 0;
data.family = 0;
data.rank = 0;
return true; // Have name/sub fields; base fields are important but optional
}
data.typeFlags = packet.readUInt32();
data.creatureType = packet.readUInt32();
data.family = packet.readUInt32();
data.rank = packet.readUInt32();
LOG_DEBUG("[Classic] Creature query: ", data.name, " type=", data.creatureType,
LOG_DEBUG("Classic SMSG_CREATURE_QUERY_RESPONSE: ", data.name, " type=", data.creatureType,
" rank=", data.rank);
return true;
}

View file

@ -30,6 +30,12 @@ namespace TbcMoveFlags {
// - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32)
// ============================================================================
bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
// Validate minimum packet size for updateFlags byte
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[TBC] Movement block packet too small (need at least 1 byte for updateFlags)");
return false;
}
// TBC 2.4.3: UpdateFlags is uint8 (1 byte)
uint8_t updateFlags = packet.readUInt8();
block.updateFlags = static_cast<uint16_t>(updateFlags);
@ -297,14 +303,40 @@ network::Packet TbcPacketParsers::buildMovementPacket(LogicalOpcode opcode,
// - Equipment: 20 items (not 23)
// ============================================================================
bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& response) {
// Validate minimum packet size for count byte
if (packet.getSize() < 1) {
LOG_ERROR("[TBC] SMSG_CHAR_ENUM packet too small: ", packet.getSize(), " bytes");
return false;
}
uint8_t count = packet.readUInt8();
// Cap count to prevent excessive memory allocation
constexpr uint8_t kMaxCharacters = 32;
if (count > kMaxCharacters) {
LOG_WARNING("[TBC] Character count ", (int)count, " exceeds max ", (int)kMaxCharacters,
", capping");
count = kMaxCharacters;
}
LOG_INFO("[TBC] Parsing SMSG_CHAR_ENUM: ", (int)count, " characters");
response.characters.clear();
response.characters.reserve(count);
for (uint8_t i = 0; i < count; ++i) {
// Sanity check: ensure we have at least minimal data before reading next character
// Minimum: guid(8) + name(1) + race(1) + class(1) + gender(1) + appearance(4)
// + facialFeatures(1) + level(1) + zone(4) + map(4) + pos(12) + guild(4)
// + flags(4) + firstLogin(1) + pet(12) + equipment(20*9)
constexpr size_t kMinCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 12 + 4 + 4 + 1 + 12 + 180;
if (packet.getReadPos() + kMinCharacterSize > packet.getSize()) {
LOG_WARNING("[TBC] Character enum packet truncated at character ", (int)(i + 1),
", pos=", packet.getReadPos(), " needed=", kMinCharacterSize,
" size=", packet.getSize());
break;
}
Character character;
// GUID (8 bytes)
@ -508,9 +540,25 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage
data.titleTextId = packet.readUInt32();
uint32_t optionCount = packet.readUInt32();
// Cap option count to reasonable maximum
constexpr uint32_t kMaxGossipOptions = 256;
if (optionCount > kMaxGossipOptions) {
LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE optionCount=", optionCount, " exceeds max ",
kMaxGossipOptions, ", capping");
optionCount = kMaxGossipOptions;
}
data.options.clear();
data.options.reserve(optionCount);
for (uint32_t i = 0; i < optionCount; ++i) {
// Sanity check: ensure minimum bytes available for option
// (id(4)+icon(1)+isCoded(1)+boxMoney(4)+text(1)+boxText(1))
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 12) {
LOG_WARNING("[TBC] gossip option ", i, " truncated (", remaining, " bytes left)");
break;
}
GossipOption opt;
opt.id = packet.readUInt32();
opt.icon = packet.readUInt8();
@ -521,10 +569,34 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage
data.options.push_back(opt);
}
// Ensure we have at least 4 bytes for questCount
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 4) {
LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE truncated before questCount");
return data.options.size() > 0; // Return true if we got at least some options
}
uint32_t questCount = packet.readUInt32();
// Cap quest count to reasonable maximum
constexpr uint32_t kMaxGossipQuests = 256;
if (questCount > kMaxGossipQuests) {
LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE questCount=", questCount, " exceeds max ",
kMaxGossipQuests, ", capping");
questCount = kMaxGossipQuests;
}
data.quests.clear();
data.quests.reserve(questCount);
for (uint32_t i = 0; i < questCount; ++i) {
// Sanity check: ensure minimum bytes available for quest
// (id(4)+icon(4)+level(4)+title(1))
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 13) {
LOG_WARNING("[TBC] gossip quest ", i, " truncated (", remaining, " bytes left)");
break;
}
GossipQuestItem quest;
quest.questId = packet.readUInt32();
quest.questIcon = packet.readUInt32();
@ -886,12 +958,24 @@ bool TbcPacketParsers::parseNameQueryResponse(network::Packet& packet, NameQuery
// - Has statsCount prefix (Classic reads 10 pairs with no prefix)
// ============================================================================
bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) {
// Validate minimum packet size: entry(4)
if (packet.getSize() < 4) {
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)");
return false;
}
data.entry = packet.readUInt32();
if (data.entry & 0x80000000) {
data.entry &= ~0x80000000;
return true;
}
// Validate minimum size for fixed fields: itemClass(4) + subClass(4) + soundOverride(4) + 4 name strings + displayInfoId(4) + quality(4)
if (packet.getSize() - packet.getReadPos() < 12) {
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before names (entry=", data.entry, ")");
return false;
}
uint32_t itemClass = packet.readUInt32();
uint32_t subClass = packet.readUInt32();
data.itemClass = itemClass;
@ -908,6 +992,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
data.displayInfoId = packet.readUInt32();
data.quality = packet.readUInt32();
// Validate minimum size for fixed fields: Flags(4) + BuyPrice(4) + SellPrice(4) + inventoryType(4)
if (packet.getSize() - packet.getReadPos() < 16) {
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before inventoryType (entry=", data.entry, ")");
return false;
}
packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2)
// TBC: NO Flags2, NO BuyCount
packet.readUInt32(); // BuyPrice
@ -915,6 +1005,12 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
data.inventoryType = packet.readUInt32();
// Validate minimum size for remaining fixed fields: 13×4 = 52 bytes
if (packet.getSize() - packet.getReadPos() < 52) {
LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")");
return false;
}
packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace
data.itemLevel = packet.readUInt32();
@ -931,9 +1027,22 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
data.containerSlots = packet.readUInt32();
// TBC: statsCount prefix + exactly statsCount pairs (WotLK always sends 10)
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated at statsCount (entry=", data.entry, ")");
return true; // Have core fields; stats are optional
}
uint32_t statsCount = packet.readUInt32();
if (statsCount > 10) statsCount = 10; // sanity cap
if (statsCount > 10) {
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: statsCount=", statsCount, " exceeds max 10 (entry=",
data.entry, "), capping");
statsCount = 10;
}
for (uint32_t i = 0; i < statsCount; i++) {
// Each stat is 2 uint32s = 8 bytes
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")");
break;
}
uint32_t statType = packet.readUInt32();
int32_t statValue = static_cast<int32_t>(packet.readUInt32());
switch (statType) {
@ -950,9 +1059,14 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
}
// TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only)
// 5 damage entries
// 5 damage entries (5×12 = 60 bytes)
bool haveWeaponDamage = false;
for (int i = 0; i < 5; i++) {
// Each damage entry is dmgMin(4) + dmgMax(4) + damageType(4) = 12 bytes
if (packet.getSize() - packet.getReadPos() < 12) {
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: damage ", i, " truncated (entry=", data.entry, ")");
break;
}
float dmgMin = packet.readFloat();
float dmgMax = packet.readFloat();
uint32_t damageType = packet.readUInt32();
@ -965,6 +1079,11 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
}
}
// Validate minimum size for armor (4 bytes)
if (packet.getSize() - packet.getReadPos() < 4) {
LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before armor (entry=", data.entry, ")");
return true; // Have core fields; armor is important but optional
}
data.armor = static_cast<int32_t>(packet.readUInt32());
if (packet.getSize() - packet.getReadPos() >= 28) {
@ -1157,13 +1276,29 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
}
data.hitCount = packet.readUInt8();
// Cap hit count to prevent OOM from huge target lists
if (data.hitCount > 128) {
LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
data.hitCount = 128;
}
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
data.hitTargets.push_back(packet.readUInt64()); // full GUID in TBC
}
// Check if we read all expected hits
if (data.hitTargets.size() < data.hitCount) {
LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(),
"/", (int)data.hitCount);
data.hitCount = data.hitTargets.size();
}
if (packet.getReadPos() < packet.getSize()) {
data.missCount = packet.readUInt8();
// Cap miss count to prevent OOM
if (data.missCount > 128) {
LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)data.missCount, ")");
data.missCount = 128;
}
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) {
SpellGoMissEntry m;
@ -1171,6 +1306,12 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
m.missType = packet.readUInt8();
data.missTargets.push_back(m);
}
// Check if we read all expected misses
if (data.missTargets.size() < data.missCount) {
LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", (int)data.missTargets.size(),
"/", (int)data.missCount);
data.missCount = data.missTargets.size();
}
}
LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,

File diff suppressed because it is too large Load diff

View file

@ -64,9 +64,9 @@ bool CharacterPreview::initialize(pipeline::AssetManager* am) {
return false;
}
// Disable fog and shadows for the preview
// Configure lighting for character preview
// Use distant fog to avoid clipping, enable shadows for visual depth
charRenderer_->setFog(glm::vec3(0.05f, 0.05f, 0.1f), 9999.0f, 10000.0f);
charRenderer_->clearShadowMap();
camera_ = std::make_unique<Camera>();
// Portrait-style camera: WoW Z-up coordinate system
@ -819,8 +819,8 @@ void CharacterPreview::compositePass(VkCommandBuffer cmd, uint32_t frameIndex) {
// No fog in preview
ubo.fogColor = glm::vec4(0.05f, 0.05f, 0.1f, 0.0f);
ubo.fogParams = glm::vec4(9999.0f, 10000.0f, 0.0f, 0.0f);
// Shadows disabled
ubo.shadowParams = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f);
// Enable shadows for visual depth in preview (strength=0.5 for subtle effect)
ubo.shadowParams = glm::vec4(1.0f, 0.5f, 0.0f, 0.0f);
std::memcpy(previewUBOMapped_[fi], &ubo, sizeof(GPUPerFrameData));

View file

@ -23,6 +23,7 @@
#include "rendering/vk_utils.hpp"
#include "rendering/vk_frame_data.hpp"
#include "rendering/camera.hpp"
#include "rendering/frustum.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/blp_loader.hpp"
#include "core/logger.hpp"
@ -1647,7 +1648,13 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
inst.animationTime += deltaTime * 1000.0f;
if (seq.duration > 0 && inst.animationTime >= static_cast<float>(seq.duration)) {
if (inst.animationLoop) {
inst.animationTime = std::fmod(inst.animationTime, static_cast<float>(seq.duration));
// Subtract duration instead of fmod to preserve float precision
// fmod() loses precision with large animationTime values
inst.animationTime -= static_cast<float>(seq.duration);
// Clamp to [0, duration) to handle multiple loops in one frame
while (inst.animationTime >= static_cast<float>(seq.duration)) {
inst.animationTime -= static_cast<float>(seq.duration);
}
} else {
// One-shot animation finished: return to Stand (0) unless dead
if (inst.currentAnimationId != 1 /*Death*/) {
@ -1961,16 +1968,18 @@ void CharacterRenderer::prepareRender(uint32_t frameIndex) {
}
}
void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) {
void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) {
if (instances.empty() || !opaquePipeline_) {
return;
}
const float renderRadius = static_cast<float>(envSizeOrDefault("WOWEE_CHAR_RENDER_RADIUS", 130));
const float renderRadiusSq = renderRadius * renderRadius;
const float nearNoConeCullSq = 16.0f * 16.0f;
const float backfaceDotCull = -0.30f;
const float characterCullRadius = 2.0f; // Estimate character radius for frustum testing
const glm::vec3 camPos = camera.getPosition();
const glm::vec3 camForward = camera.getForward();
// Extract frustum planes for per-instance visibility testing
Frustum frustum;
frustum.extractFromMatrix(camera.getViewProjectionMatrix());
uint32_t frameIndex = vkCtx_->getCurrentFrame();
uint32_t frameSlot = frameIndex % 2u;
@ -2001,22 +2010,17 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
// Skip invisible instances (e.g., player in first-person mode)
if (!instance.visible) continue;
// Character instance culling: avoid drawing far-away / strongly behind-camera
// actors in dense city scenes.
// Character instance culling: test both distance and frustum visibility
if (!instance.hasOverrideModelMatrix) {
glm::vec3 toInst = instance.position - camPos;
float distSq = glm::dot(toInst, toInst);
// Distance cull: skip if beyond render radius
if (distSq > renderRadiusSq) continue;
if (distSq > nearNoConeCullSq) {
// Backface cull without sqrt: dot(toInst, camFwd) / |toInst| < threshold
// ⟺ dot < 0 || dot² < threshold² * distSq (when threshold < 0, dot must be negative)
float rawDot = glm::dot(toInst, camForward);
if (backfaceDotCull >= 0.0f) {
if (rawDot < 0.0f || rawDot * rawDot < backfaceDotCull * backfaceDotCull * distSq) continue;
} else {
if (rawDot < 0.0f && rawDot * rawDot > backfaceDotCull * backfaceDotCull * distSq) continue;
}
}
// Frustum cull: skip if outside view frustum
if (!frustum.intersectsSphere(instance.position, characterCullRadius)) continue;
}
if (!instance.cachedModel) continue;

View file

@ -1880,7 +1880,15 @@ static void resolveTrackTime(const pipeline::M2AnimationTrack& track,
// Global sequence: always use sub-array 0, wrap time at global duration
outSeqIdx = 0;
float dur = static_cast<float>(globalSeqDurations[track.globalSequence]);
outTime = (dur > 0.0f) ? std::fmod(time, dur) : 0.0f;
if (dur > 0.0f) {
// Use iterative subtraction instead of fmod() to preserve precision
outTime = time;
while (outTime >= dur) {
outTime -= dur;
}
} else {
outTime = 0.0f;
}
} else {
outSeqIdx = seqIdx;
outTime = time;
@ -1997,7 +2005,7 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
std::uniform_real_distribution<float> distDrift(-0.2f, 0.2f);
smokeEmitAccum += deltaTime;
float emitInterval = 1.0f / 16.0f; // 16 particles per second per emitter
float emitInterval = 1.0f / 48.0f; // 48 particles per second per emitter (was 32; increased for denser lava/magma steam effects in sparse areas)
if (smokeEmitAccum >= emitInterval &&
static_cast<int>(smokeParticles.size()) < MAX_SMOKE_PARTICLES) {
@ -2070,8 +2078,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
for (size_t idx : particleOnlyInstanceIndices_) {
if (idx >= instances.size()) continue;
auto& instance = instances[idx];
if (instance.animTime > 3333.0f) {
instance.animTime = std::fmod(instance.animTime, 3333.0f);
// Use iterative subtraction instead of fmod() to preserve precision
while (instance.animTime > 3333.0f) {
instance.animTime -= 3333.0f;
}
}
@ -2114,7 +2123,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
instance.animTime = 0.0f;
instance.variationTimer = 4000.0f + static_cast<float>(rand() % 6000);
} else {
instance.animTime = std::fmod(instance.animTime, std::max(1.0f, instance.animDuration));
// Use iterative subtraction instead of fmod() to preserve precision
float duration = std::max(1.0f, instance.animDuration);
while (instance.animTime >= duration) {
instance.animTime -= duration;
}
}
}
@ -3452,8 +3465,12 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
if ((em.flags & kParticleFlagTiled) && totalTiles > 1) {
float animSeconds = inst.animTime / 1000.0f;
uint32_t animFrame = static_cast<uint32_t>(std::floor(animSeconds * totalTiles)) % totalTiles;
tileIndex = std::fmod(p.tileIndex + static_cast<float>(animFrame),
static_cast<float>(totalTiles));
tileIndex = p.tileIndex + static_cast<float>(animFrame);
float tilesFloat = static_cast<float>(totalTiles);
// Wrap tile index within totalTiles range
while (tileIndex >= tilesFloat) {
tileIndex -= tilesFloat;
}
}
group.vertexData.push_back(tileIndex);
totalParticles++;

View file

@ -1,5 +1,6 @@
#include "rendering/quest_marker_renderer.hpp"
#include "rendering/camera.hpp"
#include "rendering/frustum.hpp"
#include "rendering/vk_context.hpp"
#include "rendering/vk_shader.hpp"
#include "rendering/vk_pipeline.hpp"
@ -374,6 +375,10 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
glm::mat4 view = camera.getViewMatrix();
glm::vec3 cameraPos = camera.getPosition();
// Extract frustum planes for visibility testing
Frustum frustum;
frustum.extractFromMatrix(camera.getViewProjectionMatrix());
// Get camera right and up vectors for billboarding
glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]);
glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]);
@ -398,6 +403,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
glm::vec3 toCamera = cameraPos - marker.position;
float distSq = glm::dot(toCamera, toCamera);
if (distSq > CULL_DIST_SQ) continue;
// Frustum cull quest markers (small sphere for icon)
constexpr float markerCullRadius = 0.5f;
if (!frustum.intersectsSphere(marker.position, markerCullRadius)) continue;
float dist = std::sqrt(distSq);
// Calculate fade alpha

View file

@ -2008,7 +2008,12 @@ void Renderer::updateCharacterAnimation() {
// Rider bob: sinusoidal motion synced to mount's run animation (only used in fallback positioning)
mountBob = 0.0f;
if (moving && haveMountState && curMountDur > 1.0f) {
float norm = std::fmod(curMountTime, curMountDur) / curMountDur;
// Wrap mount time preserving precision via subtraction instead of fmod
float wrappedTime = curMountTime;
while (wrappedTime >= curMountDur) {
wrappedTime -= curMountDur;
}
float norm = wrappedTime / curMountDur;
// One bounce per stride cycle
float bobSpeed = taxiFlight_ ? 2.0f : 1.0f;
mountBob = std::sin(norm * 2.0f * 3.14159f * bobSpeed) * 0.12f;
@ -2580,8 +2585,13 @@ bool Renderer::shouldTriggerFootstepEvent(uint32_t animationId, float animationT
return false;
}
float norm = std::fmod(animationTimeMs, animationDurationMs) / animationDurationMs;
if (norm < 0.0f) norm += 1.0f;
// Wrap animation time preserving precision via subtraction instead of fmod
float wrappedTime = animationTimeMs;
while (wrappedTime >= animationDurationMs) {
wrappedTime -= animationDurationMs;
}
if (wrappedTime < 0.0f) wrappedTime += animationDurationMs;
float norm = wrappedTime / animationDurationMs;
if (animationId != footstepLastAnimationId) {
footstepLastAnimationId = animationId;
@ -2875,8 +2885,13 @@ void Renderer::update(float deltaTime) {
float animTimeMs = 0.0f, animDurationMs = 0.0f;
if (characterRenderer->getAnimationState(mountInstanceId_, animId, animTimeMs, animDurationMs) &&
animDurationMs > 1.0f && cameraController->isMoving()) {
float norm = std::fmod(animTimeMs, animDurationMs) / animDurationMs;
if (norm < 0.0f) norm += 1.0f;
// Wrap animation time preserving precision via subtraction instead of fmod
float wrappedTime = animTimeMs;
while (wrappedTime >= animDurationMs) {
wrappedTime -= animDurationMs;
}
if (wrappedTime < 0.0f) wrappedTime += animDurationMs;
float norm = wrappedTime / animDurationMs;
if (animId != mountFootstepLastAnimId) {
mountFootstepLastAnimId = animId;

View file

@ -1952,40 +1952,27 @@ VkDescriptorSet WMORenderer::allocateMaterialSet() {
bool WMORenderer::isGroupVisible(const GroupResources& group, const glm::mat4& modelMatrix,
const Camera& camera) const {
// Simple frustum culling using bounding box
// Transform bounding box corners to world space
glm::vec3 corners[8] = {
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMin.y, group.boundingBoxMin.z),
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMin.y, group.boundingBoxMin.z),
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMax.y, group.boundingBoxMin.z),
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMax.y, group.boundingBoxMin.z),
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMin.y, group.boundingBoxMax.z),
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMin.y, group.boundingBoxMax.z),
glm::vec3(group.boundingBoxMin.x, group.boundingBoxMax.y, group.boundingBoxMax.z),
glm::vec3(group.boundingBoxMax.x, group.boundingBoxMax.y, group.boundingBoxMax.z)
};
// Proper frustum-AABB intersection test for accurate visibility culling
// Transform bounding box min/max to world space
glm::vec3 localMin = group.boundingBoxMin;
glm::vec3 localMax = group.boundingBoxMax;
// Transform corners to world space
for (int i = 0; i < 8; i++) {
glm::vec4 worldPos = modelMatrix * glm::vec4(corners[i], 1.0f);
corners[i] = glm::vec3(worldPos);
}
// Transform min and max to world space
glm::vec4 worldMinH = modelMatrix * glm::vec4(localMin, 1.0f);
glm::vec4 worldMaxH = modelMatrix * glm::vec4(localMax, 1.0f);
glm::vec3 worldMin = glm::vec3(worldMinH);
glm::vec3 worldMax = glm::vec3(worldMaxH);
// Simple check: if all corners are behind camera, cull
// (This is a very basic culling implementation - a full frustum test would be better)
glm::vec3 forward = camera.getForward();
glm::vec3 camPos = camera.getPosition();
// Ensure min/max are correct after transformation (handles non-uniform scaling)
glm::vec3 boundsMin = glm::min(worldMin, worldMax);
glm::vec3 boundsMax = glm::max(worldMin, worldMax);
int behindCount = 0;
for (int i = 0; i < 8; i++) {
glm::vec3 toCorner = corners[i] - camPos;
if (glm::dot(toCorner, forward) < 0.0f) {
behindCount++;
}
}
// Extract frustum planes from view-projection matrix
Frustum frustum;
frustum.extractFromMatrix(camera.getViewProjectionMatrix());
// If all corners are behind camera, cull
return behindCount < 8;
// Test if AABB intersects view frustum
return frustum.intersectsAABB(boundsMin, boundsMax);
}
int WMORenderer::findContainingGroup(const ModelData& model, const glm::vec3& localPos) const {

View file

@ -5317,6 +5317,10 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
// Player nameplates are always shown; NPC nameplates respect the V-key toggle
if (!isPlayer && !showNameplates_) continue;
// For corpses (dead units), only show a minimal grey nameplate if selected
bool isCorpse = (unit->getHealth() == 0);
if (isCorpse && !isTarget) continue;
// Prefer the renderer's actual instance position so the nameplate tracks the
// rendered model exactly (avoids drift from the parallel entity interpolator).
glm::vec3 renderPos;
@ -5349,9 +5353,13 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
float alpha = dist < (cullDist - 5.0f) ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f;
auto A = [&](int v) { return static_cast<int>(v * alpha); };
// Bar colour by hostility
// Bar colour by hostility (grey for corpses)
ImU32 barColor, bgColor;
if (unit->isHostile()) {
if (isCorpse) {
// Minimal grey bar for selected corpses (loot/skin targets)
barColor = IM_COL32(140, 140, 140, A(200));
bgColor = IM_COL32(70, 70, 70, A(160));
} else if (unit->isHostile()) {
barColor = IM_COL32(220, 60, 60, A(200));
bgColor = IM_COL32(100, 25, 25, A(160));
} else {
@ -5372,7 +5380,10 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
0.0f, 1.0f);
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f);
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
// For corpses, don't fill health bar (just show grey background)
if (!isCorpse) {
drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f);
}
drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f);
// Name + level label above health bar
@ -5384,10 +5395,14 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
// Fall back to level as placeholder while the name query is pending.
if (!unitName.empty())
snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str());
else if (level > 0)
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
else
snprintf(labelBuf, sizeof(labelBuf), "Player");
else {
// Name query may be pending; request it now to ensure it gets resolved
gameHandler.queryPlayerName(unit->getGuid());
if (level > 0)
snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level);
else
snprintf(labelBuf, sizeof(labelBuf), "Player");
}
} else if (level > 0) {
uint32_t playerLevel = gameHandler.getPlayerLevel();
// Show skull for units more than 10 levels above the player
@ -7741,6 +7756,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
// Show only the most recently sold item (LIFO).
const int i = 0;
const auto& entry = buyback[0];
// Proactively ensure buyback item info is loaded
gameHandler.ensureItemInfo(entry.item.itemId);
uint32_t sellPrice = entry.item.sellPrice;
if (sellPrice == 0) {
if (auto* info = gameHandler.getItemInfo(entry.item.itemId); info && info->valid) {
@ -7804,6 +7821,9 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
ImGui::TableNextRow();
ImGui::PushID(vi);
// Proactively ensure vendor item info is loaded
gameHandler.ensureItemInfo(item.itemId);
ImGui::TableSetColumnIndex(0);
auto* info = gameHandler.getItemInfo(item.itemId);
if (info && info->valid) {
@ -8561,6 +8581,7 @@ void GameScreen::renderSettingsWindow() {
pendingMinimapRotate = minimapRotate_;
pendingMinimapSquare = minimapSquare_;
pendingMinimapNpcDots = minimapNpcDots_;
pendingShowLatencyMeter = showLatencyMeter_;
if (renderer) {
if (auto* minimap = renderer->getMinimap()) {
minimap->setRotateWithCamera(minimapRotate_);
@ -8595,16 +8616,37 @@ void GameScreen::renderSettingsWindow() {
if (ImGui::BeginTabItem("Video")) {
ImGui::Spacing();
// Graphics Quality Presets
{
const char* presetLabels[] = { "Custom", "Low", "Medium", "High", "Ultra" };
int presetIdx = static_cast<int>(pendingGraphicsPreset);
if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) {
pendingGraphicsPreset = static_cast<GraphicsPreset>(presetIdx);
if (pendingGraphicsPreset != GraphicsPreset::CUSTOM) {
applyGraphicsPreset(pendingGraphicsPreset);
saveSettings();
}
}
ImGui::TextDisabled("Adjust these for custom settings");
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
if (ImGui::Checkbox("Fullscreen", &pendingFullscreen)) {
window->setFullscreen(pendingFullscreen);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
if (ImGui::Checkbox("VSync", &pendingVsync)) {
window->setVsync(pendingVsync);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
if (ImGui::Checkbox("Shadows", &pendingShadows)) {
if (renderer) renderer->setShadowsEnabled(pendingShadows);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
if (pendingShadows) {
@ -8612,6 +8654,7 @@ void GameScreen::renderSettingsWindow() {
ImGui::SetNextItemWidth(150.0f);
if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 500.0f, "%.0f")) {
if (renderer) renderer->setShadowDistance(pendingShadowDistance);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
}
@ -8643,6 +8686,7 @@ void GameScreen::renderSettingsWindow() {
VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT
};
if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]);
updateGraphicsPresetFromCurrentSettings();
saveSettings();
}
}
@ -8909,6 +8953,16 @@ void GameScreen::renderSettingsWindow() {
}
}
ImGui::Spacing();
ImGui::SeparatorText("Network");
ImGui::Spacing();
if (ImGui::Checkbox("Show Latency Meter", &pendingShowLatencyMeter)) {
showLatencyMeter_ = pendingShowLatencyMeter;
saveSettings();
}
ImGui::SameLine();
ImGui::TextDisabled("(ms indicator near minimap)");
ImGui::EndChild();
ImGui::EndTabItem();
}
@ -9426,6 +9480,175 @@ void GameScreen::renderSettingsWindow() {
ImGui::End();
}
void GameScreen::applyGraphicsPreset(GraphicsPreset preset) {
auto* renderer = core::Application::getInstance().getRenderer();
// Define preset values based on quality level
switch (preset) {
case GraphicsPreset::LOW: {
pendingShadows = false;
pendingShadowDistance = 100.0f;
pendingAntiAliasing = 0; // Off
pendingNormalMapping = false;
pendingPOM = false;
pendingGroundClutterDensity = 25;
if (renderer) {
renderer->setShadowsEnabled(false);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(false);
wr->setPOMEnabled(false);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(false);
cr->setPOMEnabled(false);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(0.25f);
}
}
break;
}
case GraphicsPreset::MEDIUM: {
pendingShadows = true;
pendingShadowDistance = 200.0f;
pendingAntiAliasing = 1; // 2x MSAA
pendingNormalMapping = true;
pendingNormalMapStrength = 0.6f;
pendingPOM = true;
pendingPOMQuality = 0; // Low
pendingGroundClutterDensity = 60;
if (renderer) {
renderer->setShadowsEnabled(true);
renderer->setShadowDistance(200.0f);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_2_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(true);
wr->setNormalMapStrength(0.6f);
wr->setPOMEnabled(true);
wr->setPOMQuality(0);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(true);
cr->setNormalMapStrength(0.6f);
cr->setPOMEnabled(true);
cr->setPOMQuality(0);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(0.60f);
}
}
break;
}
case GraphicsPreset::HIGH: {
pendingShadows = true;
pendingShadowDistance = 350.0f;
pendingAntiAliasing = 2; // 4x MSAA
pendingNormalMapping = true;
pendingNormalMapStrength = 0.8f;
pendingPOM = true;
pendingPOMQuality = 1; // Medium
pendingGroundClutterDensity = 100;
if (renderer) {
renderer->setShadowsEnabled(true);
renderer->setShadowDistance(350.0f);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_4_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(true);
wr->setNormalMapStrength(0.8f);
wr->setPOMEnabled(true);
wr->setPOMQuality(1);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(true);
cr->setNormalMapStrength(0.8f);
cr->setPOMEnabled(true);
cr->setPOMQuality(1);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(1.0f);
}
}
break;
}
case GraphicsPreset::ULTRA: {
pendingShadows = true;
pendingShadowDistance = 500.0f;
pendingAntiAliasing = 3; // 8x MSAA
pendingNormalMapping = true;
pendingNormalMapStrength = 1.2f;
pendingPOM = true;
pendingPOMQuality = 2; // High
pendingGroundClutterDensity = 150;
if (renderer) {
renderer->setShadowsEnabled(true);
renderer->setShadowDistance(500.0f);
renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT);
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(true);
wr->setNormalMapStrength(1.2f);
wr->setPOMEnabled(true);
wr->setPOMQuality(2);
}
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(true);
cr->setNormalMapStrength(1.2f);
cr->setPOMEnabled(true);
cr->setPOMQuality(2);
}
if (auto* tm = renderer->getTerrainManager()) {
tm->setGroundClutterDensityScale(1.5f);
}
}
break;
}
default:
break;
}
currentGraphicsPreset = preset;
pendingGraphicsPreset = preset;
}
void GameScreen::updateGraphicsPresetFromCurrentSettings() {
// Check if current settings match any preset, otherwise mark as CUSTOM
// This is a simplified check; could be enhanced with more detailed matching
auto matchesPreset = [this](GraphicsPreset preset) -> bool {
switch (preset) {
case GraphicsPreset::LOW:
return !pendingShadows && pendingAntiAliasing == 0 && !pendingNormalMapping && !pendingPOM &&
pendingGroundClutterDensity <= 30;
case GraphicsPreset::MEDIUM:
return pendingShadows && pendingShadowDistance >= 180 && pendingShadowDistance <= 220 &&
pendingAntiAliasing == 1 && pendingNormalMapping && pendingPOM &&
pendingGroundClutterDensity >= 50 && pendingGroundClutterDensity <= 70;
case GraphicsPreset::HIGH:
return pendingShadows && pendingShadowDistance >= 330 && pendingShadowDistance <= 370 &&
pendingAntiAliasing == 2 && pendingNormalMapping && pendingPOM &&
pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110;
case GraphicsPreset::ULTRA:
return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 &&
pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140;
default:
return false;
}
};
// Try to match a preset, otherwise mark as custom
if (matchesPreset(GraphicsPreset::LOW)) {
pendingGraphicsPreset = GraphicsPreset::LOW;
} else if (matchesPreset(GraphicsPreset::MEDIUM)) {
pendingGraphicsPreset = GraphicsPreset::MEDIUM;
} else if (matchesPreset(GraphicsPreset::HIGH)) {
pendingGraphicsPreset = GraphicsPreset::HIGH;
} else if (matchesPreset(GraphicsPreset::ULTRA)) {
pendingGraphicsPreset = GraphicsPreset::ULTRA;
} else {
pendingGraphicsPreset = GraphicsPreset::CUSTOM;
}
}
void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) {
const auto& statuses = gameHandler.getNpcQuestStatuses();
if (statuses.empty()) return;
@ -9868,9 +10091,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
break; // Show at most one queue slot indicator
}
// Latency indicator (shown when in world and last latency is known)
// Latency indicator (toggleable in Interface settings)
uint32_t latMs = gameHandler.getLatencyMs();
if (latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) {
if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) {
ImVec4 latColor;
if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.8f); // Green < 100ms
else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.8f); // Yellow < 250ms
@ -10128,6 +10351,7 @@ void GameScreen::saveSettings() {
out << "minimap_rotate=" << (pendingMinimapRotate ? 1 : 0) << "\n";
out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n";
out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n";
out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n";
out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n";
out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n";
out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n";
@ -10154,6 +10378,7 @@ void GameScreen::saveSettings() {
// Gameplay
out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n";
out << "graphics_preset=" << static_cast<int>(currentGraphicsPreset) << "\n";
out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n";
out << "shadows=" << (pendingShadows ? 1 : 0) << "\n";
out << "shadow_distance=" << pendingShadowDistance << "\n";
@ -10227,6 +10452,9 @@ void GameScreen::loadSettings() {
int v = std::stoi(val);
minimapNpcDots_ = (v != 0);
pendingMinimapNpcDots = minimapNpcDots_;
} else if (key == "show_latency_meter") {
showLatencyMeter_ = (std::stoi(val) != 0);
pendingShowLatencyMeter = showLatencyMeter_;
} else if (key == "separate_bags") {
pendingSeparateBags = (std::stoi(val) != 0);
inventoryScreen.setSeparateBags(pendingSeparateBags);
@ -10267,6 +10495,11 @@ void GameScreen::loadSettings() {
else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100);
// Gameplay
else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0);
else if (key == "graphics_preset") {
int presetVal = std::clamp(std::stoi(val), 0, 4);
currentGraphicsPreset = static_cast<GraphicsPreset>(presetVal);
pendingGraphicsPreset = currentGraphicsPreset;
}
else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150);
else if (key == "shadows") pendingShadows = (std::stoi(val) != 0);
else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f);

View file

@ -1357,7 +1357,7 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
}
}
// Weapon row
// Weapon row - positioned to the right of left column to avoid crowding main equipment
ImGui::Spacing();
ImGui::Separator();
@ -1366,6 +1366,9 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
game::EquipSlot::OFF_HAND,
game::EquipSlot::RANGED,
};
// Position weapons in center column area (after left column, 3D preview renders on top)
ImGui::SetCursorPosX(contentStartX + slotSize + 8.0f);
for (int i = 0; i < 3; i++) {
if (i > 0) ImGui::SameLine();
const auto& slot = inventory.getEquipSlot(weaponSlots[i]);

View file

@ -228,9 +228,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
if (bgIt != bgTextureCache_.end()) {
bgTex = bgIt->second;
} else {
// Try to load the background texture
// Only load the background if icon uploads aren't saturating this frame.
// Background is cosmetic; skip if we're already loading icons this frame.
std::string bgPath = bgFile;
// Normalize path separators
for (auto& c : bgPath) { if (c == '\\') c = '/'; }
bgPath += ".blp";
auto blpData = assetManager->readFile(bgPath);
@ -244,6 +244,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
}
}
}
// Cache even if null to avoid retrying every frame on missing files
bgTextureCache_[tabId] = bgTex;
}
@ -618,6 +619,17 @@ VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManag
auto cit = spellIconCache.find(iconId);
if (cit != spellIconCache.end()) return cit->second;
// Rate-limit texture uploads to avoid multi-hundred-ms stalls when switching
// to a tab whose icons are not yet cached (each upload is a blocking GPU op).
// Allow at most 4 new icon loads per frame; the rest show a blank icon and
// load on the next frame, spreading the cost across ~5 frames.
static int loadsThisFrame = 0;
static int lastImGuiFrame = -1;
int curFrame = ImGui::GetFrameCount();
if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; }
if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer, don't cache null
++loadsThisFrame;
auto pit = spellIconPaths.find(iconId);
if (pit == spellIconPaths.end()) {
spellIconCache[iconId] = VK_NULL_HANDLE;