Compare commits

...

71 commits

Author SHA1 Message Date
Kelsi
2f0809b570 fix: correct TBC aura entry minimum-size guard from 13 to 15 bytes
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
Each SMSG_INIT/SET_EXTRA_AURA_INFO entry is 15 bytes:
  uint8 slot(1) + uint32 spellId(4) + uint8 effectIndex(1)
  + uint8 flags(1) + uint32 durationMs(4) + uint32 maxDurMs(4) = 15

The previous guard of 13 would allow the loop to start reading a
partial entry, silently returning zeroes for durationMs/maxDurMs
when 13-14 bytes remained in the packet.
2026-03-11 03:49:54 -07:00
Kelsi
144c87a72f feat: show spell failure reason in chat from SMSG_SPELL_FAILURE
SMSG_SPELL_FAILURE carries a failReason byte (same enum as SMSG_CAST_RESULT)
that was previously ignored. Now parse castCount+spellId+failReason and
display the localized reason string for the player's interrupted casts
(e.g. 'Interrupted', 'Stunned', 'Can\'t do that while moving').
2026-03-11 03:42:41 -07:00
Kelsi
1446d4fddd fix: pass player power type to getSpellCastResultString for result 85
Result 85 is 'not enough power' — the message should say 'Not enough
rage', 'Not enough energy', 'Not enough runic power', etc. based on
the player's actual power type rather than always showing 'Not enough
mana'.
2026-03-11 03:41:49 -07:00
Kelsi
84a6ee4801 fix: surface absorb/resist in SMSG_ENVIRONMENTAL_DAMAGE_LOG (Classic/TBC)
The Classic/TBC variant handler was discarding the resisted field entirely
(only reading absorbed but discarding it). Now reads and shows both as
ABSORB/RESIST combat text, matching the WotLK SMSG_ENVIRONMENTALDAMAGELOG
fix from the previous commit.
2026-03-11 03:40:41 -07:00
Kelsi
00db93b7f2 fix: show RESIST (not MISS) for SMSG_PROCRESIST combat text
SMSG_PROCRESIST is sent when a proc effect is resisted. Show 'Resisted'
rather than 'Miss' to correctly communicate what happened to the player.
2026-03-11 03:38:39 -07:00
Kelsi
fb01361837 feat: show blocked amount and reduced damage on VICTIMSTATE_BLOCKS
When an attack is partially blocked, the server sends the remaining
damage in totalDamage and the blocked amount in data.blocked. Show
both: the damage taken and a 'Block N' entry. When block amount is
zero (full block with no damage), just show 'Block'.
2026-03-11 03:36:45 -07:00
Kelsi
d1c5e09127 fix: correct SMSG_PERIODICAURALOG packet format for WotLK 3.3.5a
WotLK adds an overkill(4) field between damage and school for aura type
3/89 (periodic damage), and adds absorbed(4)+isCrit(1) after overHeal
for aura type 8/124/45 (periodic heal). Without these fields the absorb
and resist values were reading out-of-alignment, producing garbage data.

Also surfaces the heal-absorbed amount as ABSORB combat text (e.g. when
a HoT tick is partially absorbed by Vampiric Embrace counter-healing).
2026-03-11 03:34:27 -07:00
Kelsi
f50cb04887 feat: surface absorb/resist from SMSG_ENVIRONMENTALDAMAGELOG
Environmental damage (drowning, lava, fire) also carries absorb/resist
fields. Show these as ABSORB/RESIST combat text so players see the full
picture of incoming environmental hits, consistent with spell/melee.
2026-03-11 03:31:33 -07:00
Kelsi
031448ec6d feat: show absorb/resist on periodic damage (DoT) ticks
SMSG_PERIODICAURALOG already parsed abs/res fields for type 3/89 but
discarded them. Surface these as ABSORB/RESIST combat text so players
see when DoT ticks are being partially absorbed (e.g. vs. PW:Shield).
2026-03-11 03:30:24 -07:00
Kelsi
dfc78572f5 feat: show melee absorb/resist in combat text from SMSG_ATTACKERSTATEUPDATE
Sub-damage entries carry absorbed/resisted per school. Accumulate these
and emit ABSORB/RESIST combat text alongside the hit damage when nonzero,
matching the behavior just added for SMSG_SPELLNONMELEEDAMAGELOG.
2026-03-11 03:29:37 -07:00
Kelsi
d2ae4d8215 feat: show partial absorb/resist amounts in spell combat text
handleSpellDamageLog now emits ABSORB/RESIST entries when data.absorbed
or data.resisted are nonzero, so players see 'Absorbed 123' alongside
damage numbers (e.g. vs. Power Word: Shield or Ice Barrier).
handleSpellHealLog does the same for heal absorbs (e.g. Vampiric Embrace
counter-absorbs). renderCombatText now formats amount when nonzero.
2026-03-11 03:28:19 -07:00
Kelsi
e902375763 feat: add ABSORB and RESIST combat text types for spell misses
Adds dedicated CombatTextEntry::Type entries for ABSORB (miss type 7)
and RESIST (miss type 8), replacing the generic MISS display. Updates
missTypes arrays in SMSG_SPELLLOGMISS and SMSG_SPELL_GO, and adds
light-blue "Absorb" and grey "Resist" rendering in the combat text overlay.
2026-03-11 03:23:01 -07:00
Kelsi
d5196abaec fix: show IMMUNE text for miss type 5 in SMSG_SPELL_GO and SMSG_SPELLLOGMISS
IMMUNE misses (spell miss type 5) were shown as generic MISS text in both
spell cast feedback handlers. Now consistently shows IMMUNE combat text
to match the fix already applied to SMSG_ATTACKERSTATEUPDATE.
2026-03-11 03:13:14 -07:00
Kelsi
35683920ff fix: handle EVADE/IMMUNE/DEFLECT victimStates in melee combat text
SMSG_ATTACKERSTATEUPDATE victimState values 5 (EVADE), 6 (IMMUNE), and
7 (DEFLECT) were previously falling through to the damage display path,
showing incorrect damage numbers instead of the proper miss/immune feedback.
Now correctly shows MISS for evade/deflect and IMMUNE for immune hits.
2026-03-11 03:09:39 -07:00
Kelsi
1646bef1c2 fix: add size guards to spell learn/remove handlers and implement SMSG_SPELLSTEALLOG
handleLearnedSpell, handleRemovedSpell, handleSupercededSpell, and
handleUnlearnSpells all lacked size checks before reading packet fields.
Also implements SMSG_SPELLSTEALLOG (previously silently consumed) with
proper player feedback showing the stolen spell name when the local player
is the caster, matching the same expansion-conditional packed-guid format
as SPELLDISPELLOG.
2026-03-11 03:03:44 -07:00
Kelsi
ae6c2aa056 fix: correct SMSG_SPELLDISPELLOG entry size from 8 to 5 bytes
Each dispelled spell entry is uint32(spellId) + uint8(isPositive) = 5 bytes,
not uint32 + uint32 = 8 bytes as the loop previously assumed.

The incorrect stride caused the second and subsequent entries to be read at
wrong offsets, potentially showing the wrong spell name for multi-dispels.
2026-03-11 02:57:05 -07:00
Kelsi
603e52e5b0 fix: add size check and skip WotLK guid suffix in handleCooldownEvent
SMSG_COOLDOWN_EVENT in WotLK appends an 8-byte unit guid after the spellId.
The handler was reading without a size check and not consuming the trailing
guid, which could misalign subsequent reads.
2026-03-11 02:51:58 -07:00
Kelsi
0a17683545 fix: correct WotLK packed guid format in SMSG_PROCRESIST and SMSG_TOTEM_CREATED
Both opcodes use packed GUIDs in WotLK 3.3.5a but were reading full uint64,
causing incorrect GUID parsing and potentially matching wrong player entities.

SMSG_PROCRESIST: caster + victim guids (packed in WotLK, uint64 in TBC/Classic)
SMSG_TOTEM_CREATED: totem guid (packed in WotLK, uint64 in TBC/Classic)
2026-03-11 02:50:53 -07:00
Kelsi
9cd7e7978d fix: correct SMSG_DISPEL_FAILED packet format and improve message
WotLK sends spellId(4) + packed_guid caster + packed_guid victim, while
TBC/Classic sends full uint64 caster + uint64 victim + spellId(4).
The previous handler assumed TBC format unconditionally, causing incorrect
reads in WotLK mode.

Also use the spell name cache to display "Purge failed to dispel." rather
than a raw "Dispel failed! (spell N)" message.
2026-03-11 02:49:37 -07:00
Kelsi
1f4880985b fix: correct SMSG_SPELLLOGMISS packet format for all expansions
All expansions send spellId(4) before the caster guid — the previous
handler was missing this field entirely, causing the caster guid read
to consume spellId bytes and corrupt all subsequent parsing.

Additionally, in WotLK mode, victim guids inside the per-miss loop are
packed guids (not full uint64), matching the caster guid format.

Also handle the REFLECT (missInfo=11) extra payload in WotLK: the server
appends reflectSpellId(4) + reflectResult(1) for reflected spells, which
previously caused the following loop entries to be mis-parsed.
2026-03-11 02:47:15 -07:00
Kelsi
d696da9227 fix: also use school mask for pre-cast melee range/facing check in castSpell()
Same DBC-driven physical school detection replaces the brittle hardcoded
warrior spell list in the pre-cast range check, so rogue, DK, paladin,
feral druid, and hunter melee abilities get correct range/facing enforcement.
2026-03-11 02:40:27 -07:00
Kelsi
21c55ad6b4 fix: detect melee abilities via spell school mask instead of hardcoded spell ID list
Replace the brittle warrior-only hardcoded spell ID list for melee ability
detection with a DBC-driven check: physical school mask (1) from spellNameCache_
covers warrior, rogue, DK, paladin, feral druid, and all other physical-school
instant abilities generically. Instant detection: spellId != currentCastSpellId.
2026-03-11 02:39:25 -07:00
Kelsi
643d48ee89 fix: show reason-specific messages for SMSG_TRANSFER_ABORTED
Replace generic 'Transfer aborted' message with WotLK TRANSFER_ABORT_*
reason codes: difficulty, expansion required, instance full, too many
instances, zone in combat, etc.
2026-03-11 02:36:55 -07:00
Kelsi
3082df2ac0 fix: use packed guids in SMSG_SPELLDAMAGESHIELD for WotLK and read absorbed field
WotLK 3.3.5a format uses packed guids (not full uint64) for victim and caster,
and adds an absorbed(4) field before schoolMask. Classic/TBC use full uint64 guids.
Previously the handler always read full uint64 guids, causing misparse on WotLK
(e.g. Thorns and Shield Spike damage shield combat text was garbled/wrong).
2026-03-11 02:27:57 -07:00
Kelsi
c4b2089d31 fix: handle OBS_MOD_POWER and PERIODIC_ENERGIZE aura types in SMSG_PERIODICAURALOG
Add PERIODIC_ENERGIZE (91) and OBS_MOD_POWER (46) handling so mana/energy/rage
restore ticks from common WotLK auras (Replenishment, Mana Spring Totem, Divine
Plea, etc.) appear as ENERGIZE in floating combat text. Also handle PERIODIC_MANA_LEECH
(98) to properly consume its 12 bytes instead of halting mid-event parse.
2026-03-11 02:25:42 -07:00
Kelsi
a67feb6d93 Fix handleInstanceDifficulty to handle variable-length packet formats
MSG_SET_DUNGEON_DIFFICULTY sends 4 or 12 bytes (difficulty + optional
isInGroup + savedBool) while SMSG_INSTANCE_DIFFICULTY sends 8 bytes
(difficulty + heroic).  The previous guard of < 8 caused the handler
to silently return for 4-byte variants, leaving instanceDifficulty_
unchanged.  Now reads as much as available and infers heroic flag from
the field count.
2026-03-11 02:08:38 -07:00
Kelsi
7c77c4a81e Fix per-frame particle descriptor set leak in M2 renderer
Pre-allocate one stable VkDescriptorSet per particle emitter at model
upload time (particleTexSets[]) instead of allocating a new set from
materialDescPool_ every frame for each particle group.  The per-frame
path exhausted the 8192-set pool in ~14 s at 60 fps with 10 active
particle emitters, causing GPU device-lost crashes.  The old path is
kept as an explicit fallback but should never be reached in practice.
2026-03-11 02:01:23 -07:00
Kelsi
570465f51a fix: handle MSG_SET_DUNGEON_DIFFICULTY and suppress SMSG_LEARNED_DANCE_MOVES warnings 2026-03-11 01:48:18 -07:00
Kelsi
f043077746 fix: free WMO and M2 material descriptor sets on group/model destroy to prevent pool exhaustion 2026-03-11 01:44:12 -07:00
Kelsi
4393798409 fix: parse WotLK areaId field in SMSG_INIT_WORLD_STATES to fix truncation warning 2026-03-11 01:40:33 -07:00
Kelsi
ce36171000 fix: prefer variationIndex=0 run animation and silence spurious compositeWithRegions warns
- character_renderer: playAnimation now prefers the primary variation
  (variationIndex==0) when multiple sequences share the same animation ID;
  this fixes hitching on human female run where a variation sequence was
  selected first before the base cycle
- character_renderer: move the compositeWithRegions size-mismatch warning
  inside the else branch so it only fires when sizes genuinely don't match,
  not for every successful 1:1 or scaled blit
2026-03-11 01:35:37 -07:00
Kelsi
f462db6bfa fix: terrain descriptor pool leak, minimap quest markers, and item comparison UI
- terrain_renderer: add FREE_DESCRIPTOR_SET_BIT flag and vkFreeDescriptorSets
  in destroyChunkGPU so material descriptor sets are returned to the pool;
  prevents GPU device lost from pool exhaustion near populated areas
- game_screen: fix projectToMinimap to use the exact inverse of the minimap
  shader transform so quest objective markers appear at the correct position
  and orientation regardless of camera bearing
- inventory_screen: fix item comparison tooltip to not compare equipped items
  against themselves (character screen); add item level diff line; show (=)
  indicator when stats are equal rather than bare value which looked identical
  to the item's own tooltip
2026-03-11 01:29:56 -07:00
Kelsi
568c566e1a fix: correct quest offer reward parser and trade slot trail size
- QuestOfferRewardParser: replace 4-variant heuristic with 0..16 byte
  prefix scan × fixed/variable arrays (34 candidates total).  AzerothCore
  WotLK 3.3.5a sends uint32 autoFinish + uint32 suggestedPlayers = 8 bytes
  before emoteCount; old uint8 read caused 3-byte misalignment, producing
  wrong item IDs and missing icons on quest reward windows.  Scoring now
  strongly favours the 8-byte prefix and exact byte consumption.
- Quest reward tooltip: delegate to InventoryScreen::renderItemTooltip()
  for full stats (armor, DPS, stats, bind type, etc.); show "Loading…"
  while item data is still fetching instead of showing nothing.
- SMSG_TRADE_STATUS_EXTENDED: fix SLOT_TRAIL 49→52 bytes.  AC 3.3.5a
  sends giftCreatorGuid(8) + 6 enchant slots(24) + randPropId(4) +
  suffixFactor(4) + durability(4) + maxDurability(4) + createPlayedTime(4)
  = 52 bytes after isWrapped; wrong skip misaligned all subsequent slots.
2026-03-11 01:00:08 -07:00
Kelsi
170ff1597c fix: prefetch item info for trade slot items in SMSG_TRADE_STATUS_EXTENDED
After parsing the peer's trade window state, query item info for all
occupied slots so item names display immediately rather than showing
'Item 12345' until the cache is populated on the next frame.
2026-03-11 00:46:11 -07:00
Kelsi
06facc0060 feat: implement trade window UI with item slots and gold offering
Previously trade only showed an accept/decline popup with no way to
actually offer items or gold. This commit adds the complete trade flow:

Packets:
- CMSG_SET_TRADE_ITEM (tradeSlot, bag, bagSlot) — add item to slot
- CMSG_CLEAR_TRADE_ITEM (tradeSlot) — remove item from slot
- CMSG_SET_TRADE_GOLD (uint64 copper) — set gold offered
- CMSG_UNACCEPT_TRADE — unaccept without cancelling
- SMSG_TRADE_STATUS_EXTENDED parser — updates trade slot/gold state

State:
- TradeSlot struct: itemId, displayId, stackCount, bag, bagSlot
- myTradeSlots_/peerTradeSlots_ arrays (6 slots each)
- myTradeGold_/peerTradeGold_ (copper)
- resetTradeState() helper clears all state on cancel/complete/close

UI (renderTradeWindow):
- Two-column layout: my offer | peer offer
- Each column shows 6 item slots with item names
- Double-click own slot to remove; right-click empty slot to open
  backpack picker popup
- Gold input field (copper, Enter to set)
- Accept Trade / Cancel buttons
- Window close button triggers cancel trade
2026-03-11 00:44:07 -07:00
Kelsi
7c5d688c00 fix: show area name in SMSG_ZONE_UNDER_ATTACK system message
Replace the raw area ID in the zone-under-attack message with the
resolved area name from the zone manager, matching retail WoW behavior
('Hillsbrad Foothills is under attack!' instead of 'area 267').
2026-03-11 00:36:40 -07:00
Kelsi
eaf827668a fix: parse SMSG_QUESTUPDATE_ADD_PVP_KILL and update quest log kill counts
Previously only displayed a chat message without updating the quest
tracker. Now parses the full packet (guid+questId+count+reqCount),
stores progress under entry-key 0 in killCounts, and shows a progress
message matching the format used for creature kills.

Handles both WotLK (4-field) and Classic (3-field, no reqCount) variants
with fallback to the existing killCounts or killObjectives for reqCount.
2026-03-11 00:34:23 -07:00
Kelsi
77ce54833a feat: add quest kill objective indicator (⚔) to unit nameplates
Yellow crossed-swords icon appears to the right of the unit name when
the creature's entry is an incomplete kill objective in a tracked quest.
Updated icon is suppressed once the kill count is satisfied.

Uses unit->getEntry() (Unit subclass method) rather than the base
Entity pointer, matching how questKillEntries keys are stored.
2026-03-11 00:29:35 -07:00
Kelsi
72a16a2427 fix: clear gossip/quest POI markers on map change (SMSG_NEW_WORLD)
Quest POI markers are map-specific. Clearing gossipPois_ on world entry
prevents stale markers from previous maps being displayed on the new map.
Quest POIs will be re-fetched as the quest log re-queries on the new map.
2026-03-11 00:24:35 -07:00
Kelsi
2ee0934653 fix: remove quest POI minimap markers when quest is abandoned
When abandonQuest() removes a quest from the log, also remove any
gossipPoi markers tagged with that questId (data field) so stale
objective markers don't linger on the minimap.
2026-03-11 00:21:33 -07:00
Kelsi
ef0e171da5 fix: deduplicate quest POI markers on repeated queries and fix LOG_DEBUG ordering
Remove existing POI markers for a quest before adding new ones (using the
data field as questId tag) so repeated CMSG_QUEST_POI_QUERY calls don't
accumulate duplicate markers. Also fix LOG_DEBUG to appear before the move.
2026-03-11 00:20:31 -07:00
Kelsi
6f5bdb2e91 feat: implement WotLK quest POI query to show objective locations on minimap
Send CMSG_QUEST_POI_QUERY alongside each CMSG_QUEST_QUERY (WotLK only,
gated by questLogStride == 5 and opcode availability). Parse the response
to extract POI region centroids and add them as GossipPoi markers so the
existing minimap rendering shows quest objective locations as cyan diamonds.

Each quest POI region is reduced to its centroid point; markers for the
current map only are shown. This gives players visual guidance for where
to go for active quests directly on the minimap.
2026-03-11 00:18:23 -07:00
Kelsi
12aa5e01b6 fix: correct game-object quest objective handling and item count fallback
- SMSG_QUESTUPDATE_ADD_KILL: use absolute value of npcOrGoId when looking
  up required count from killObjectives (negative values = game objects)
- applyPackedKillCountsFromFields: same fix — use abs(npcOrGoId) as map key
  so GO objective counts are stored with the correct entry key
- SMSG_QUESTUPDATE_ADD_ITEM: also match quests via itemObjectives when
  requiredItemCounts is not yet populated (race at quest accept time)
- Quest log and minimap sidebar: fall back to GO name cache for entries
  that return empty from getCachedCreatureName (interact/loot objectives)
2026-03-11 00:13:09 -07:00
Kelsi
e64b566d72 fix: correct TBC quest objective parsing and show creature names in quest log
SMSG_QUEST_QUERY_RESPONSE uses 40 fixed uint32 fields + 4 strings for both
Classic/Turtle and TBC, but the isClassicLayout flag was only set for stride-3
expansions (Classic/Turtle). TBC (stride 4) was incorrectly using the WotLK
55-field path, causing objective parsing to fail.

- Extend isClassicLayout to cover stride <= 4 (includes TBC)
- Refactor extractQuestQueryObjectives to try both layouts with fallback,
  matching the robustness of pickBestQuestQueryTexts
- Pre-fetch creature/GO/item name queries when quest objectives are parsed
  so names are ready before the player opens the quest log
- Quest log detail view: show creature names instead of raw entry IDs for
  kill objectives, and show required count (x/y) for item objectives
2026-03-11 00:05:05 -07:00
Kelsi
73439a4457 feat: restore quest kill counts from update fields using parsed objectives
Parse kill/item objectives from SMSG_QUEST_QUERY_RESPONSE binary data:
- extractQuestQueryObjectives() scans past the fixed integer header and
  variable-length strings to reach the 4 entity + 6 item objective entries
  (using known offsets: 40 fields for Classic/TBC, 55 for WotLK)
- Objectives stored in QuestLogEntry.killObjectives / itemObjectives arrays
- After storing, applyPackedKillCountsFromFields() reads 6-bit packed counts
  from update-field slots (stride+2 / stride+3) and populates killCounts
  using the parsed creature/GO entry IDs as keys

This means on login, quests that were in progress show correct kill count
progress (e.g. "2/5 Defias Bandits killed") without waiting for the first
server SMSG_QUESTUPDATE_ADD_KILL notification.
2026-03-10 23:52:18 -07:00
Kelsi
7e55d21cdd feat: read quest completion state from update fields on login and mid-session
resyncQuestLogFromServerSlots now reads the state field (slot*stride+1)
alongside the quest ID field, and marks quest.complete=true when the
server reports QuestStatus=1 (complete/ready-to-turn-in). Previously,
quests that were already complete before login would remain incorrectly
marked as incomplete until SMSG_QUESTUPDATE_COMPLETE fired, which only
happens when objectives are NEWLY completed during the session.

applyQuestStateFromFields() is a lightweight companion called from both
the CREATE and VALUES update handlers that applies the same state-field
check to already-tracked quests mid-session, catching the case where
the last objective completes via an update-field delta rather than the
dedicated quest-complete packet.

Works across all expansion strides (Classic stride=3, TBC stride=4,
WotLK stride=5); guarded against stride<2 (no state field available).
2026-03-10 23:33:38 -07:00
Kelsi
3a7ff71262 fix: use expansion-aware explored zone count to prevent fog-of-war corruption
Classic 1.12 and Turtle WoW have only 64 PLAYER_EXPLORED_ZONES uint32
fields (zone IDs pack into 2048 bits). TBC and WotLK use 128 (needed for
Outland/Northrend zone IDs up to bit 4095).

The hardcoded PLAYER_EXPLORED_ZONES_COUNT=128 caused extractExploredZoneFields
to read 64 extra fields beyond the actual zone block in Classic/Turtle —
consuming PLAYER_REST_STATE_EXPERIENCE, PLAYER_FIELD_COINAGE, and character-
points fields as zone flags. On the world map, this could mark zones as
explored based on random bit patterns in those unrelated fields.

Add `exploredZonesCount()` virtual method to PacketParsers (default=128,
Classic/Turtle override=64) and use it in extractExploredZoneFields to
limit reads to the correct block and zero-fill remaining slots.
2026-03-10 23:18:16 -07:00
Kelsi
1b55ebb387 fix: correct PLAYER_REST_STATE_EXPERIENCE wire indices for all expansions
The REST_STATE_EXPERIENCE field was erroneously set to the same index as
PLAYER_SKILL_INFO_START in all four expansion JSON files, causing the
rested XP tracker to read the first skill slot ID as the rested XP value.

Correct indices derived from layout: EXPLORED_ZONES_START + 128 zone
fields (or 64 for Classic) immediately precede PLAYER_FIELD_COINAGE, with
REST_STATE_EXPERIENCE in the one slot between them.

- WotLK: 636 → 1169  (1041 + 128 = 1169, before COINAGE=1170)
- Classic: 718 → 1175 (1111 + 64 = 1175, before COINAGE=1176)
- TBC: 928 → 1440    (1312 + 128 = 1440, before COINAGE=1441)
- Turtle: 718 → 1175 (same as Classic layout)
2026-03-10 23:14:18 -07:00
Kelsi
99de1fa3e5 feat: track UNIT_FIELD_STAT0-4 from server update fields for accurate character stats
Add UNIT_FIELD_STAT0-4 (STR/AGI/STA/INT/SPI) to the UF enum and wire up
per-expansion indices in all four expansion JSON files (WotLK: 84-88,
Classic/Turtle: 138-142, TBC: 159-163). Read the values in both CREATE
and VALUES player update handlers and store in playerStats_[5].

renderStatsPanel now uses the server-authoritative totals when available,
falling back to the previous 20+level estimate only if the server hasn't
sent UNIT_FIELD_STAT* yet. Item-query bonuses are still shown as (+N)
alongside the server total for both paths.
2026-03-10 23:08:15 -07:00
Kelsi
d95abfb607 feat: propagate OBJECT_FIELD_SCALE_X through creature and GO spawn pipeline
Reads OBJECT_FIELD_SCALE_X (field 4, cross-expansion) from CREATE_OBJECT
update fields and passes it through the full creature and game object spawn
chain: game_handler callbacks → pending spawn structs → async load results
→ createInstance() calls. This gives boss giants, gnomes, children, and
other non-unit-scale NPCs correct visual size, and ensures scaled GOs
(e.g. large treasure chests, oversized plants) render at the server-specified
scale rather than always at 1.0.

- Added OBJECT_FIELD_SCALE_X to UF enum and all expansion update_fields.json
- Added float scale to CreatureSpawnCallback and GameObjectSpawnCallback
- Propagated scale through PendingCreatureSpawn, PreparedCreatureModel,
  PendingGameObjectSpawn, PreparedGameObjectWMO
- Used scale in charRenderer/m2Renderer/wmoRenderer createInstance() calls
- Sanity-clamped raw float to [0.01, 100.0] range before use
2026-03-10 22:45:47 -07:00
Kelsi
b658743e94 feat: highlight required level in item tooltips when player is under-level
Display 'Requires Level N' in red when the player does not meet the
item's level requirement, and in normal colour when they do. Applies
to both equipped-item and bag-item tooltip paths.
2026-03-10 22:27:04 -07:00
Kelsi
00a939a733 fix: world map exploration fallback when server mask is unavailable
When the server has not sent SMSG_INIT_WORLD_STATES or the mask is
empty, fall back to locally-accumulated explored zones tracked by
player position. The local set is cleared when a real server mask
arrives so it doesn't persist stale data.
2026-03-10 22:27:00 -07:00
Kelsi
6928b8ddf6 feat: desaturate quest markers for trivial (gray) quests
Trivial/low-level quests now show gray '!' / '?' markers instead of
yellow, matching the in-game distinction between available and trivial
quests. Add grayscale parameter to QuestMarkerRenderer::setMarker and
the push-constant block; application sets grayscale=1.0 for trivial
markers and 0.0 for all others.
2026-03-10 22:26:56 -07:00
Kelsi
19eb7a1fb7 fix: animation stutter, resolution crash, memory cap, spell tooltip hints, GO collision
- Animation stutter: skip playAnimation(Run) for the local player in the
  server movement callback — the player renderer state machine already manages
  it; resetting animTime on every movement packet caused visible stutter
- Resolution crash: reorder swapchain recreation so old swapchain is only
  destroyed after confirming the new build succeeded; add null-swapchain
  guard in beginFrame to survive the retry window
- Memory cap: reduce cache budget from 80% uncapped to 50% hard-capped at
  16 GB to prevent excessive RAM use on high-memory systems
- Spell tooltip: suppress "Drag to action bar / Double-click to cast" hints
  when the tooltip is shown from the action bar (showUsageHints=false)
- M2 collision: add watermelon/melon/squash/gourd to foliage (no-collision);
  exclude chair/bench/stool/seat/throne from smallSolidProp so invisible chair
  bounding boxes no longer trap the player
2026-03-10 22:26:50 -07:00
Kelsi
8f2974b17c feat: add standalone LFG group-found popup (renderLfgProposalPopup)
Shows a centered modal when LfgState::Proposal is active regardless of
whether the Dungeon Finder window is open, matching WoW behaviour where
the accept/decline prompt always appears over the game world.
Mirrors the BG invite popup pattern; buttons call lfgAcceptProposal().
2026-03-10 21:40:21 -07:00
Kelsi
7c8bda0907 fix: suppress wchar_t '>= 0' tautological comparison warning on arm64
On Windows arm64, wchar_t is unsigned so 'wc >= 0' is always true
and GCC/Clang emit -Wtype-limits. Drop the redundant lower bound
check — only the upper bound 'wc <= 0x7f' is needed.
2026-03-10 21:34:54 -07:00
Kelsi
b4469b1577 fix: correct buff bar and quest tracker vertical positions
- Buff bar was at Y=140 which overlaps the minimap (Y=10 to Y=210);
  moved to Y=215 (just below minimap bottom edge) with 8 icons per row
- Quest tracker moved from Y=200 (inside minimap area) to Y=320 to
  leave space for up to 3 rows of buffs between minimap and tracker
- Both are right-anchored and no longer conflict with the minimap or
  each other in typical usage (up to ~20 active auras)
2026-03-10 21:32:58 -07:00
Kelsi
a7474b96cf fix: move buff bar to top-right, split buffs/debuffs, raise aura cap to 40
- Relocates buff bar from top-left Y=145 (overlapping party frames) to
  top-right (screenW - barW - 10, 140) where it doesn't conflict with
  party/raid frames anchored on the left side
- Increases max shown auras from 16 to 40 (WotLK supports 48 slots)
- Two-pass rendering: buffs shown first, debuffs below with a spacing gap
  between them; both still use green/red borders for visual distinction
- Widens row to 12 icons for better horizontal use of screen space
2026-03-10 21:29:47 -07:00
Kelsi
5fbeb7938c feat: right-click context menu for party member frames
Right-clicking a party member name in the 5-man party frame opens
a context menu with: Target, Set Focus, Whisper, Trade, Inspect.
- Whisper switches chat type to WHISPER and pre-fills the target name
- Trade calls GameHandler::initiateTrade(guid)
- Inspect sets target then calls GameHandler::inspectTarget()
- Uses BeginPopupContextItem tied to the Selectable widget
2026-03-10 21:27:26 -07:00
Kelsi
bc18fb7c3e feat: leader crown and LFG role indicators in party/raid frames
- Party frames: gold star prefix and gold name color for group leader;
  LFG role badges [T]/[H]/[D] shown inline after member name
- Raid frames: leader name rendered in gold with a corner star marker;
  role letter (T/H/D) drawn in bottom-right corner of each compact cell;
  uses partyData.leaderGuid already present in the function scope
- Minimap party dots already use gold for leader (unchanged)
2026-03-10 21:24:40 -07:00
Kelsi
4a445081d8 feat: latency indicator, BG queue status, and ToT improvements
- Add latency indicator below minimap (color-coded: green/yellow/orange/red)
  using the lastLatency value measured via CMSG_PING/SMSG_PONG
- Add BG queue status indicator below minimap when in WAIT_QUEUE
  (abbreviated name: AV/WSG/AB/EotS etc.)
- Target-of-Target frame: add level display and click-to-target support
- Expose getLatencyMs() accessor on GameHandler
2026-03-10 21:19:42 -07:00
Kelsi
8ab83987f1 feat: focus target frame with health/power bars and cast bar
Add a compact focus target frame on the right side of the screen
when the player has a focus target set via /focus.

- Shows [Focus] label, name (colored by hostility/level diff), level
- HP bar with green→yellow→red coloring; power bar with type colors
- Cast bar showing spell name and remaining time when focus is casting
- Clicking the frame targets the focus entity
- Clears automatically when focus is lost (/clearfocus)
2026-03-10 21:15:24 -07:00
Kelsi
a7a559cdcc feat: battleground invitation popup with countdown timer
Replace the text-only "/join to enter" message with an interactive
popup that shows the BG name, a live countdown progress bar, and
Enter/Leave Queue buttons.

- Parse STATUS_WAIT_JOIN timeout from SMSG_BATTLEFIELD_STATUS
- Store inviteReceivedTime (steady_clock) on the queue slot
- BgQueueSlot moved to public section so UI can read invite details
- Add declineBattlefield() that sends CMSG_BATTLEFIELD_PORT(action=0)
- acceptBattlefield() optimistically sets statusId=3 to dismiss popup
- renderBgInvitePopup: colored countdown bar (green→yellow→red),
  named BG (Alterac Valley, Warsong Gulch, etc.), auto-dismisses on expiry
2026-03-10 21:12:28 -07:00
Kelsi
4986308581 feat: rich item tooltips in vendor and loot-roll windows
- Vendor window: replace manual stat-only tooltip with full renderItemTooltip
  (now shows bind type, slot, weapon stats, armor, extra stats, spell effects,
  flavor text, and sell price — consistent with inventory)
- Loot-roll popup: add item icon and hover tooltip via renderItemTooltip
- Loot-roll: pre-fetch item info via queryItemInfo when roll prompt appears
2026-03-10 20:59:02 -07:00
Kelsi
6275a45ec0 feat: achievement name in toast, parse earned achievements, loot item tooltips
- Parse SMSG_ALL_ACHIEVEMENT_DATA on login to populate earnedAchievements_ set
- Pass achievement name through callback so toast shows name instead of ID
- Add renderItemTooltip(ItemQueryResponseData) overload for loot/non-inventory contexts
- Loot window now shows full item tooltip on hover (stats, sell price, bind type, etc.)
2026-03-10 20:53:21 -07:00
Kelsi
984decd664 fix: pre-fetch quest reward item info when quest details packet arrives
When SMSG_QUESTGIVER_QUEST_DETAILS is received (quest accept dialog),
immediately query item info for all rewardChoiceItems and rewardItems.
This ensures item names and icons are cached before the offer-reward
dialog opens on turn-in, eliminating the "Item {id}" placeholder that
appeared when the dialog opened before item queries completed.
2026-03-10 20:39:49 -07:00
Kelsi
b87b6cee0f fix: add ParentAreaNum/MapID to AreaTable DBC layout for world map exploration
AreaTable["ParentAreaNum"] was missing from all expansion DBC layouts,
causing getUInt32(i, 0xFFFFFFFF) to return 0 for every area's parent.
This made childBitsByParent keyed by 0 instead of the actual parent area
IDs, so sub-zone explore bits were never associated with their parent zones
on the world map.

Result: newly explored sub-zones (e.g. Stormwind Keep) would not reveal
their parent continent zones (Stormwind City) because the zone's exploreBits
only included the direct zone bit, not sub-zone bits.

Fix: add "MapID": 1, "ParentAreaNum": 2 to all expansion AreaTable layouts.
2026-03-10 20:35:42 -07:00
Kelsi
2b9f216dae fix: rest state detection and minimap north-up orientation
- WotLK opcode 0x21E is aliased to both SMSG_SET_REST_START and
  SMSG_QUEST_FORCE_REMOVE. In WotLK, treat as SET_REST_START (non-zero
  = entering rest area, zero = leaving); Classic/TBC treat as quest removal.
- PLAYER_BYTES_2 rest state byte: change from `& 0x01` to `!= 0` to also
  detect REST_TYPE_IN_CITY (value 2), not just REST_TYPE_IN_TAVERN (1).
- Minimap arrow: server orientation (π/2=North) needed conversion to
  minimap arrow space (0=North). Subtract π/2 in both render paths so
  arrow points North when player faces North.
2026-03-10 20:29:55 -07:00
Kelsi
28550dbc99 fix: reduce GO fallback hit radius to prevent invisible chair click-lock
Fallback sphere for GameObjects without a loaded renderer instance was 2.5f,
causing invisible/unloaded chairs in Goldshire Inn to be accidentally targeted
during camera right-drag. This sent CMSG_GAMEOBJ_USE which set server stand
state to SIT, trapping the player until a stand-up packet was sent.

Reduce fallback radius to 1.2f and height offset to 1.0f so only deliberate
close-range direct clicks register on unloaded GO geometry.
2026-03-10 19:59:23 -07:00
Kelsi
564a286282 fix: stand-up-on-move and nameplate position tracking
Camera controller / sitting:
- Any movement key (WASD/QE/Space) pressed while sitting now clears the
  sitting flag immediately, matching WoW's sit-to-stand-on-move behaviour
- Added StandUpCallback: when the player stands up via local input the
  callback fires setStandState(0) → CMSG_STAND_STATE_CHANGE(STAND) so
  the server releases the sit lock and restores normal movement
- Fixes character getting stuck in sit state after accidentally
  right-clicking a chair GO in Goldshire Inn (or similar)

Nameplates:
- Use getRenderPositionForGuid() (renderer visual position) as primary
  source for nameplate anchor, falling back to entity X/Y/Z only when
  no render instance exists yet; keeps health bars in sync with the
  rendered model instead of the parallel entity interpolator
2026-03-10 19:49:33 -07:00
Kelsi
48d15fc653 fix: quest markers, level-up effect, and emote loop
- renderer: construct QuestMarkerRenderer via make_unique (was never
  instantiated, causing getQuestMarkerRenderer() to always return null
  and all quest-marker updates to be silently skipped)
- m2_renderer: add "levelup" to effectByName so LevelUp.m2 is treated
  as a spell effect (additive blend, no collision, particle-dominated)
- renderer: auto-cancel non-looping emote animations when they reach
  end-of-sequence, transitioning player back to IDLE state
2026-03-10 19:41:01 -07:00
43 changed files with 2589 additions and 401 deletions

View file

@ -30,7 +30,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"AreaTable": { "ID": 0, "ExploreFlag": 3 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,
"HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7,

View file

@ -1,5 +1,6 @@
{
"OBJECT_FIELD_ENTRY": 3,
"OBJECT_FIELD_SCALE_X": 4,
"UNIT_FIELD_TARGET_LO": 16,
"UNIT_FIELD_TARGET_HI": 17,
"UNIT_FIELD_BYTES_0": 36,
@ -16,13 +17,18 @@
"UNIT_NPC_FLAGS": 147,
"UNIT_DYNAMIC_FLAGS": 143,
"UNIT_FIELD_RESISTANCES": 154,
"UNIT_FIELD_STAT0": 138,
"UNIT_FIELD_STAT1": 139,
"UNIT_FIELD_STAT2": 140,
"UNIT_FIELD_STAT3": 141,
"UNIT_FIELD_STAT4": 142,
"UNIT_END": 188,
"PLAYER_FLAGS": 190,
"PLAYER_BYTES": 191,
"PLAYER_BYTES_2": 192,
"PLAYER_XP": 716,
"PLAYER_NEXT_LEVEL_XP": 717,
"PLAYER_REST_STATE_EXPERIENCE": 718,
"PLAYER_REST_STATE_EXPERIENCE": 1175,
"PLAYER_FIELD_COINAGE": 1176,
"PLAYER_QUEST_LOG_START": 198,
"PLAYER_FIELD_INV_SLOT_HEAD": 486,

View file

@ -30,7 +30,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"AreaTable": { "ID": 0, "ExploreFlag": 3 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,
"HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7,

View file

@ -1,5 +1,6 @@
{
"OBJECT_FIELD_ENTRY": 3,
"OBJECT_FIELD_SCALE_X": 4,
"UNIT_FIELD_TARGET_LO": 16,
"UNIT_FIELD_TARGET_HI": 17,
"UNIT_FIELD_BYTES_0": 36,
@ -16,13 +17,18 @@
"UNIT_NPC_FLAGS": 168,
"UNIT_DYNAMIC_FLAGS": 164,
"UNIT_FIELD_RESISTANCES": 185,
"UNIT_FIELD_STAT0": 159,
"UNIT_FIELD_STAT1": 160,
"UNIT_FIELD_STAT2": 161,
"UNIT_FIELD_STAT3": 162,
"UNIT_FIELD_STAT4": 163,
"UNIT_END": 234,
"PLAYER_FLAGS": 236,
"PLAYER_BYTES": 237,
"PLAYER_BYTES_2": 238,
"PLAYER_XP": 926,
"PLAYER_NEXT_LEVEL_XP": 927,
"PLAYER_REST_STATE_EXPERIENCE": 928,
"PLAYER_REST_STATE_EXPERIENCE": 1440,
"PLAYER_FIELD_COINAGE": 1441,
"PLAYER_QUEST_LOG_START": 244,
"PLAYER_FIELD_INV_SLOT_HEAD": 650,

View file

@ -30,7 +30,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"AreaTable": { "ID": 0, "ExploreFlag": 3 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,
"HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7,

View file

@ -1,5 +1,6 @@
{
"OBJECT_FIELD_ENTRY": 3,
"OBJECT_FIELD_SCALE_X": 4,
"UNIT_FIELD_TARGET_LO": 16,
"UNIT_FIELD_TARGET_HI": 17,
"UNIT_FIELD_BYTES_0": 36,
@ -16,13 +17,18 @@
"UNIT_NPC_FLAGS": 147,
"UNIT_DYNAMIC_FLAGS": 143,
"UNIT_FIELD_RESISTANCES": 154,
"UNIT_FIELD_STAT0": 138,
"UNIT_FIELD_STAT1": 139,
"UNIT_FIELD_STAT2": 140,
"UNIT_FIELD_STAT3": 141,
"UNIT_FIELD_STAT4": 142,
"UNIT_END": 188,
"PLAYER_FLAGS": 190,
"PLAYER_BYTES": 191,
"PLAYER_BYTES_2": 192,
"PLAYER_XP": 716,
"PLAYER_NEXT_LEVEL_XP": 717,
"PLAYER_REST_STATE_EXPERIENCE": 718,
"PLAYER_REST_STATE_EXPERIENCE": 1175,
"PLAYER_FIELD_COINAGE": 1176,
"PLAYER_QUEST_LOG_START": 198,
"PLAYER_FIELD_INV_SLOT_HEAD": 486,

View file

@ -31,7 +31,7 @@
"ReputationBase2": 12, "ReputationBase3": 13
},
"Achievement": { "ID": 0, "Title": 4, "Description": 21 },
"AreaTable": { "ID": 0, "ExploreFlag": 3 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,
"HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7,

View file

@ -1,5 +1,6 @@
{
"OBJECT_FIELD_ENTRY": 3,
"OBJECT_FIELD_SCALE_X": 4,
"UNIT_FIELD_TARGET_LO": 6,
"UNIT_FIELD_TARGET_HI": 7,
"UNIT_FIELD_BYTES_0": 23,
@ -16,13 +17,18 @@
"UNIT_NPC_FLAGS": 82,
"UNIT_DYNAMIC_FLAGS": 147,
"UNIT_FIELD_RESISTANCES": 99,
"UNIT_FIELD_STAT0": 84,
"UNIT_FIELD_STAT1": 85,
"UNIT_FIELD_STAT2": 86,
"UNIT_FIELD_STAT3": 87,
"UNIT_FIELD_STAT4": 88,
"UNIT_END": 148,
"PLAYER_FLAGS": 150,
"PLAYER_BYTES": 153,
"PLAYER_BYTES_2": 154,
"PLAYER_XP": 634,
"PLAYER_NEXT_LEVEL_XP": 635,
"PLAYER_REST_STATE_EXPERIENCE": 636,
"PLAYER_REST_STATE_EXPERIENCE": 1169,
"PLAYER_FIELD_COINAGE": 1170,
"PLAYER_QUEST_LOG_START": 158,
"PLAYER_FIELD_INV_SLOT_HEAD": 324,

View file

@ -5,6 +5,7 @@ layout(set = 1, binding = 0) uniform sampler2D markerTexture;
layout(push_constant) uniform Push {
mat4 model;
float alpha;
float grayscale; // 0 = full colour, 1 = fully desaturated (trivial quests)
} push;
layout(location = 0) in vec2 TexCoord;
@ -14,5 +15,7 @@ layout(location = 0) out vec4 outColor;
void main() {
vec4 texColor = texture(markerTexture, TexCoord);
if (texColor.a < 0.1) discard;
outColor = vec4(texColor.rgb, texColor.a * push.alpha);
float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114));
vec3 rgb = mix(texColor.rgb, vec3(lum), push.grayscale);
outColor = vec4(rgb, texColor.a * push.alpha);
}

Binary file not shown.

View file

@ -98,7 +98,7 @@ private:
void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z);
void buildFactionHostilityMap(uint8_t playerRace);
pipeline::M2Model loadCreatureM2Sync(const std::string& m2Path);
void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation);
void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f);
void despawnOnlineCreature(uint64_t guid);
bool tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId);
void spawnOnlinePlayer(uint64_t guid,
@ -113,7 +113,7 @@ private:
void despawnOnlinePlayer(uint64_t guid);
void buildCreatureDisplayLookups();
std::string getModelPathForDisplayId(uint32_t displayId) const;
void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation);
void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f);
void despawnOnlineGameObject(uint64_t guid);
void buildGameObjectDisplayLookups();
std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const;
@ -214,6 +214,7 @@ private:
uint32_t displayId;
uint32_t modelId;
float x, y, z, orientation;
float scale = 1.0f;
std::shared_ptr<pipeline::M2Model> model; // parsed on background thread
std::unordered_map<std::string, pipeline::BLPImage> predecodedTextures; // decoded on bg thread
bool valid = false;
@ -300,6 +301,7 @@ private:
uint64_t guid;
uint32_t displayId;
float x, y, z, orientation;
float scale = 1.0f;
};
std::deque<PendingCreatureSpawn> pendingCreatureSpawns_;
static constexpr int MAX_SPAWNS_PER_FRAME = 3;
@ -393,6 +395,7 @@ private:
uint32_t entry;
uint32_t displayId;
float x, y, z, orientation;
float scale = 1.0f;
};
std::vector<PendingGameObjectSpawn> pendingGameObjectSpawns_;
void processGameObjectSpawnQueue();
@ -403,6 +406,7 @@ private:
uint32_t entry;
uint32_t displayId;
float x, y, z, orientation;
float scale = 1.0f;
std::shared_ptr<pipeline::WMOModel> wmoModel;
std::unordered_map<std::string, pipeline::BLPImage> predecodedTextures; // decoded on bg thread
bool valid = false;

View file

@ -295,6 +295,13 @@ public:
// Server-authoritative armor (UNIT_FIELD_RESISTANCES[0])
int32_t getArmorRating() const { return playerArmorRating_; }
// Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI).
// Returns -1 if the server hasn't sent the value yet.
int32_t getPlayerStat(int idx) const {
if (idx < 0 || idx > 4) return -1;
return playerStats_[idx];
}
// Inventory
Inventory& getInventory() { return inventory; }
const Inventory& getInventory() const { return inventory; }
@ -340,9 +347,24 @@ public:
// Random roll
void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100);
// Battleground queue slot (public so UI can read invite details)
struct BgQueueSlot {
uint32_t queueSlot = 0;
uint32_t bgTypeId = 0;
uint8_t arenaType = 0;
uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress
uint32_t inviteTimeout = 80;
std::chrono::steady_clock::time_point inviteReceivedTime{};
};
// Battleground
bool hasPendingBgInvite() const;
void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
const std::array<BgQueueSlot, 3>& getBgQueues() const { return bgQueues_; }
// Network latency (milliseconds, updated each PONG response)
uint32_t getLatencyMs() const { return lastLatency; }
// Logout commands
void requestLogout();
@ -720,8 +742,8 @@ public:
void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); }
// Creature spawn callback (online mode - triggered when creature enters view)
// Parameters: guid, displayId, x, y, z (canonical), orientation
using CreatureSpawnCallback = std::function<void(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation)>;
// Parameters: guid, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X)
using CreatureSpawnCallback = std::function<void(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale)>;
void setCreatureSpawnCallback(CreatureSpawnCallback cb) { creatureSpawnCallback_ = std::move(cb); }
// Creature despawn callback (online mode - triggered when creature leaves view)
@ -751,8 +773,8 @@ public:
void setPlayerEquipmentCallback(PlayerEquipmentCallback cb) { playerEquipmentCallback_ = std::move(cb); }
// GameObject spawn callback (online mode - triggered when gameobject enters view)
// Parameters: guid, entry, displayId, x, y, z (canonical), orientation
using GameObjectSpawnCallback = std::function<void(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation)>;
// Parameters: guid, entry, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X)
using GameObjectSpawnCallback = std::function<void(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale)>;
void setGameObjectSpawnCallback(GameObjectSpawnCallback cb) { gameObjectSpawnCallback_ = std::move(cb); }
// GameObject move callback (online mode - triggered when gameobject position updates)
@ -911,13 +933,38 @@ public:
enum class TradeStatus : uint8_t {
None = 0, PendingIncoming, Open, Accepted, Complete
};
static constexpr int TRADE_SLOT_COUNT = 6; // WoW has 6 normal trade slots + slot 6 for non-trade item
struct TradeSlot {
uint32_t itemId = 0;
uint32_t displayId = 0;
uint32_t stackCount = 0;
uint64_t itemGuid = 0;
uint8_t bag = 0xFF; // 0xFF = not set
uint8_t bagSlot = 0xFF;
bool occupied = false;
};
TradeStatus getTradeStatus() const { return tradeStatus_; }
bool hasPendingTradeRequest() const { return tradeStatus_ == TradeStatus::PendingIncoming; }
bool isTradeOpen() const { return tradeStatus_ == TradeStatus::Open || tradeStatus_ == TradeStatus::Accepted; }
const std::string& getTradePeerName() const { return tradePeerName_; }
// My trade slots (what I'm offering)
const std::array<TradeSlot, TRADE_SLOT_COUNT>& getMyTradeSlots() const { return myTradeSlots_; }
// Peer's trade slots (what they're offering)
const std::array<TradeSlot, TRADE_SLOT_COUNT>& getPeerTradeSlots() const { return peerTradeSlots_; }
uint64_t getMyTradeGold() const { return myTradeGold_; }
uint64_t getPeerTradeGold() const { return peerTradeGold_; }
void acceptTradeRequest(); // respond to incoming SMSG_TRADE_STATUS(1) with CMSG_BEGIN_TRADE
void declineTradeRequest(); // respond with CMSG_CANCEL_TRADE
void acceptTrade(); // lock in offer: CMSG_ACCEPT_TRADE
void cancelTrade(); // CMSG_CANCEL_TRADE
void setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot);
void clearTradeItem(uint8_t tradeSlot);
void setTradeGold(uint64_t copper);
// ---- Duel ----
bool hasPendingDuelRequest() const { return pendingDuelRequest_; }
@ -1047,12 +1094,27 @@ public:
std::string title;
std::string objectives;
bool complete = false;
// Objective kill counts: objectiveIndex -> (current, required)
// Objective kill counts: npcOrGoEntry -> (current, required)
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> killCounts;
// Quest item progress: itemId -> current count
std::unordered_map<uint32_t, uint32_t> itemCounts;
// Server-authoritative quest item requirements from REQUEST_ITEMS
std::unordered_map<uint32_t, uint32_t> requiredItemCounts;
// Structured kill objectives parsed from SMSG_QUEST_QUERY_RESPONSE.
// Index 0-3 map to the server's objective slot order (packed into update fields).
// npcOrGoId != 0 => entity objective (kill NPC or interact with GO).
struct KillObjective {
int32_t npcOrGoId = 0; // negative = game-object entry
uint32_t required = 0;
};
std::array<KillObjective, 4> killObjectives{}; // zeroed by default
// Required item objectives parsed from SMSG_QUEST_QUERY_RESPONSE.
// itemId != 0 => collect items of that type.
struct ItemObjective {
uint32_t itemId = 0;
uint32_t required = 0;
};
std::array<ItemObjective, 6> itemObjectives{}; // zeroed by default
};
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
void abandonQuest(uint32_t questId);
@ -1134,8 +1196,9 @@ public:
void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); }
// Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received
using AchievementEarnedCallback = std::function<void(uint32_t achievementId)>;
using AchievementEarnedCallback = std::function<void(uint32_t achievementId, const std::string& name)>;
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
@ -1580,6 +1643,7 @@ private:
void handleGossipMessage(network::Packet& packet);
void handleQuestgiverQuestList(network::Packet& packet);
void handleGossipComplete(network::Packet& packet);
void handleQuestPoiQueryResponse(network::Packet& packet);
void handleQuestDetails(network::Packet& packet);
void handleQuestRequestItems(network::Packet& packet);
void handleQuestOfferReward(network::Packet& packet);
@ -1614,6 +1678,8 @@ private:
void handleQuestConfirmAccept(network::Packet& packet);
void handleSummonRequest(network::Packet& packet);
void handleTradeStatus(network::Packet& packet);
void handleTradeStatusExtended(network::Packet& packet);
void resetTradeState();
void handleDuelRequested(network::Packet& packet);
void handleDuelComplete(network::Packet& packet);
void handleDuelWinner(network::Packet& packet);
@ -1969,12 +2035,6 @@ private:
std::unordered_set<uint32_t> petAutocastSpells_; // spells with autocast on
// ---- Battleground queue state ----
struct BgQueueSlot {
uint32_t queueSlot = 0;
uint32_t bgTypeId = 0;
uint8_t arenaType = 0;
uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress
};
std::array<BgQueueSlot, 3> bgQueues_{};
// Instance difficulty
@ -2044,6 +2104,10 @@ private:
TradeStatus tradeStatus_ = TradeStatus::None;
uint64_t tradePeerGuid_= 0;
std::string tradePeerName_;
std::array<TradeSlot, TRADE_SLOT_COUNT> myTradeSlots_{};
std::array<TradeSlot, TRADE_SLOT_COUNT> peerTradeSlots_{};
uint64_t myTradeGold_ = 0;
uint64_t peerTradeGold_ = 0;
// Duel state
bool pendingDuelRequest_ = false;
@ -2099,6 +2163,8 @@ private:
std::unordered_map<uint64_t, float> recentLootMoneyAnnounceCooldowns_;
uint64_t playerMoneyCopper_ = 0;
int32_t playerArmorRating_ = 0;
// Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet
int32_t playerStats_[5] = {-1, -1, -1, -1, -1};
// Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating
// money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime.
uint32_t pendingMoneyDelta_ = 0;
@ -2246,6 +2312,9 @@ private:
std::unordered_map<uint32_t, std::string> achievementNameCache_;
bool achievementNameCacheLoaded_ = false;
void loadAchievementNameCache();
// Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA)
std::unordered_set<uint32_t> earnedAchievements_;
void handleAllAchievementData(network::Packet& packet);
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
std::unordered_map<uint32_t, std::string> areaNameCache_;
@ -2340,6 +2409,10 @@ private:
void loadSkillLineAbilityDbc();
void extractSkillFields(const std::map<uint16_t, uint32_t>& fields);
void extractExploredZoneFields(const std::map<uint16_t, uint32_t>& fields);
void applyQuestStateFromFields(const std::map<uint16_t, uint32_t>& fields);
// Apply packed kill counts from player update fields to a quest entry that has
// already had its killObjectives populated from SMSG_QUEST_QUERY_RESPONSE.
void applyPackedKillCountsFromFields(QuestLogEntry& quest);
NpcDeathCallback npcDeathCallback_;
NpcAggroCallback npcAggroCallback_;

View file

@ -207,6 +207,11 @@ public:
* WotLK: 5 fields per slot, Classic/Vanilla: 3. */
virtual uint8_t questLogStride() const { return 5; }
/** Number of PLAYER_EXPLORED_ZONES uint32 fields in update-object blocks.
* Classic/Vanilla/Turtle: 64 (bit-packs up to zone ID 2047).
* TBC/WotLK: 128 (covers Outland/Northrend zone IDs up to 4095). */
virtual uint8_t exploredZonesCount() const { return 128; }
// --- Quest Giver Status ---
/** Read quest giver status from packet.
@ -407,6 +412,9 @@ public:
network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override;
// parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3)
uint8_t questLogStride() const override { return 3; }
// Classic 1.12 has 64 explored-zone uint32 fields (zone IDs fit in 2048 bits).
// TBC/WotLK use 128 (needed for Outland/Northrend zone IDs up to 4095).
uint8_t exploredZonesCount() const override { return 64; }
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override {
return MonsterMoveParser::parseVanilla(packet, data);
}

View file

@ -51,7 +51,7 @@ struct CombatTextEntry {
enum Type : uint8_t {
MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK,
CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL,
ENERGIZE, XP_GAIN, IMMUNE
ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST
};
Type type;
int32_t amount = 0;

View file

@ -14,6 +14,7 @@ namespace game {
enum class UF : uint16_t {
// Object fields
OBJECT_FIELD_ENTRY,
OBJECT_FIELD_SCALE_X,
// Unit fields
UNIT_FIELD_TARGET_LO,
@ -33,6 +34,11 @@ enum class UF : uint16_t {
UNIT_NPC_FLAGS,
UNIT_DYNAMIC_FLAGS,
UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array)
UNIT_FIELD_STAT0, // Strength (effective base, includes items)
UNIT_FIELD_STAT1, // Agility
UNIT_FIELD_STAT2, // Stamina
UNIT_FIELD_STAT3, // Intellect
UNIT_FIELD_STAT4, // Spirit
UNIT_END,
// Player fields

View file

@ -1356,6 +1356,33 @@ public:
static network::Packet build();
};
/** CMSG_SET_TRADE_ITEM packet builder (tradeSlot, bag, bagSlot) */
class SetTradeItemPacket {
public:
// tradeSlot: 0-5 (normal) or 6 (backpack money-only slot)
// bag: 255 = main backpack, 19-22 = bag slots
// bagSlot: slot within bag
static network::Packet build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot);
};
/** CMSG_CLEAR_TRADE_ITEM packet builder (remove item from trade slot) */
class ClearTradeItemPacket {
public:
static network::Packet build(uint8_t tradeSlot);
};
/** CMSG_SET_TRADE_GOLD packet builder (gold offered, in copper) */
class SetTradeGoldPacket {
public:
static network::Packet build(uint64_t copper);
};
/** CMSG_UNACCEPT_TRADE packet builder (unaccept without cancelling) */
class UnacceptTradePacket {
public:
static network::Packet build();
};
/** CMSG_ATTACKSWING packet builder */
class AttackSwingPacket {
public:

View file

@ -90,6 +90,11 @@ public:
// Movement callback for sending opcodes to server
using MovementCallback = std::function<void(uint32_t opcode)>;
void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); }
// Callback invoked when the player stands up via local input (space/X/movement key
// while server-sitting), so the caller can send CMSG_STAND_STATE_CHANGE(0).
using StandUpCallback = std::function<void()>;
void setStandUpCallback(StandUpCallback cb) { standUpCallback_ = std::move(cb); }
void setUseWoWSpeed(bool use) { useWoWSpeed = use; }
void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; }
void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; }
@ -265,6 +270,7 @@ private:
// Movement callback
MovementCallback movementCallback;
StandUpCallback standUpCallback_;
// Movement speeds
bool useWoWSpeed = false;

View file

@ -127,7 +127,8 @@ struct M2ModelGPU {
// Particle emitter data (kept from M2Model)
std::vector<pipeline::M2ParticleEmitter> particleEmitters;
std::vector<VkTexture*> particleTextures; // Resolved Vulkan textures per emitter
std::vector<VkTexture*> particleTextures; // Resolved Vulkan textures per emitter
std::vector<VkDescriptorSet> particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc)
// Texture transform data for UV animation
std::vector<pipeline::M2TextureTransform> textureTransforms;

View file

@ -35,8 +35,10 @@ public:
* @param position World position (NPC base position)
* @param markerType 0=available(!), 1=turnin(?), 2=incomplete(?)
* @param boundingHeight NPC bounding height (optional, default 2.0f)
* @param grayscale 0 = full colour, 1 = desaturated grey (trivial/low-level quests)
*/
void setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight = 2.0f);
void setMarker(uint64_t guid, const glm::vec3& position, int markerType,
float boundingHeight = 2.0f, float grayscale = 0.0f);
/**
* Remove a quest marker
@ -61,6 +63,7 @@ private:
glm::vec3 position;
int type; // 0=available, 1=turnin, 2=incomplete
float boundingHeight = 2.0f;
float grayscale = 0.0f; // 0 = colour, 1 = desaturated (trivial quests)
};
std::unordered_map<uint64_t, Marker> markers_;

View file

@ -117,6 +117,8 @@ private:
std::vector<uint32_t> serverExplorationMask;
bool hasServerExplorationMask = false;
std::unordered_set<int> exploredZones;
// Locally accumulated exploration (used as fallback when server mask is unavailable)
std::unordered_set<int> locallyExploredZones_;
};
} // namespace rendering

View file

@ -189,6 +189,7 @@ private:
* Render target frame
*/
void renderTargetFrame(game::GameHandler& gameHandler);
void renderFocusFrame(game::GameHandler& gameHandler);
/**
* Render pet frame (below player frame when player has an active pet)
@ -223,6 +224,7 @@ private:
void renderDuelRequestPopup(game::GameHandler& gameHandler);
void renderLootRollPopup(game::GameHandler& gameHandler);
void renderTradeRequestPopup(game::GameHandler& gameHandler);
void renderTradeWindow(game::GameHandler& gameHandler);
void renderSummonRequestPopup(game::GameHandler& gameHandler);
void renderSharedQuestPopup(game::GameHandler& gameHandler);
void renderItemTextWindow(game::GameHandler& gameHandler);
@ -248,6 +250,8 @@ private:
void renderGuildRoster(game::GameHandler& gameHandler);
void renderGuildInvitePopup(game::GameHandler& gameHandler);
void renderReadyCheckPopup(game::GameHandler& gameHandler);
void renderBgInvitePopup(game::GameHandler& gameHandler);
void renderLfgProposalPopup(game::GameHandler& gameHandler);
void renderChatBubbles(game::GameHandler& gameHandler);
void renderMailWindow(game::GameHandler& gameHandler);
void renderMailComposeWindow(game::GameHandler& gameHandler);
@ -366,6 +370,7 @@ private:
static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f;
float achievementToastTimer_ = 0.0f;
uint32_t achievementToastId_ = 0;
std::string achievementToastName_;
void renderAchievementToast();
// Zone discovery text ("Entering: <ZoneName>")
@ -377,7 +382,7 @@ private:
public:
void triggerDing(uint32_t newLevel);
void triggerAchievementToast(uint32_t achievementId);
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
};
} // namespace ui

View file

@ -96,6 +96,7 @@ private:
std::unordered_map<uint32_t, VkDescriptorSet> iconCache_;
public:
VkDescriptorSet getItemIcon(uint32_t displayInfoId);
void renderItemTooltip(const game::ItemQueryResponseData& info);
private:
// Character model preview
@ -147,7 +148,8 @@ private:
int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper);
void renderEquipmentPanel(game::Inventory& inventory);
void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false);
void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0);
void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0,
const int32_t* serverStats = nullptr);
void renderReputationPanel(game::GameHandler& gameHandler);
void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot,

View file

@ -94,8 +94,8 @@ private:
VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager);
const SpellInfo* getSpellInfo(uint32_t spellId) const;
// Tooltip rendering helper
void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler);
// Tooltip rendering helper (showUsageHints=false when called from action bar)
void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints = true);
};
} // namespace ui

View file

@ -628,6 +628,11 @@ void Application::setState(AppState newState) {
gameHandler->sendMovement(static_cast<game::Opcode>(opcode));
}
});
cc->setStandUpCallback([this]() {
if (gameHandler) {
gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND)
}
});
cc->setUseWoWSpeed(true);
}
if (gameHandler) {
@ -980,6 +985,18 @@ void Application::update(float deltaTime) {
retrySpawn.y = unit->getY();
retrySpawn.z = unit->getZ();
retrySpawn.orientation = unit->getOrientation();
{
using game::fieldIndex; using game::UF;
uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X);
if (si != 0xFFFF) {
uint32_t raw = unit->getField(si);
if (raw != 0) {
float s2 = 1.0f;
std::memcpy(&s2, &raw, sizeof(float));
if (s2 > 0.01f && s2 < 100.0f) retrySpawn.scale = s2;
}
}
}
pendingCreatureSpawns_.push_back(retrySpawn);
pendingCreatureSpawnGuids_.insert(guid);
}
@ -2193,12 +2210,12 @@ void Application::setupUICallbacks() {
// Faction hostility map is built in buildFactionHostilityMap() when character enters world
// Creature spawn callback (online mode) - spawn creature models
gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) {
gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
// Queue spawns to avoid hanging when many creatures appear at once.
// Deduplicate so repeated updates don't flood pending queue.
if (creatureInstances_.count(guid)) return;
if (pendingCreatureSpawnGuids_.count(guid)) return;
pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation});
pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation, scale});
pendingCreatureSpawnGuids_.insert(guid);
});
@ -2244,8 +2261,8 @@ void Application::setupUICallbacks() {
});
// GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.)
gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation});
gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation, scale});
});
// GameObject despawn callback (online mode) - remove static models
@ -2330,9 +2347,9 @@ void Application::setupUICallbacks() {
});
// Achievement earned callback — show toast banner
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) {
gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) {
if (uiManager) {
uiManager->getGameScreen().triggerAchievementToast(achievementId);
uiManager->getGameScreen().triggerAchievementToast(achievementId, name);
}
});
@ -2548,13 +2565,19 @@ void Application::setupUICallbacks() {
// Don't override Death animation (1). The per-frame sync loop will return to
// Stand when movement stops.
if (durationMs > 0) {
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
auto* cr = renderer->getCharacterRenderer();
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) {
cr->playAnimation(instanceId, 5u, /*loop=*/true);
// Player animation is managed by the local renderer state machine —
// don't reset it here or every server movement packet restarts the
// run cycle from frame 0, causing visible stutter.
if (!isPlayer) {
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
auto* cr = renderer->getCharacterRenderer();
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur);
// Only start Run if not already running and not in Death animation.
if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) {
cr->playAnimation(instanceId, 5u, /*loop=*/true);
}
creatureWasMoving_[guid] = true;
}
if (!isPlayer) creatureWasMoving_[guid] = true;
}
}
});
@ -4743,7 +4766,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// Process ALL pending game object spawns.
while (!pendingGameObjectSpawns_.empty()) {
auto& s = pendingGameObjectSpawns_.front();
spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation);
spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale);
pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin());
}
@ -5274,7 +5297,7 @@ pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) {
return model;
}
void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) {
void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return;
// Skip if lookups not yet built (asset manager not ready)
@ -5711,9 +5734,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
// Convert canonical WoW orientation (0=north) -> render yaw (0=west)
float renderYaw = orientation + glm::radians(90.0f);
// Create instance
// Create instance (apply server-provided scale from OBJECT_FIELD_SCALE_X)
uint32_t instanceId = charRenderer->createInstance(modelId, renderPos,
glm::vec3(0.0f, 0.0f, renderYaw), 1.0f);
glm::vec3(0.0f, 0.0f, renderYaw), scale);
if (instanceId == 0) {
LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec);
@ -7024,7 +7047,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) {
creatureWasWalking_.erase(guid);
}
void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) {
if (!renderer || !assetManager) return;
if (!gameObjectLookupsBuilt_) {
@ -7181,7 +7204,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
if (loadedAsWmo) {
uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos,
glm::vec3(0.0f, 0.0f, renderYawWmo), 1.0f);
glm::vec3(0.0f, 0.0f, renderYawWmo), scale);
if (instanceId == 0) {
LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec);
return;
@ -7289,7 +7312,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
}
uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos,
glm::vec3(0.0f, 0.0f, renderYawM2go), 1.0f);
glm::vec3(0.0f, 0.0f, renderYawM2go), scale);
if (instanceId == 0) {
LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec);
return;
@ -7407,6 +7430,7 @@ void Application::processAsyncCreatureResults(bool unlimited) {
s.y = result.y;
s.z = result.z;
s.orientation = result.orientation;
s.scale = result.scale;
pendingCreatureSpawns_.push_back(s);
pendingCreatureSpawnGuids_.insert(result.guid);
}
@ -7721,6 +7745,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) {
result.y = s.y;
result.z = s.z;
result.orientation = s.orientation;
result.scale = s.scale;
auto m2Data = am->readFile(m2Path);
if (m2Data.empty()) {
@ -7799,7 +7824,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) {
// Cached model — spawn is fast (no file I/O, just instance creation + texture setup)
{
auto spawnStart = std::chrono::steady_clock::now();
spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation);
spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale);
auto spawnEnd = std::chrono::steady_clock::now();
float spawnMs = std::chrono::duration<float, std::milli>(spawnEnd - spawnStart).count();
if (spawnMs > 100.0f) {
@ -8004,7 +8029,7 @@ void Application::processAsyncGameObjectResults() {
if (!result.valid || !result.isWmo || !result.wmoModel) {
// Fallback: spawn via sync path (likely an M2 or failed WMO)
spawnOnlineGameObject(result.guid, result.entry, result.displayId,
result.x, result.y, result.z, result.orientation);
result.x, result.y, result.z, result.orientation, result.scale);
continue;
}
@ -8031,7 +8056,7 @@ void Application::processAsyncGameObjectResults() {
glm::vec3 renderPos = core::coords::canonicalToRender(
glm::vec3(result.x, result.y, result.z));
uint32_t instanceId = wmoRenderer->createInstance(
modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), 1.0f);
modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale);
if (instanceId == 0) continue;
gameObjectInstances_[result.guid] = {modelId, instanceId, true};
@ -8118,6 +8143,7 @@ void Application::processGameObjectSpawnQueue() {
result.y = capture.y;
result.z = capture.z;
result.orientation = capture.orientation;
result.scale = capture.scale;
result.modelPath = capturePath;
result.isWmo = true;
@ -8183,7 +8209,7 @@ void Application::processGameObjectSpawnQueue() {
}
// Cached WMO or M2 — spawn synchronously (cheap)
spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation);
spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale);
pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin());
}
}
@ -8696,17 +8722,21 @@ void Application::updateQuestMarkers() {
int markerType = -1; // -1 = no marker
using game::QuestGiverStatus;
float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests)
switch (status) {
case QuestGiverStatus::AVAILABLE:
markerType = 0; // Yellow !
break;
case QuestGiverStatus::AVAILABLE_LOW:
markerType = 0; // Available (yellow !)
markerType = 0; // Grey ! (same texture, desaturated in shader)
markerGrayscale = 1.0f;
break;
case QuestGiverStatus::REWARD:
case QuestGiverStatus::REWARD_REP:
markerType = 1; // Turn-in (yellow ?)
markerType = 1; // Yellow ?
break;
case QuestGiverStatus::INCOMPLETE:
markerType = 2; // Incomplete (grey ?)
markerType = 2; // Grey ?
break;
default:
break;
@ -8740,7 +8770,7 @@ void Application::updateQuestMarkers() {
}
// Set the marker (renderer will handle positioning, bob, glow, etc.)
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight);
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale);
markersAdded++;
}

View file

@ -109,16 +109,16 @@ size_t MemoryMonitor::getAvailableRAM() const {
size_t MemoryMonitor::getRecommendedCacheBudget() const {
size_t available = getAvailableRAM();
// Use 80% of available RAM for caches (very aggressive), but cap at 90% of total
size_t budget = available * 80 / 100;
size_t maxBudget = totalRAM_ * 90 / 100;
return budget < maxBudget ? budget : maxBudget;
// Use 50% of available RAM for caches, hard-capped at 16 GB.
static constexpr size_t kHardCapBytes = 16ull * 1024 * 1024 * 1024; // 16 GB
size_t budget = available * 50 / 100;
return budget < kHardCapBytes ? budget : kHardCapBytes;
}
bool MemoryMonitor::isMemoryPressure() const {
size_t available = getAvailableRAM();
// Memory pressure if < 20% RAM available
return available < (totalRAM_ * 20 / 100);
// Memory pressure if < 10% RAM available
return available < (totalRAM_ * 10 / 100);
}
} // namespace core

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ struct UFNameEntry {
static const UFNameEntry kUFNames[] = {
{"OBJECT_FIELD_ENTRY", UF::OBJECT_FIELD_ENTRY},
{"OBJECT_FIELD_SCALE_X", UF::OBJECT_FIELD_SCALE_X},
{"UNIT_FIELD_TARGET_LO", UF::UNIT_FIELD_TARGET_LO},
{"UNIT_FIELD_TARGET_HI", UF::UNIT_FIELD_TARGET_HI},
{"UNIT_FIELD_BYTES_0", UF::UNIT_FIELD_BYTES_0},
@ -36,6 +37,11 @@ static const UFNameEntry kUFNames[] = {
{"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS},
{"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS},
{"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES},
{"UNIT_FIELD_STAT0", UF::UNIT_FIELD_STAT0},
{"UNIT_FIELD_STAT1", UF::UNIT_FIELD_STAT1},
{"UNIT_FIELD_STAT2", UF::UNIT_FIELD_STAT2},
{"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3},
{"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4},
{"UNIT_END", UF::UNIT_END},
{"PLAYER_FLAGS", UF::PLAYER_FLAGS},
{"PLAYER_BYTES", UF::PLAYER_BYTES},
@ -52,6 +58,9 @@ static const UFNameEntry kUFNames[] = {
{"PLAYER_EXPLORED_ZONES_START", UF::PLAYER_EXPLORED_ZONES_START},
{"GAMEOBJECT_DISPLAYID", UF::GAMEOBJECT_DISPLAYID},
{"ITEM_FIELD_STACK_COUNT", UF::ITEM_FIELD_STACK_COUNT},
{"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY},
{"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY},
{"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE},
{"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS},
{"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1},
};

View file

@ -2177,6 +2177,35 @@ network::Packet AcceptTradePacket::build() {
return packet;
}
network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM));
packet.writeUInt8(tradeSlot);
packet.writeUInt8(bag);
packet.writeUInt8(bagSlot);
LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", (int)tradeSlot, " bag=", (int)bag, " bagSlot=", (int)bagSlot);
return packet;
}
network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM));
packet.writeUInt8(tradeSlot);
LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", (int)tradeSlot);
return packet;
}
network::Packet SetTradeGoldPacket::build(uint64_t copper) {
network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD));
packet.writeUInt64(copper);
LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper);
return packet;
}
network::Packet UnacceptTradePacket::build() {
network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE));
LOG_DEBUG("Built CMSG_UNACCEPT_TRADE");
return packet;
}
network::Packet InitiateTradePacket::build(uint64_t targetGuid) {
network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE));
packet.writeUInt64(targetGuid);
@ -3637,11 +3666,19 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
data.title = normalizeWowTextTokens(packet.readString());
data.rewardText = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 10 > packet.getSize()) {
if (packet.getReadPos() + 8 > packet.getSize()) {
LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");
return true;
}
// After the two strings the packet contains a variable prefix (autoFinish + optional fields)
// before the emoteCount. Different expansions and server emulator versions differ:
// Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes
// TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays)
// WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays)
// Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix).
// We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each.
struct ParsedTail {
uint32_t rewardMoney = 0;
uint32_t rewardXp = 0;
@ -3649,28 +3686,27 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
std::vector<QuestRewardItem> fixedRewards;
bool ok = false;
int score = -1000;
size_t prefixSkip = 0;
bool fixedArrays = false;
};
auto parseTail = [&](size_t startPos, bool hasFlags, bool fixedArrays) -> ParsedTail {
auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail {
ParsedTail out;
out.prefixSkip = prefixSkip;
out.fixedArrays = fixedArrays;
packet.setReadPos(startPos);
if (packet.getReadPos() + 1 > packet.getSize()) return out;
/*autoFinish*/ packet.readUInt8();
if (hasFlags) {
if (packet.getReadPos() + 4 > packet.getSize()) return out;
/*flags*/ packet.readUInt32();
}
if (packet.getReadPos() + 4 > packet.getSize()) return out;
/*suggestedPlayers*/ packet.readUInt32();
// Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount)
if (packet.getReadPos() + prefixSkip > packet.getSize()) return out;
packet.setReadPos(packet.getReadPos() + prefixSkip);
if (packet.getReadPos() + 4 > packet.getSize()) return out;
uint32_t emoteCount = packet.readUInt32();
if (emoteCount > 64) return out; // guard against misalignment
if (emoteCount > 32) return out; // guard against misalignment
for (uint32_t i = 0; i < emoteCount; ++i) {
if (packet.getReadPos() + 8 > packet.getSize()) return out;
packet.readUInt32(); // delay
packet.readUInt32(); // emote
packet.readUInt32(); // emote type
}
if (packet.getReadPos() + 4 > packet.getSize()) return out;
@ -3688,7 +3724,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
item.choiceSlot = i;
if (item.itemId > 0) {
out.choiceRewards.push_back(item);
nonZeroChoice++;
++nonZeroChoice;
}
}
@ -3706,7 +3742,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
item.displayInfoId = packet.readUInt32();
if (item.itemId > 0) {
out.fixedRewards.push_back(item);
nonZeroFixed++;
++nonZeroFixed;
}
}
@ -3717,43 +3753,56 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
out.ok = true;
out.score = 0;
if (hasFlags) out.score += 1;
if (fixedArrays) out.score += 1;
// Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers)
if (prefixSkip == 8) out.score += 3;
else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers
// Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots)
if (fixedArrays) out.score += 2;
// Valid counts
if (choiceCount <= 6) out.score += 3;
if (rewardCount <= 4) out.score += 3;
if (fixedArrays) {
if (nonZeroChoice <= choiceCount) out.score += 3;
if (nonZeroFixed <= rewardCount) out.score += 3;
} else {
out.score += 3; // variable arrays align naturally with count
}
if (packet.getReadPos() <= packet.getSize()) out.score += 2;
// All non-zero items are within declared counts
if (nonZeroChoice <= choiceCount) out.score += 2;
if (nonZeroFixed <= rewardCount) out.score += 2;
// No bytes left over (or only a few)
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining <= 32) out.score += 2;
if (remaining == 0) out.score += 5;
else if (remaining <= 4) out.score += 3;
else if (remaining <= 8) out.score += 2;
else if (remaining <= 16) out.score += 1;
else out.score -= static_cast<int>(remaining / 4);
// Plausible money/XP values
if (out.rewardMoney < 5000000u) out.score += 1; // < 500g
if (out.rewardXp < 200000u) out.score += 1; // < 200k XP
return out;
};
size_t tailStart = packet.getReadPos();
ParsedTail a = parseTail(tailStart, true, true); // WotLK-like (flags + fixed 6/4 arrays)
ParsedTail b = parseTail(tailStart, false, true); // no flags + fixed 6/4 arrays
ParsedTail c = parseTail(tailStart, true, false); // flags + variable arrays
ParsedTail d = parseTail(tailStart, false, false); // classic-like variable arrays
// Try prefix sizes 0..16 bytes with both fixed and variable array layouts
std::vector<ParsedTail> candidates;
candidates.reserve(34);
for (size_t skip = 0; skip <= 16; ++skip) {
candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays
candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays
}
const ParsedTail* best = nullptr;
for (const ParsedTail* cand : {&a, &b, &c, &d}) {
if (!cand->ok) continue;
if (!best || cand->score > best->score) best = cand;
for (const auto& cand : candidates) {
if (!cand.ok) continue;
if (!best || cand.score > best->score) best = &cand;
}
if (best) {
data.choiceRewards = best->choiceRewards;
data.fixedRewards = best->fixedRewards;
data.rewardMoney = best->rewardMoney;
data.rewardXp = best->rewardXp;
data.fixedRewards = best->fixedRewards;
data.rewardMoney = best->rewardMoney;
data.rewardXp = best->rewardXp;
}
LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title,
"' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size());
"' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(),
" prefix=", (best ? best->prefixSkip : size_t(0)),
(best && best->fixedArrays ? " fixed" : " var"));
return true;
}

View file

@ -92,7 +92,7 @@ void AssetManager::setupFileCacheBudget() {
const size_t envMaxMB = parseEnvSizeMB("WOWEE_FILE_CACHE_MAX_MB");
const size_t minBudgetBytes = 256ull * 1024ull * 1024ull;
const size_t defaultMaxBudgetBytes = 32768ull * 1024ull * 1024ull;
const size_t defaultMaxBudgetBytes = 12288ull * 1024ull * 1024ull; // 12 GB max for file cache
const size_t maxBudgetBytes = (envMaxMB > 0)
? (envMaxMB * 1024ull * 1024ull)
: defaultMaxBudgetBytes;

View file

@ -64,7 +64,7 @@ std::string narrowWString(const wchar_t* msg) {
std::string out;
for (const wchar_t* p = msg; *p; ++p) {
const wchar_t wc = *p;
if (wc >= 0 && wc <= 0x7f) {
if (wc <= 0x7f) {
out.push_back(static_cast<char>(wc));
} else {
out.push_back('?');

View file

@ -369,6 +369,7 @@ void CameraController::update(float deltaTime) {
// Toggle sit/crouch with X key (edge-triggered) — only when UI doesn't want keyboard
// Blocked while mounted
bool prevSitting = sitting;
bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X);
if (xDown && !xKeyWasDown && !mounted_) {
sitting = !sitting;
@ -376,6 +377,21 @@ void CameraController::update(float deltaTime) {
if (mounted_) sitting = false;
xKeyWasDown = xDown;
// Stand up on any movement key or jump while sitting (WoW behaviour)
if (!uiWantsKeyboard && sitting && !movementSuppressed) {
bool anyMoveKey =
input.isKeyPressed(SDL_SCANCODE_W) || input.isKeyPressed(SDL_SCANCODE_S) ||
input.isKeyPressed(SDL_SCANCODE_A) || input.isKeyPressed(SDL_SCANCODE_D) ||
input.isKeyPressed(SDL_SCANCODE_Q) || input.isKeyPressed(SDL_SCANCODE_E) ||
input.isKeyPressed(SDL_SCANCODE_SPACE);
if (anyMoveKey) sitting = false;
}
// Notify server when the player stands up via local input
if (prevSitting && !sitting && standUpCallback_) {
standUpCallback_();
}
// Update eye height based on crouch state (smooth transition)
float targetEyeHeight = sitting ? CROUCH_EYE_HEIGHT : STAND_EYE_HEIGHT;
float heightLerpSpeed = 10.0f * deltaTime;
@ -389,11 +405,6 @@ void CameraController::update(float deltaTime) {
if (nowStrafeLeft) movement += right;
if (nowStrafeRight) movement -= right;
// Stand up if jumping while crouched
if (!uiWantsKeyboard && sitting && input.isKeyPressed(SDL_SCANCODE_SPACE)) {
sitting = false;
}
// Third-person orbit camera mode
if (thirdPerson && followTarget) {
// Move the follow target (character position) instead of the camera

View file

@ -1327,12 +1327,12 @@ VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath,
blitOverlay(composite, width, height, overlay, dstX, dstY);
}
} else {
// Size mismatch — blit at natural size (may clip or leave gap)
core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx,
" at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height,
" expected=", expectedW, "x", expectedH, " from ", rl.second);
blitOverlay(composite, width, height, overlay, dstX, dstY);
}
core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx,
" at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height,
" expected=", expectedW, "x", expectedH, " from ", rl.second);
}
// Upload to GPU via VkTexture
@ -1580,12 +1580,20 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId,
instance.animationTime = 0.0f;
instance.animationLoop = loop;
// Prefer variationIndex==0 (primary animation); fall back to first match
int firstMatch = -1;
for (size_t i = 0; i < model.sequences.size(); i++) {
if (model.sequences[i].id == animationId) {
instance.currentSequenceIndex = static_cast<int>(i);
break;
if (firstMatch < 0) firstMatch = static_cast<int>(i);
if (model.sequences[i].variationIndex == 0) {
instance.currentSequenceIndex = static_cast<int>(i);
break;
}
}
}
if (instance.currentSequenceIndex < 0 && firstMatch >= 0) {
instance.currentSequenceIndex = firstMatch;
}
if (instance.currentSequenceIndex < 0) {
// Fall back to first sequence

View file

@ -743,10 +743,16 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) {
VmaAllocator alloc = vkCtx_->getAllocator();
if (model.vertexBuffer) { vmaDestroyBuffer(alloc, model.vertexBuffer, model.vertexAlloc); model.vertexBuffer = VK_NULL_HANDLE; }
if (model.indexBuffer) { vmaDestroyBuffer(alloc, model.indexBuffer, model.indexAlloc); model.indexBuffer = VK_NULL_HANDLE; }
VkDevice device = vkCtx_->getDevice();
for (auto& batch : model.batches) {
if (batch.materialSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &batch.materialSet); batch.materialSet = VK_NULL_HANDLE; }
if (batch.materialUBO) { vmaDestroyBuffer(alloc, batch.materialUBO, batch.materialUBOAlloc); batch.materialUBO = VK_NULL_HANDLE; }
// materialSet freed when pool is reset/destroyed
}
// Free pre-allocated particle texture descriptor sets
for (auto& pSet : model.particleTexSets) {
if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; }
}
model.particleTexSets.clear();
}
void M2Renderer::destroyInstanceBones(M2Instance& inst) {
@ -979,8 +985,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(lowerName.find("monument") != std::string::npos) ||
(lowerName.find("sculpture") != std::string::npos);
gpuModel.collisionStatue = statueName;
// Sittable furniture: chairs/benches/stools cause players to get stuck against
// invisible bounding boxes; WMOs already handle room collision.
bool sittableFurnitureName =
(lowerName.find("chair") != std::string::npos) ||
(lowerName.find("bench") != std::string::npos) ||
(lowerName.find("stool") != std::string::npos) ||
(lowerName.find("seat") != std::string::npos) ||
(lowerName.find("throne") != std::string::npos);
bool smallSolidPropName =
statueName ||
(statueName && !sittableFurnitureName) ||
(lowerName.find("crate") != std::string::npos) ||
(lowerName.find("box") != std::string::npos) ||
(lowerName.find("chest") != std::string::npos) ||
@ -1023,6 +1037,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(lowerName.find("bamboo") != std::string::npos) ||
(lowerName.find("banana") != std::string::npos) ||
(lowerName.find("coconut") != std::string::npos) ||
(lowerName.find("watermelon") != std::string::npos) ||
(lowerName.find("melon") != std::string::npos) ||
(lowerName.find("squash") != std::string::npos) ||
(lowerName.find("gourd") != std::string::npos) ||
(lowerName.find("canopy") != std::string::npos) ||
(lowerName.find("hedge") != std::string::npos) ||
(lowerName.find("cactus") != std::string::npos) ||
@ -1148,7 +1166,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(lowerName.find("lavasplash") != std::string::npos) ||
(lowerName.find("lavabubble") != std::string::npos) ||
(lowerName.find("lavasteam") != std::string::npos) ||
(lowerName.find("wisps") != std::string::npos);
(lowerName.find("wisps") != std::string::npos) ||
(lowerName.find("levelup") != std::string::npos);
gpuModel.isSpellEffect = effectByName ||
(hasParticles && model.vertices.size() <= 200 &&
model.particleEmitters.size() >= 3);
@ -1335,6 +1354,31 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
}
// Pre-allocate one stable descriptor set per particle emitter to avoid per-frame allocation.
// This prevents materialDescPool_ exhaustion when many emitters are active each frame.
if (particleTexLayout_ && materialDescPool_ && !model.particleEmitters.empty()) {
VkDevice device = vkCtx_->getDevice();
gpuModel.particleTexSets.resize(model.particleEmitters.size(), VK_NULL_HANDLE);
for (size_t ei = 0; ei < model.particleEmitters.size(); ei++) {
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = materialDescPool_;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &particleTexLayout_;
if (vkAllocateDescriptorSets(device, &ai, &gpuModel.particleTexSets[ei]) == VK_SUCCESS) {
VkTexture* tex = gpuModel.particleTextures[ei];
VkDescriptorImageInfo imgInfo = tex->descriptorInfo();
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = gpuModel.particleTexSets[ei];
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
}
}
}
// Copy texture transform data for UV animation
gpuModel.textureTransforms = model.textureTransforms;
gpuModel.textureTransformLookup = model.textureTransformLookup;
@ -3401,6 +3445,7 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
uint8_t blendType;
uint16_t tilesX;
uint16_t tilesY;
VkDescriptorSet preAllocSet = VK_NULL_HANDLE; // Pre-allocated stable set, avoids per-frame alloc
std::vector<float> vertexData; // 9 floats per particle
};
std::unordered_map<ParticleGroupKey, ParticleGroup, ParticleGroupKeyHash> groups;
@ -3442,6 +3487,11 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
group.blendType = em.blendingType;
group.tilesX = tilesX;
group.tilesY = tilesY;
// Capture pre-allocated descriptor set on first insertion for this key
if (group.preAllocSet == VK_NULL_HANDLE &&
p.emitterIndex < static_cast<int>(gpu.particleTexSets.size())) {
group.preAllocSet = gpu.particleTexSets[p.emitterIndex];
}
group.vertexData.push_back(p.position.x);
group.vertexData.push_back(p.position.y);
@ -3485,23 +3535,27 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
currentPipeline = desiredPipeline;
}
// Allocate descriptor set for this group's texture
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = materialDescPool_;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &particleTexLayout_;
VkDescriptorSet texSet = VK_NULL_HANDLE;
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) {
VkTexture* tex = group.texture ? group.texture : whiteTexture_.get();
VkDescriptorImageInfo imgInfo = tex->descriptorInfo();
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.dstSet = texSet;
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr);
// Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable
VkDescriptorSet texSet = group.preAllocSet;
if (texSet == VK_NULL_HANDLE) {
// Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice)
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = materialDescPool_;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &particleTexLayout_;
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) {
VkTexture* tex = group.texture ? group.texture : whiteTexture_.get();
VkDescriptorImageInfo imgInfo = tex->descriptorInfo();
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.dstSet = texSet;
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr);
}
}
if (texSet != VK_NULL_HANDLE) {
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
particlePipelineLayout_, 1, 1, &texSet, 0, nullptr);
}

View file

@ -17,8 +17,9 @@ namespace wowee { namespace rendering {
// Push constant layout matching quest_marker.vert.glsl / quest_marker.frag.glsl
struct QuestMarkerPushConstants {
glm::mat4 model; // 64 bytes, used by vertex shader
float alpha; // 4 bytes, used by fragment shader
glm::mat4 model; // 64 bytes, used by vertex shader
float alpha; // 4 bytes, used by fragment shader
float grayscale; // 4 bytes: 0=colour, 1=desaturated (trivial quests)
};
QuestMarkerRenderer::QuestMarkerRenderer() {
@ -340,8 +341,9 @@ void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) {
}
}
void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight) {
markers_[guid] = {position, markerType, boundingHeight};
void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType,
float boundingHeight, float grayscale) {
markers_[guid] = {position, markerType, boundingHeight, grayscale};
}
void QuestMarkerRenderer::removeMarker(uint64_t guid) {
@ -436,10 +438,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_,
1, 1, &texDescSets_[marker.type], 0, nullptr);
// Push constants: model matrix + alpha
// Push constants: model matrix + alpha + grayscale tint
QuestMarkerPushConstants push{};
push.model = model;
push.alpha = fadeAlpha;
push.grayscale = marker.grayscale;
vkCmdPushConstants(cmd, pipelineLayout_,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,

View file

@ -710,6 +710,8 @@ bool Renderer::initialize(core::Window* win) {
levelUpEffect = std::make_unique<LevelUpEffect>();
questMarkerRenderer = std::make_unique<QuestMarkerRenderer>();
LOG_INFO("Vulkan sub-renderers initialized (Phase 3)");
// LightingManager doesn't use GL — initialize for data-only use
@ -2222,6 +2224,14 @@ void Renderer::updateCharacterAnimation() {
} else if (sitting) {
cancelEmote();
newState = CharAnimState::SIT_DOWN;
} else if (!emoteLoop && characterRenderer && characterInstanceId > 0) {
// Auto-cancel non-looping emotes once animation completes
uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f;
if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur)
&& curDur > 0.1f && curT >= curDur - 0.05f) {
cancelEmote();
newState = CharAnimState::IDLE;
}
}
break;
@ -4845,7 +4855,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y);
hasMinimapPlayerOrientation = true;
} else if (gameHandler) {
minimapPlayerOrientation = gameHandler->getMovementInfo().orientation;
// Server orientation is in WoW space: π/2 = North, 0 = East.
// Minimap arrow expects render space: 0 = North, π/2 = East.
// Convert: minimap_angle = server_orientation - π/2
minimapPlayerOrientation = gameHandler->getMovementInfo().orientation
- static_cast<float>(M_PI_2);
hasMinimapPlayerOrientation = true;
}
minimap->render(cmd, *camera, minimapCenter,
@ -4973,7 +4987,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y);
hasMinimapPlayerOrientation = true;
} else if (gameHandler) {
minimapPlayerOrientation = gameHandler->getMovementInfo().orientation;
// Server orientation is in WoW space: π/2 = North, 0 = East.
// Minimap arrow expects render space: 0 = North, π/2 = East.
// Convert: minimap_angle = server_orientation - π/2
minimapPlayerOrientation = gameHandler->getMovementInfo().orientation
- static_cast<float>(M_PI_2);
hasMinimapPlayerOrientation = true;
}
minimap->render(currentCmd, *camera, minimapCenter,

View file

@ -89,6 +89,7 @@ bool TerrainRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameL
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
poolInfo.maxSets = MAX_MATERIAL_SETS;
poolInfo.poolSizeCount = 2;
poolInfo.pPoolSizes = poolSizes;
@ -1034,6 +1035,10 @@ void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) {
destroyBuffer(allocator, ab);
chunk.paramsUBO = VK_NULL_HANDLE;
}
// Return material descriptor set to the pool so it can be reused by new chunks
if (chunk.materialSet && materialDescPool) {
vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &chunk.materialSet);
}
chunk.materialSet = VK_NULL_HANDLE;
// Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly)

View file

@ -1051,14 +1051,21 @@ bool VkContext::recreateSwapchain(int width, int height) {
auto swapRet = builder.build();
if (oldSwapchain) {
vkDestroySwapchainKHR(device, oldSwapchain, nullptr);
if (!swapRet) {
// Destroy old swapchain now that we failed (it can't be used either)
if (oldSwapchain) {
vkDestroySwapchainKHR(device, oldSwapchain, nullptr);
swapchain = VK_NULL_HANDLE;
}
LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message());
// Keep swapchainDirty=true so the next frame retries
swapchainDirty = true;
return false;
}
if (!swapRet) {
LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message());
swapchain = VK_NULL_HANDLE;
return false;
// Success — safe to retire the old swapchain
if (oldSwapchain) {
vkDestroySwapchainKHR(device, oldSwapchain, nullptr);
}
auto vkbSwap = swapRet.value();
@ -1322,6 +1329,7 @@ bool VkContext::recreateSwapchain(int width, int height) {
VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) {
if (deviceLost_) return VK_NULL_HANDLE;
if (swapchain == VK_NULL_HANDLE) return VK_NULL_HANDLE; // Swapchain lost; recreate pending
auto& frame = frames[currentFrame];

View file

@ -124,6 +124,7 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
poolInfo.maxSets = MAX_MATERIAL_SETS;
poolInfo.poolSizeCount = 2;
poolInfo.pPoolSizes = poolSizes;
@ -1946,8 +1947,13 @@ void WMORenderer::destroyGroupGPU(GroupResources& group) {
group.indexAlloc = VK_NULL_HANDLE;
}
// Destroy material UBOs (descriptor sets are freed when pool is reset/destroyed)
// Destroy material UBOs and free descriptor sets back to pool
VkDevice device = vkCtx_->getDevice();
for (auto& mb : group.mergedBatches) {
if (mb.materialSet) {
vkFreeDescriptorSets(device, materialDescPool_, 1, &mb.materialSet);
mb.materialSet = VK_NULL_HANDLE;
}
if (mb.materialUBO) {
vmaDestroyBuffer(allocator, mb.materialUBO, mb.materialUBOAlloc);
mb.materialUBO = VK_NULL_HANDLE;

View file

@ -233,6 +233,10 @@ void WorldMap::setMapName(const std::string& name) {
void WorldMap::setServerExplorationMask(const std::vector<uint32_t>& masks, bool hasData) {
if (!hasData || masks.empty()) {
// New session or no data yet — reset both server mask and local accumulation
if (hasServerExplorationMask) {
locallyExploredZones_.clear();
}
hasServerExplorationMask = false;
serverExplorationMask.clear();
return;
@ -765,9 +769,12 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) {
}
if (markedAny) return;
// Server mask unavailable or empty — fall back to locally-accumulated position tracking.
// Add the zone the player is currently in to the local set and display that.
float wowX = playerRenderPos.y;
float wowY = playerRenderPos.x;
bool foundPos = false;
for (int i = 0; i < static_cast<int>(zones.size()); i++) {
const auto& z = zones[i];
if (z.areaID == 0) continue;
@ -775,15 +782,18 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) {
float minY = std::min(z.locTop, z.locBottom), maxY = std::max(z.locTop, z.locBottom);
if (maxX - minX < 0.001f || maxY - minY < 0.001f) continue;
if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) {
exploredZones.insert(i);
markedAny = true;
locallyExploredZones_.insert(i);
foundPos = true;
}
}
if (!markedAny) {
if (!foundPos) {
int zoneIdx = findZoneForPlayer(playerRenderPos);
if (zoneIdx >= 0) exploredZones.insert(zoneIdx);
if (zoneIdx >= 0) locallyExploredZones_.insert(zoneIdx);
}
// Display the accumulated local set
exploredZones = locallyExploredZones_;
}
void WorldMap::zoomIn(const glm::vec3& playerRenderPos) {

View file

@ -380,6 +380,11 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderTargetFrame(gameHandler);
}
// Focus target frame (only when we have a focus)
if (gameHandler.hasFocus()) {
renderFocusFrame(gameHandler);
}
// Render windows
if (showPlayerInfo) {
renderPlayerInfo(gameHandler);
@ -409,11 +414,14 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderDuelRequestPopup(gameHandler);
renderLootRollPopup(gameHandler);
renderTradeRequestPopup(gameHandler);
renderTradeWindow(gameHandler);
renderSummonRequestPopup(gameHandler);
renderSharedQuestPopup(gameHandler);
renderItemTextWindow(gameHandler);
renderGuildInvitePopup(gameHandler);
renderReadyCheckPopup(gameHandler);
renderBgInvitePopup(gameHandler);
renderLfgProposalPopup(gameHandler);
renderGuildRoster(gameHandler);
renderBuffBar(gameHandler);
renderLootWindow(gameHandler);
@ -1683,11 +1691,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
heightOffset = 0.3f;
}
} else if (t == game::ObjectType::GAMEOBJECT) {
// Do not hard-filter by GO type here. Some realms/content
// classify usable objects (including some chests) with types
// that look decorative in cache data.
hitRadius = 2.5f;
heightOffset = 1.2f;
// For GOs with no renderer instance yet, use a tight fallback
// sphere (not 2.5f) so invisible/unloaded GOs (chairs, doodads)
// are not accidentally clicked during camera right-drag.
hitRadius = 1.2f;
heightOffset = 1.0f;
}
hitCenter = core::coords::canonicalToRender(
glm::vec3(entity->getX(), entity->getY(), entity->getZ()));
@ -2458,6 +2466,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
if (totEntity->getType() == game::ObjectType::UNIT ||
totEntity->getType() == game::ObjectType::PLAYER) {
auto totUnit = std::static_pointer_cast<game::Unit>(totEntity);
if (totUnit->getLevel() > 0) {
ImGui::SameLine();
ImGui::TextDisabled("Lv%u", totUnit->getLevel());
}
uint32_t hp = totUnit->getHealth();
uint32_t maxHp = totUnit->getMaxHealth();
if (maxHp > 0) {
@ -2470,6 +2482,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
}
}
// Click to target the target-of-target
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) {
gameHandler.setTarget(totGuid);
}
}
ImGui::End();
ImGui::PopStyleColor(2);
@ -2479,6 +2495,134 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
}
}
void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) {
auto focus = gameHandler.getFocus();
if (!focus) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Position: right side of screen, mirroring the target frame on the opposite side
float frameW = 200.0f;
float frameX = screenW - frameW - 10.0f;
ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize;
// Determine color based on relation (same logic as target frame)
ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f);
if (focus->getType() == game::ObjectType::PLAYER) {
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} else if (focus->getType() == game::ObjectType::UNIT) {
auto u = std::static_pointer_cast<game::Unit>(focus);
if (u->getHealth() == 0 && u->getMaxHealth() > 0) {
focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f);
} else if (u->isHostile()) {
uint32_t playerLv = gameHandler.getPlayerLevel();
uint32_t mobLv = u->getLevel();
int32_t diff = static_cast<int32_t>(mobLv) - static_cast<int32_t>(playerLv);
if (game::GameHandler::killXp(playerLv, mobLv) == 0)
focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f);
else if (diff >= 10)
focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f);
else if (diff >= 5)
focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f);
else if (diff >= -2)
focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f);
else
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
} else {
focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
}
}
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus
if (ImGui::Begin("##FocusFrame", nullptr, flags)) {
// "Focus" label
ImGui::TextDisabled("[Focus]");
ImGui::SameLine();
std::string focusName = getEntityName(focus);
ImGui::TextColored(focusColor, "%s", focusName.c_str());
if (focus->getType() == game::ObjectType::UNIT ||
focus->getType() == game::ObjectType::PLAYER) {
auto unit = std::static_pointer_cast<game::Unit>(focus);
// Level + health on same row
ImGui::SameLine();
ImGui::TextDisabled("Lv %u", unit->getLevel());
uint32_t hp = unit->getHealth();
uint32_t maxHp = unit->getMaxHealth();
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) :
ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
char overlay[32];
snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay);
ImGui::PopStyleColor();
// Power bar
uint8_t pType = unit->getPowerType();
uint32_t pwr = unit->getPower();
uint32_t maxPwr = unit->getMaxPower();
if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100;
if (maxPwr > 0) {
float mpPct = static_cast<float>(pwr) / static_cast<float>(maxPwr);
ImVec4 pwrColor;
switch (pType) {
case 0: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
case 1: pwrColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break;
case 3: pwrColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break;
case 6: pwrColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break;
default: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break;
}
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor);
ImGui::ProgressBar(mpPct, ImVec2(-1, 10), "");
ImGui::PopStyleColor();
}
}
// Focus cast bar
const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid());
if (focusCast) {
float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f;
float rem = focusCast->timeRemaining;
float prog = std::clamp(1.0f - rem / total, 0.f, 1.f);
const std::string& spName = gameHandler.getSpellName(focusCast->spellId);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f));
char castBuf[64];
if (!spName.empty())
snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem);
else
snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem);
ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf);
ImGui::PopStyleColor();
}
}
// Clicking the focus frame targets it
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) {
gameHandler.setTarget(focus->getGuid());
}
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
if (strlen(chatInputBuffer) > 0) {
std::string input(chatInputBuffer);
@ -4867,7 +5011,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
if (toShow.empty()) return;
float x = screenW - TRACKER_W - RIGHT_MARGIN;
float y = 200.0f; // below minimap area
float y = 320.0f; // below minimap (210) + buff bar space (up to 3 rows ≈ 114px)
ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always);
@ -4905,10 +5049,15 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
} else {
// Kill counts
for (const auto& [entry, progress] : q.killCounts) {
std::string creatureName = gameHandler.getCachedCreatureName(entry);
if (!creatureName.empty()) {
std::string name = gameHandler.getCachedCreatureName(entry);
if (name.empty()) {
// May be a game object objective; fall back to GO name cache.
const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry);
if (goInfo && !goInfo->name.empty()) name = goInfo->name;
}
if (!name.empty()) {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
" %s: %u/%u", creatureName.c_str(),
" %s: %u/%u", name.c_str(),
progress.first, progress.second);
} else {
ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f),
@ -5024,7 +5173,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
: ImVec4(0.4f, 0.9f, 1.0f, alpha);
break;
case game::CombatTextEntry::BLOCK:
snprintf(text, sizeof(text), outgoing ? "Block" : "You Block");
if (entry.amount > 0)
snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount);
else
snprintf(text, sizeof(text), outgoing ? "Block" : "You Block");
color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha)
: ImVec4(0.4f, 0.9f, 1.0f, alpha);
break;
@ -5054,6 +5206,20 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
snprintf(text, sizeof(text), "Immune!");
color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune
break;
case game::CombatTextEntry::ABSORB:
if (entry.amount > 0)
snprintf(text, sizeof(text), "Absorbed %d", entry.amount);
else
snprintf(text, sizeof(text), "Absorbed");
color = ImVec4(0.5f, 0.8f, 1.0f, alpha); // Light blue for absorb
break;
case game::CombatTextEntry::RESIST:
if (entry.amount > 0)
snprintf(text, sizeof(text), "Resisted %d", entry.amount);
else
snprintf(text, sizeof(text), "Resisted");
color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist
break;
default:
snprintf(text, sizeof(text), "%d", entry.amount);
color = ImVec4(1.0f, 1.0f, 1.0f, alpha);
@ -5094,6 +5260,27 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
const uint64_t playerGuid = gameHandler.getPlayerGuid();
const uint64_t targetGuid = gameHandler.getTargetGuid();
// Build set of creature entries that are kill objectives in active (incomplete) quests.
std::unordered_set<uint32_t> questKillEntries;
{
const auto& questLog = gameHandler.getQuestLog();
const auto& trackedIds = gameHandler.getTrackedQuestIds();
for (const auto& q : questLog) {
if (q.complete || q.questId == 0) continue;
// Only highlight for tracked quests (or all if nothing tracked).
if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue;
for (const auto& obj : q.killObjectives) {
if (obj.npcOrGoId > 0 && obj.required > 0) {
// Check if not already completed.
auto it = q.killCounts.find(static_cast<uint32_t>(obj.npcOrGoId));
if (it == q.killCounts.end() || it->second.first < it->second.second) {
questKillEntries.insert(static_cast<uint32_t>(obj.npcOrGoId));
}
}
}
}
}
ImDrawList* drawList = ImGui::GetBackgroundDrawList();
for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) {
@ -5108,9 +5295,13 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
// Player nameplates are always shown; NPC nameplates respect the V-key toggle
if (!isPlayer && !showNameplates_) continue;
// Convert canonical WoW position → render space, raise to head height
glm::vec3 renderPos = core::coords::canonicalToRender(
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
// 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;
if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) {
renderPos = core::coords::canonicalToRender(
glm::vec3(unit->getX(), unit->getY(), unit->getZ()));
}
renderPos.z += 2.3f;
// Cull distance: target or other players up to 40 units; NPC others up to 20 units
@ -5215,6 +5406,14 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym);
drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym);
}
// Quest kill objective indicator: small yellow sword icon to the right of the name
if (!isPlayer && questKillEntries.count(unit->getEntry())) {
const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8)
float objX = nameX + textSize.x + 4.0f;
drawList->AddText(ImVec2(objX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym);
drawList->AddText(ImVec2(objX, nameY), IM_COL32(255, 220, 0, A(230)), objSym);
}
}
// Click to target: detect left-click inside the combined nameplate region
@ -5313,13 +5512,27 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
bool isDead = (m.onlineStatus & 0x0020) != 0;
bool isGhost = (m.onlineStatus & 0x0010) != 0;
// Name text (truncated)
// Name text (truncated); leader name is gold
char truncName[16];
snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str());
ImU32 nameCol = (!isOnline || isDead || isGhost)
? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255);
bool isMemberLeader = (m.guid == partyData.leaderGuid);
ImU32 nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) :
(!isOnline || isDead || isGhost)
? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255);
draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName);
// Leader crown star in top-right of cell
if (isMemberLeader)
draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*");
// LFG role badge in bottom-right corner of cell
if (m.roles & 0x02)
draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T");
else if (m.roles & 0x04)
draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H");
else if (m.roles & 0x08)
draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D");
// Health bar
uint32_t hp = m.hasPartyStats ? m.curHealth : 0;
uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0;
@ -5397,11 +5610,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f));
if (ImGui::Begin("##PartyFrames", nullptr, flags)) {
const uint64_t leaderGuid = partyData.leaderGuid;
for (const auto& member : partyData.members) {
ImGui::PushID(static_cast<int>(member.guid));
// Name with level and status info
std::string label = member.name;
bool isLeader = (member.guid == leaderGuid);
// Name with level and status info — leader gets a gold star prefix
std::string label = (isLeader ? "* " : " ") + member.name;
if (member.hasPartyStats && member.level > 0) {
label += " [" + std::to_string(member.level) + "]";
}
@ -5413,10 +5629,20 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
else if (isDead || isGhost) label += " (dead)";
}
// Clickable name to target
// Clickable name to target; leader name is gold
if (isLeader) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f));
if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) {
gameHandler.setTarget(member.guid);
}
if (isLeader) ImGui::PopStyleColor();
// LFG role badge (Tank/Healer/DPS) — shown on same line as name when set
if (member.roles != 0) {
ImGui::SameLine();
if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]");
if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); }
if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); }
}
// Health bar: prefer party stats, fall back to entity
uint32_t hp = 0, maxHp = 0;
@ -5475,6 +5701,32 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
}
// Right-click context menu for party member actions
if (ImGui::BeginPopupContextItem("PartyMemberCtx")) {
ImGui::TextDisabled("%s", member.name.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Target")) {
gameHandler.setTarget(member.guid);
}
if (ImGui::MenuItem("Set Focus")) {
gameHandler.setFocus(member.guid);
}
if (ImGui::MenuItem("Whisper")) {
selectedChatType = 4; // WHISPER
strncpy(whisperTargetBuffer, member.name.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
refocusChatInput = true;
}
if (ImGui::MenuItem("Trade")) {
gameHandler.initiateTrade(member.guid);
}
if (ImGui::MenuItem("Inspect")) {
gameHandler.setTarget(member.guid);
gameHandler.inspectTarget();
}
ImGui::EndPopup();
}
ImGui::Separator();
ImGui::PopID();
}
@ -5746,6 +5998,150 @@ void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) {
ImGui::End();
}
void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) {
if (!gameHandler.isTradeOpen()) return;
const auto& mySlots = gameHandler.getMyTradeSlots();
const auto& peerSlots = gameHandler.getPeerTradeSlots();
const uint64_t myGold = gameHandler.getMyTradeGold();
const uint64_t peerGold = gameHandler.getPeerTradeGold();
const auto& peerName = gameHandler.getTradePeerName();
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once);
bool open = true;
if (ImGui::Begin(("Trade with " + peerName).c_str(), &open,
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) {
auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) {
uint64_t g = copper / 10000;
uint64_t s = (copper % 10000) / 100;
uint64_t c = copper % 100;
if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc",
(unsigned long long)g, (unsigned long long)s, (unsigned long long)c);
else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc",
(unsigned long long)s, (unsigned long long)c);
else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c);
};
auto renderSlotColumn = [&](const char* label,
const std::array<game::GameHandler::TradeSlot,
game::GameHandler::TRADE_SLOT_COUNT>& slots,
uint64_t gold, bool isMine) {
ImGui::Text("%s", label);
ImGui::Separator();
for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) {
const auto& slot = slots[i];
ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100));
if (slot.occupied && slot.itemId != 0) {
const auto* info = gameHandler.getItemInfo(slot.itemId);
std::string name = (info && info->valid && !info->name.empty())
? info->name
: ("Item " + std::to_string(slot.itemId));
if (slot.stackCount > 1)
name += " x" + std::to_string(slot.stackCount);
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), " %d. %s", i + 1, name.c_str());
if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
gameHandler.clearTradeItem(static_cast<uint8_t>(i));
}
if (isMine && ImGui::IsItemHovered()) {
ImGui::SetTooltip("Double-click to remove");
}
} else {
ImGui::TextDisabled(" %d. (empty)", i + 1);
// Allow dragging inventory items into trade slots via right-click context menu
if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
ImGui::OpenPopup(("##additem" + std::to_string(i)).c_str());
}
}
if (isMine) {
// Drag-from-inventory: show small popup listing bag items
if (ImGui::BeginPopup(("##additem" + std::to_string(i)).c_str())) {
ImGui::TextDisabled("Add from inventory:");
const auto& inv = gameHandler.getInventory();
// Backpack slots 0-15 (bag=255)
for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) {
const auto& slot = inv.getBackpackSlot(si);
if (slot.empty()) continue;
const auto* ii = gameHandler.getItemInfo(slot.item.itemId);
std::string iname = (ii && ii->valid && !ii->name.empty())
? ii->name
: (!slot.item.name.empty() ? slot.item.name
: ("Item " + std::to_string(slot.item.itemId)));
if (ImGui::Selectable(iname.c_str())) {
// bag=255 = main backpack
gameHandler.setTradeItem(static_cast<uint8_t>(i), 255u,
static_cast<uint8_t>(si));
ImGui::CloseCurrentPopup();
}
}
ImGui::EndPopup();
}
}
ImGui::PopID();
}
// Gold row
char gbuf[48];
formatGold(gold, gbuf, sizeof(gbuf));
ImGui::Spacing();
if (isMine) {
ImGui::Text("Gold offered: %s", gbuf);
static char goldInput[32] = "0";
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput),
ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) {
uint64_t copper = std::strtoull(goldInput, nullptr, 10);
gameHandler.setTradeGold(copper);
}
ImGui::SameLine();
ImGui::TextDisabled("(copper, Enter to set)");
} else {
ImGui::Text("Gold offered: %s", gbuf);
}
};
// Two-column layout: my offer | peer offer
float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f;
ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true);
renderSlotColumn("Your offer", mySlots, myGold, true);
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true);
renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false);
ImGui::EndChild();
// Buttons
ImGui::Spacing();
ImGui::Separator();
float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) {
gameHandler.acceptTrade();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(bw, 0))) {
gameHandler.cancelTrade();
}
}
ImGui::End();
if (!open) {
gameHandler.cancelTrade();
}
}
void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingLootRoll()) return;
@ -5771,7 +6167,19 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) {
ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1];
ImGui::Text("An item is up for rolls:");
// Show item icon if available
const auto* rollInfo = gameHandler.getItemInfo(roll.itemId);
uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0;
VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE;
if (rollIcon) {
ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24));
ImGui::SameLine();
}
ImGui::TextColored(col, "[%s]", roll.itemName.c_str());
if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) {
inventoryScreen.renderItemTooltip(*rollInfo);
}
ImGui::Spacing();
if (ImGui::Button("Need", ImVec2(80, 30))) {
@ -5851,6 +6259,148 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) {
ImGui::End();
}
void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) {
if (!gameHandler.hasPendingBgInvite()) return;
const auto& queues = gameHandler.getBgQueues();
// Find the first WAIT_JOIN slot
const game::GameHandler::BgQueueSlot* slot = nullptr;
for (const auto& s : queues) {
if (s.statusId == 2) { slot = &s; break; }
}
if (!slot) return;
// Compute time remaining
auto now = std::chrono::steady_clock::now();
double elapsed = std::chrono::duration<double>(now - slot->inviteReceivedTime).count();
double remaining = static_cast<double>(slot->inviteTimeout) - elapsed;
// If invite has expired, clear it silently (server will handle the queue)
if (remaining <= 0.0) {
gameHandler.declineBattlefield(slot->queueSlot);
return;
}
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 70), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
const ImGuiWindowFlags popupFlags =
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) {
// BG name
std::string bgName;
if (slot->arenaType > 0) {
bgName = std::to_string(slot->arenaType) + "v" + std::to_string(slot->arenaType) + " Arena";
} else {
switch (slot->bgTypeId) {
case 1: bgName = "Alterac Valley"; break;
case 2: bgName = "Warsong Gulch"; break;
case 3: bgName = "Arathi Basin"; break;
case 7: bgName = "Eye of the Storm"; break;
case 9: bgName = "Strand of the Ancients"; break;
case 11: bgName = "Isle of Conquest"; break;
default: bgName = "Battleground"; break;
}
}
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str());
ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast<int>(remaining));
ImGui::Spacing();
// Countdown progress bar
float frac = static_cast<float>(remaining / static_cast<double>(slot->inviteTimeout));
frac = std::clamp(frac, 0.0f, 1.0f);
ImVec4 barColor = frac > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f)
: frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f)
: ImVec4(0.9f, 0.2f, 0.2f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor);
char countdownLabel[32];
snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast<int>(remaining));
ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel);
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) {
gameHandler.acceptBattlefield(slot->queueSlot);
}
ImGui::PopStyleColor(2);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
if (ImGui::Button("Leave Queue", ImVec2(175, 30))) {
gameHandler.declineBattlefield(slot->queueSlot);
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) {
using LfgState = game::GameHandler::LfgState;
if (gameHandler.getLfgState() != LfgState::Proposal) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
const ImGuiWindowFlags flags =
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
if (ImGui::Begin("Dungeon Finder", nullptr, flags)) {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "A group has been found!");
ImGui::Spacing();
ImGui::TextWrapped("Please accept or decline to join the dungeon.");
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true);
}
ImGui::PopStyleColor(2);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) {
gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false);
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
// O key toggle (WoW default Social/Guild keybind)
if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) {
@ -6298,12 +6848,15 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) {
auto* assetMgr = core::Application::getInstance().getAssetManager();
// Position below the player frame in top-left
// Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210)
// Anchored to the right side to stay away from party frames on the left
constexpr float ICON_SIZE = 32.0f;
constexpr int ICONS_PER_ROW = 8;
float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f;
// Dock under player frame in top-left (player frame is at 10, 30 with ~110px height)
ImGui::SetNextWindowPos(ImVec2(10.0f, 145.0f), ImGuiCond_Always);
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f;
// Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210)
ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
@ -6314,16 +6867,22 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) {
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f));
if (ImGui::Begin("##BuffBar", nullptr, flags)) {
int shown = 0;
for (size_t i = 0; i < auras.size() && shown < 16; ++i) {
// Separate buffs and debuffs; show buffs first, then debuffs with a visual gap
// Render one pass for buffs, one for debuffs
for (int pass = 0; pass < 2; ++pass) {
bool wantBuff = (pass == 0);
int shown = 0;
for (size_t i = 0; i < auras.size() && shown < 40; ++i) {
const auto& aura = auras[i];
if (aura.isEmpty()) continue;
bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag
if (isBuff != wantBuff) continue; // only render matching pass
if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine();
ImGui::PushID(static_cast<int>(i));
ImGui::PushID(static_cast<int>(i) + (pass * 256));
bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag
ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f);
// Try to get spell icon
@ -6418,10 +6977,14 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) {
ImGui::PopID();
shown++;
}
} // end aura loop
// Add visual gap between buffs and debuffs
if (pass == 0 && shown > 0) ImGui::Spacing();
} // end pass loop
// Dismiss Pet button
if (gameHandler.hasPet()) {
if (shown > 0) ImGui::Spacing();
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f));
if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) {
@ -6495,6 +7058,13 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) {
}
bool hovered = ImGui::IsItemHovered();
// Show item tooltip on hover
if (hovered && info && info->valid) {
inventoryScreen.renderItemTooltip(*info);
} else if (hovered && !itemName.empty() && itemName[0] != 'I') {
ImGui::SetTooltip("%s", itemName.c_str());
}
ImDrawList* drawList = ImGui::GetWindowDrawList();
// Draw hover highlight
@ -6977,15 +7547,16 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) {
return {iconTex, col};
};
// Helper: show item tooltip
auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 nameCol) {
// Helper: show full item tooltip (reuses InventoryScreen's rich tooltip)
auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 /*nameCol*/) {
auto* info = gameHandler.getItemInfo(ri.itemId);
if (!info || !info->valid) return;
ImGui::BeginTooltip();
ImGui::TextColored(nameCol, "%s", info->name.c_str());
if (!info->description.empty())
ImGui::TextWrapped("%s", info->description.c_str());
ImGui::EndTooltip();
if (!info || !info->valid) {
ImGui::BeginTooltip();
ImGui::TextDisabled("Loading item data...");
ImGui::EndTooltip();
return;
}
inventoryScreen.renderItemTooltip(*info);
};
if (!quest.choiceRewards.empty()) {
@ -7218,24 +7789,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
ImGui::TextColored(qualityColors[q], "%s", info->name.c_str());
// Tooltip with stats on hover
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::TextColored(qualityColors[q], "%s", info->name.c_str());
if (info->damageMax > 0.0f) {
ImGui::Text("%.0f - %.0f Damage", info->damageMin, info->damageMax);
if (info->delayMs > 0) {
float speed = static_cast<float>(info->delayMs) / 1000.0f;
float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed;
ImGui::Text("Speed %.2f", speed);
ImGui::Text("%.1f damage per second", dps);
}
}
if (info->armor > 0) ImGui::Text("Armor: %d", info->armor);
if (info->stamina > 0) ImGui::Text("+%d Stamina", info->stamina);
if (info->strength > 0) ImGui::Text("+%d Strength", info->strength);
if (info->agility > 0) ImGui::Text("+%d Agility", info->agility);
if (info->intellect > 0) ImGui::Text("+%d Intellect", info->intellect);
if (info->spirit > 0) ImGui::Text("+%d Spirit", info->spirit);
ImGui::EndTooltip();
inventoryScreen.renderItemTooltip(*info);
}
} else {
ImGui::Text("Item %u", item.itemId);
@ -8869,10 +9423,13 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
float dx = worldRenderPos.x - playerRender.x;
float dy = worldRenderPos.y - playerRender.y;
// Match minimap shader transform exactly.
// Render axes: +X=west, +Y=north. Minimap screen axes: +X=right(east), +Y=down(south).
float rx = -dx * cosB + dy * sinB;
float ry = -dx * sinB - dy * cosB;
// Exact inverse of minimap display shader:
// shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2
// where rotated = R(bearing) * center, center in [-0.5, 0.5]
// Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2)
// With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south):
float rx = -(dx * cosB + dy * sinB);
float ry = dx * sinB - dy * cosB;
// Scale to minimap pixels
float px = rx / viewRadius * mapRadius;
@ -9135,21 +9692,73 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
}
ImGui::End();
// "New Mail" indicator below the minimap
// Indicators below the minimap (stacked: new mail, then BG queue, then latency)
float indicatorX = centerX - mapRadius;
float nextIndicatorY = centerY + mapRadius + 4.0f;
const float indicatorW = mapRadius * 2.0f;
constexpr float kIndicatorH = 22.0f;
ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs;
// "New Mail" indicator
if (gameHandler.hasNewMail()) {
float indicatorX = centerX - mapRadius;
float indicatorY = centerY + mapRadius + 4.0f;
ImGui::SetNextWindowPos(ImVec2(indicatorX, indicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(mapRadius * 2.0f, 22), ImGuiCond_Always);
ImGuiWindowFlags mailFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs;
if (ImGui::Begin("##NewMailIndicator", nullptr, mailFlags)) {
// Pulsing effect
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) {
float pulse = 0.7f + 0.3f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!");
}
ImGui::End();
nextIndicatorY += kIndicatorH;
}
// BG queue status indicator (when in queue but not yet invited)
for (const auto& slot : gameHandler.getBgQueues()) {
if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only
std::string bgName;
if (slot.arenaType > 0) {
bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena";
} else {
switch (slot.bgTypeId) {
case 1: bgName = "AV"; break;
case 2: bgName = "WSG"; break;
case 3: bgName = "AB"; break;
case 7: bgName = "EotS"; break;
case 9: bgName = "SotA"; break;
case 11: bgName = "IoC"; break;
default: bgName = "BG"; break;
}
}
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) {
float pulse = 0.6f + 0.4f * std::sin(static_cast<float>(ImGui::GetTime()) * 1.5f);
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse),
"In Queue: %s", bgName.c_str());
}
ImGui::End();
nextIndicatorY += kIndicatorH;
break; // Show at most one queue slot indicator
}
// Latency indicator (shown when in world and last latency is known)
uint32_t latMs = gameHandler.getLatencyMs();
if (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
else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.8f); // Orange < 500ms
else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.8f); // Red >= 500ms
ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always);
if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) {
ImGui::TextColored(latColor, "%u ms", latMs);
}
ImGui::End();
}
}
@ -10795,8 +11404,9 @@ void GameScreen::renderDingEffect() {
IM_COL32(255, 210, 0, (int)(alpha * 255)), buf);
}
void GameScreen::triggerAchievementToast(uint32_t achievementId) {
void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) {
achievementToastId_ = achievementId;
achievementToastName_ = std::move(name);
achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION;
// Play a UI sound if available
@ -10855,9 +11465,15 @@ void GameScreen::renderAchievementToast() {
draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8),
IM_COL32(255, 215, 0, (int)(alpha * 255)), title);
// Achievement ID line (until we have Achievement.dbc name lookup)
char idBuf[64];
std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_);
// Achievement name (falls back to ID if name not available)
char idBuf[256];
const char* achText = achievementToastName_.empty()
? nullptr : achievementToastName_.c_str();
if (achText) {
std::snprintf(idBuf, sizeof(idBuf), "%s", achText);
} else {
std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_);
}
float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x;
float idX = toastX + (TOAST_W - idW) * 0.5f;
draw->AddText(font, bodySize, ImVec2(idX, toastY + 28),

View file

@ -1086,7 +1086,10 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
if (ImGui::BeginTabItem("Stats")) {
ImGui::Spacing();
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating());
int32_t stats[5];
for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i);
const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr;
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats);
ImGui::EndTabItem();
}
@ -1376,18 +1379,18 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
// Stats Panel
// ============================================================
void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor) {
// Sum equipment stats
int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0;
void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel,
int32_t serverArmor, const int32_t* serverStats) {
// Sum equipment stats for item-query bonus display
int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
if (slot.empty()) continue;
totalStr += slot.item.strength;
totalAgi += slot.item.agility;
totalSta += slot.item.stamina;
totalInt += slot.item.intellect;
totalSpi += slot.item.spirit;
itemStr += slot.item.strength;
itemAgi += slot.item.agility;
itemSta += slot.item.stamina;
itemInt += slot.item.intellect;
itemSpi += slot.item.spirit;
}
// Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available.
@ -1399,9 +1402,6 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
}
int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor;
// Base stats: 20 + level
int32_t baseStat = 20 + static_cast<int32_t>(playerLevel);
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f);
ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f);
@ -1414,23 +1414,41 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
ImGui::TextColored(gray, "Armor: 0");
}
// Helper to render a stat line
auto renderStat = [&](const char* name, int32_t equipBonus) {
int32_t total = baseStat + equipBonus;
if (equipBonus > 0) {
ImGui::TextColored(white, "%s: %d", name, total);
ImGui::SameLine();
ImGui::TextColored(green, "(+%d)", equipBonus);
} else {
ImGui::TextColored(gray, "%s: %d", name, total);
if (serverStats) {
// Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus.
// serverStats[i] is the server's effective base stat (items included, buffs excluded).
const char* statNames[5] = {"Strength", "Agility", "Stamina", "Intellect", "Spirit"};
const int32_t itemBonuses[5] = {itemStr, itemAgi, itemSta, itemInt, itemSpi};
for (int i = 0; i < 5; ++i) {
int32_t total = serverStats[i];
int32_t bonus = itemBonuses[i];
if (bonus > 0) {
ImGui::TextColored(white, "%s: %d", statNames[i], total);
ImGui::SameLine();
ImGui::TextColored(green, "(+%d)", bonus);
} else {
ImGui::TextColored(gray, "%s: %d", statNames[i], total);
}
}
};
renderStat("Strength", totalStr);
renderStat("Agility", totalAgi);
renderStat("Stamina", totalSta);
renderStat("Intellect", totalInt);
renderStat("Spirit", totalSpi);
} else {
// Fallback: estimated base (20 + level) plus item query bonuses.
int32_t baseStat = 20 + static_cast<int32_t>(playerLevel);
auto renderStat = [&](const char* name, int32_t equipBonus) {
int32_t total = baseStat + equipBonus;
if (equipBonus > 0) {
ImGui::TextColored(white, "%s: %d", name, total);
ImGui::SameLine();
ImGui::TextColored(green, "(+%d)", equipBonus);
} else {
ImGui::TextColored(gray, "%s: %d", name, total);
}
};
renderStat("Strength", itemStr);
renderStat("Agility", itemAgi);
renderStat("Stamina", itemSta);
renderStat("Intellect", itemInt);
renderStat("Spirit", itemSpi);
}
}
void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {
@ -1704,7 +1722,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
if (ImGui::IsItemHovered() && !holdingItem) {
renderItemTooltip(item, &inventory);
// Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise
const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory;
renderItemTooltip(item, tooltipInv);
}
}
}
@ -1880,7 +1900,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
if (item.requiredLevel > 1) {
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel);
uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0;
bool meetsReq = (playerLvl >= item.requiredLevel);
ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f);
ImGui::TextColored(reqColor, "Requires Level %u", item.requiredLevel);
}
if (item.maxDurability > 0) {
float durPct = static_cast<float>(item.curDurability) / static_cast<float>(item.maxDurability);
@ -1947,6 +1970,22 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str());
// Item level comparison (always shown when different)
if (eq->item.itemLevel > 0 || item.itemLevel > 0) {
char ilvlBuf[64];
float diff = static_cast<float>(item.itemLevel) - static_cast<float>(eq->item.itemLevel);
if (diff > 0.0f)
std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", item.itemLevel, diff);
else if (diff < 0.0f)
std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", item.itemLevel, -diff);
else
std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", item.itemLevel);
ImVec4 ilvlColor = (diff > 0.0f) ? ImVec4(0.0f, 1.0f, 0.0f, 1.0f)
: (diff < 0.0f) ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f)
: ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
ImGui::TextColored(ilvlColor, "%s", ilvlBuf);
}
// Helper: render a numeric stat diff line
auto showDiff = [](const char* label, float newVal, float eqVal) {
if (newVal == 0.0f && eqVal == 0.0f) return;
@ -1959,7 +1998,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, newVal, -diff);
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", buf);
} else {
std::snprintf(buf, sizeof(buf), "%s: %.0f", label, newVal);
std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, newVal);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf);
}
};
@ -2027,5 +2066,170 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
ImGui::EndTooltip();
}
// ---------------------------------------------------------------------------
// Tooltip overload for ItemQueryResponseData (used by loot window, etc.)
// ---------------------------------------------------------------------------
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) {
ImGui::BeginTooltip();
ImVec4 qColor = getQualityColor(static_cast<game::ItemQuality>(info.quality));
ImGui::TextColored(qColor, "%s", info.name.c_str());
if (info.itemLevel > 0) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel);
}
// Binding type
switch (info.bindType) {
case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break;
case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break;
case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break;
case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break;
default: break;
}
// Slot / subclass
if (info.inventoryType > 0) {
const char* slotName = "";
switch (info.inventoryType) {
case 1: slotName = "Head"; break;
case 2: slotName = "Neck"; break;
case 3: slotName = "Shoulder"; break;
case 4: slotName = "Shirt"; break;
case 5: slotName = "Chest"; break;
case 6: slotName = "Waist"; break;
case 7: slotName = "Legs"; break;
case 8: slotName = "Feet"; break;
case 9: slotName = "Wrist"; break;
case 10: slotName = "Hands"; break;
case 11: slotName = "Finger"; break;
case 12: slotName = "Trinket"; break;
case 13: slotName = "One-Hand"; break;
case 14: slotName = "Shield"; break;
case 15: slotName = "Ranged"; break;
case 16: slotName = "Back"; break;
case 17: slotName = "Two-Hand"; break;
case 18: slotName = "Bag"; break;
case 19: slotName = "Tabard"; break;
case 20: slotName = "Robe"; break;
case 21: slotName = "Main Hand"; break;
case 22: slotName = "Off Hand"; break;
case 23: slotName = "Held In Off-hand"; break;
case 25: slotName = "Thrown"; break;
case 26: slotName = "Ranged"; break;
default: break;
}
if (slotName[0]) {
if (!info.subclassName.empty())
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info.subclassName.c_str());
else
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName);
}
}
// Weapon stats
auto isWeaponInvType = [](uint32_t t) {
return t == 13 || t == 15 || t == 17 || t == 21 || t == 25 || t == 26;
};
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
if (isWeaponInvType(info.inventoryType) && info.damageMax > 0.0f && info.delayMs > 0) {
float speed = static_cast<float>(info.delayMs) / 1000.0f;
float dps = ((info.damageMin + info.damageMax) * 0.5f) / speed;
ImGui::Text("%.0f - %.0f Damage", info.damageMin, info.damageMax);
ImGui::SameLine(160.0f);
ImGui::TextDisabled("Speed %.2f", speed);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps);
}
if (info.armor > 0) ImGui::Text("%d Armor", info.armor);
auto appendBonus = [](std::string& out, int32_t val, const char* name) {
if (val <= 0) return;
if (!out.empty()) out += " ";
out += "+" + std::to_string(val) + " " + name;
};
std::string bonusLine;
appendBonus(bonusLine, info.strength, "Str");
appendBonus(bonusLine, info.agility, "Agi");
appendBonus(bonusLine, info.stamina, "Sta");
appendBonus(bonusLine, info.intellect, "Int");
appendBonus(bonusLine, info.spirit, "Spi");
if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str());
// Extra stats
for (const auto& es : info.extraStats) {
const char* statName = nullptr;
switch (es.statType) {
case 12: statName = "Defense Rating"; break;
case 13: statName = "Dodge Rating"; break;
case 14: statName = "Parry Rating"; break;
case 16: case 17: case 18: case 31: statName = "Hit Rating"; break;
case 19: case 20: case 21: case 32: statName = "Crit Rating"; break;
case 28: case 29: case 30: case 36: statName = "Haste Rating"; break;
case 35: statName = "Resilience"; break;
case 37: statName = "Expertise Rating"; break;
case 38: statName = "Attack Power"; break;
case 39: statName = "Ranged Attack Power"; break;
case 41: statName = "Healing Power"; break;
case 42: statName = "Spell Damage"; break;
case 43: statName = "Mana per 5 sec"; break;
case 44: statName = "Armor Penetration"; break;
case 45: statName = "Spell Power"; break;
case 46: statName = "Health per 5 sec"; break;
case 47: statName = "Spell Penetration"; break;
case 48: statName = "Block Value"; break;
default: statName = nullptr; break;
}
char buf[64];
if (statName)
std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName);
else
std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType);
ImGui::TextColored(green, "%s", buf);
}
if (info.requiredLevel > 1) {
uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0;
bool meetsReq = (playerLvl >= info.requiredLevel);
ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f);
ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel);
}
// Spell effects
for (const auto& sp : info.spells) {
if (sp.spellId == 0) continue;
const char* trigger = nullptr;
switch (sp.spellTrigger) {
case 0: trigger = "Use"; break;
case 1: trigger = "Equip"; break;
case 2: trigger = "Chance on Hit"; break;
default: break;
}
if (!trigger) continue;
if (gameHandler_) {
const std::string& spName = gameHandler_->getSpellName(sp.spellId);
if (!spName.empty())
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str());
else
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId);
}
}
if (info.startQuestId != 0) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest");
}
if (!info.description.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", info.description.c_str());
}
if (info.sellPrice > 0) {
uint32_t g = info.sellPrice / 10000;
uint32_t s = (info.sellPrice / 100) % 100;
uint32_t c = info.sellPrice % 100;
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
}
ImGui::EndTooltip();
}
} // namespace ui
} // namespace wowee

View file

@ -379,14 +379,24 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
ImGui::Separator();
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Tracked Progress");
for (const auto& [entry, progress] : sel.killCounts) {
ImGui::BulletText("Kill %u: %u/%u", entry, progress.first, progress.second);
std::string name = gameHandler.getCachedCreatureName(entry);
if (name.empty()) {
// Game object objective: fall back to GO name cache.
const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry);
if (goInfo && !goInfo->name.empty()) name = goInfo->name;
}
if (name.empty()) name = "Unknown (" + std::to_string(entry) + ")";
ImGui::BulletText("%s: %u/%u", name.c_str(), progress.first, progress.second);
}
for (const auto& [itemId, count] : sel.itemCounts) {
std::string itemLabel = "Item " + std::to_string(itemId);
if (const auto* info = gameHandler.getItemInfo(itemId)) {
if (!info->name.empty()) itemLabel = info->name;
}
ImGui::BulletText("%s: %u", itemLabel.c_str(), count);
uint32_t required = 1;
auto reqIt = sel.requiredItemCounts.find(itemId);
if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second;
ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required);
}
}

View file

@ -189,7 +189,7 @@ bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler
if (!dbcLoadAttempted) loadSpellDBC(assetManager);
const SpellInfo* info = getSpellInfo(spellId);
if (!info) return false;
renderSpellTooltip(info, gameHandler);
renderSpellTooltip(info, gameHandler, /*showUsageHints=*/false);
return true;
}
@ -446,7 +446,7 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const {
return (it != spellData.end()) ? &it->second : nullptr;
}
void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) {
void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(320.0f);
@ -551,8 +551,8 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle
ImGui::TextWrapped("%s", info->description.c_str());
}
// Usage hints
if (!info->isPassive()) {
// Usage hints — only shown when browsing the spellbook, not on action bar hover
if (!info->isPassive() && showUsageHints) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar");
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast");