Compare commits

...

50 commits

Author SHA1 Message Date
Kelsi
8f08d75748 fix: cache player death position so corpse reclaim works in Classic
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
Classic 1.12 does not send SMSG_DEATH_RELEASE_LOC, leaving corpseMapId_=0
and preventing the 'Resurrect from Corpse' button from appearing.

- When health reaches 0 via VALUES update, immediately cache movementInfo
  as corpse position (canonical->server axis swap applied correctly)
- Do the same on UNIT_DYNFLAG_DEAD set path
- Clear corpseMapId_ when ghost flag is removed (corpse reclaimed)
- Clear corpseMapId_ in same-map spirit-healer resurrection path

The CORPSE object detection (UPDATE_OBJECT) and SMSG_DEATH_RELEASE_LOC
(TBC/WotLK) will still override with exact server coordinates when received.
2026-03-13 04:04:38 -07:00
Kelsi
499638142e feat: make quest tracker movable, resizable, and right-edge-anchored
- Remove NoDecoration flag to allow ImGui drag/resize
- Store questTrackerRightOffset_ instead of absolute X so tracker
  stays pinned to the right edge when the window is resized
- Persist position (right offset + Y) and size in settings.cfg
- Clamp to screen bounds after drag
2026-03-13 04:04:29 -07:00
Kelsi
85767187b1 fix: clear gameObjectDisplayIdWmoCache_ on world transition, add stale-entry guard
gameObjectDisplayIdWmoCache_ was not cleared on world unload/transition,
causing stale WMO model IDs (e.g. 40006, 40003) to be looked up after
the renderer cleared its model list, resulting in "Cannot create instance
of unloaded WMO model" errors on zone re-entry.

Changes:
- Clear gameObjectDisplayIdWmoCache_ alongside other GO caches on world reset
- Add WMORenderer::isModelLoaded() for cache-hit validation
- Inline GO WMO path now verifies cached model is still renderer-resident
  before using it; evicts stale entries and falls back to reload
2026-03-13 03:43:55 -07:00
Kelsi
0487d2eda6 fix: check loadModel return before createInstance for WMO doodads
When the M2 model cache is full (>6000 entries), loadModel() returns
false and the model is never added to the GPU cache. The WMO instance
doodad path was calling createInstanceWithMatrix() unconditionally,
generating hundreds of "Cannot create instance: model X not loaded"
warnings on zone entry. Add the same guard already present in the
terrain doodad path.
2026-03-13 03:41:42 -07:00
Kelsi
863faf9b54 fix: correct talent rank indexing — store 1-indexed, fix prereq and learn checks
SMSG_TALENTS_INFO wire format sends 0-indexed ranks (0=has rank 1). Both
handlers were storing raw 0-indexed values, but handleSpellLearnedServer
correctly stored rank+1 (1-indexed). This caused:
 - getTalentRank() returning 0 for both "not learned" and "has rank 1",
   making pointsInTree always wrong and blocking tier access
 - Prereq check `prereqRank < DBC_prereqRank` always met when not learned
   (0 < 0 = false), incorrectly unlocking talents
 - Click handler sending wrong desiredRank to server

Fixes:
 - Both SMSG_TALENTS_INFO handlers: store rank+1u (1-indexed)
 - talent_screen.cpp prereq check: change < to <= (DBC is 0-indexed,
   storage is 1-indexed; must use > for "met", <= for "not met")
 - talent_screen.cpp click handler: send currentRank directly (1-indexed
   value equals what CMSG_LEARN_TALENT requestedRank expects)
 - Tooltip: display prereqRank+1 so "Requires 1 point" shows correctly
2026-03-13 03:32:45 -07:00
Kelsi
952f36b732 fix: correct minimap player arrow orientation (was 90° off, E/W appeared flipped) 2026-03-13 03:19:05 -07:00
Kelsi
ebd9cf5542 fix: handle MSG_MOVE_SET_*_SPEED opcodes to suppress unhandled opcode warnings 2026-03-13 03:13:29 -07:00
Kelsi
64439673ce fix: show repair button when vendor has NPC_FLAG_REPAIR (0x40) set
Vendors that open directly (without gossip menu) never triggered the
armorer gossip path, so canRepair was always false and the Repair button
was hidden. Now also check the NPC's unit flags for NPC_FLAG_REPAIR when
the vendor list arrives, fixing armorers accessed directly.
2026-03-13 03:06:45 -07:00
Kelsi
8f3f1b21af fix: check vertices before skin load so WotLK (v264) character M2s parse correctly
TBC races like Draenei use version-264 M2 files with no embedded skin;
indices come from a separate .skin file loaded after M2::load().
The premature isValid() check (which requires non-empty indices) always
failed for WotLK-format character models, making Draenei (and Blood Elf)
players invisible.

Fix: only check vertices.empty() right after load(), then validate fully
with isValid() after the skin file is loaded.
2026-03-13 02:58:42 -07:00
Kelsi
27213c1d40 fix: robust SMSG_ATTACKERSTATEUPDATE parsing for WotLK format
Two issues in the WotLK SMSG_ATTACKERSTATEUPDATE parser:

1. subDamageCount could read a school-mask byte when a packed GUID is
   off by one byte, producing values like 32/40/44/48 (shadow/frost/etc
   school masks) as the count. The parser then tried to read 32-48
   sub-damages before hitting EOF. Fix: silently clamp subDamageCount to
   floor(remaining/20) so we only attempt entries that actually fit.

2. After sub-damages, AzerothCore sends victimState(4)+unk1(4)+unk2(4)+
   overkill(4) (16 bytes), not the 8-byte victimState+overkill the
   parser was reading. Fix: consume unk1 and unk2 before reading overkill.
   Also handle the hitInfo-conditional HITINFO_BLOCK/RAGE_GAIN/FAKE_DAMAGE
   fields at the end of the packet.
2026-03-13 02:47:40 -07:00
Kelsi
1cd8e53b2f fix: handle SPLINEFLAG_ANIMATION in UPDATE_OBJECT legacy spline layout
When SPLINEFLAG_ANIMATION (0x00400000) is set, AzerothCore inserts 5 bytes
(uint8 animationType + int32 animTime) between durationModNext and
verticalAccel in the SMSG_UPDATE_OBJECT MoveSpline block. The parser was
not accounting for these bytes, causing verticalAccel, effectStartTime,
and pointCount to be read from the wrong offset.

This produced garbage pointCount values (e.g. 3322451254) triggering the
"Spline pointCount invalid (legacy+compact)" fallback path and breaking
UPDATE_OBJECT parsing for animated-spline entities, causing all subsequent
update blocks in the same packet to be dropped.
2026-03-13 02:38:53 -07:00
Kelsi
61adb4a803 fix: free terrain descriptor sets when unloading mid-finalization tiles
When unloadTile() was called for a tile still in finalizingTiles_
(mid-incremental-finalization), terrain chunks already uploaded to the
GPU (terrainMeshDone=true) were not being cleaned up. The early-return
path correctly removed water and M2/WMO instances but missed calling
terrainRenderer->removeTile(), causing descriptor sets to leak.

After ~20 minutes of play the VkDescriptorPool (MAX_MATERIAL_SETS=16384)
filled up, causing all subsequent terrain material allocations to fail
and the log to flood with "failed to allocate material descriptor set".

Fix: check fit->terrainMeshDone before the early return and call
terrainRenderer->removeTile() to free those descriptor sets.
2026-03-13 02:33:02 -07:00
Kelsi
862d743f87 fix: WMO culling dead-end group fallback and minimap arrow direction
wmo_renderer: when portal BFS starts from a group with no portal refs
(utility/transition group), the rest of the WMO becomes invisible because
BFS only adds the starting group. Fix: if cameraGroup has portalCount==0,
fall back to marking all groups visible (same as camera-outside behavior).

renderer: minimap player-orientation arrow was pointing the wrong direction.
The shader convention is arrowRotation=0→North, positive→clockwise (West),
negative→East. The correct mapping from canonical yaw is arrowRotation =
-canonical_yaw. Fixed both render paths (cameraController and gameHandler)
in both the FXAA and non-FXAA minimap render calls.

World-map W-key: already corrected in prior commit (13c096f); users with
stale ~/.wowee/settings.cfg should rebind via Settings > Controls.
2026-03-13 02:25:06 -07:00
Kelsi
d4bf8c871e fix: clear gameObjectDisplayIdFailedCache_ on world reset and zone change
The failed-model cache introduced in f855327 would persist across map
changes, permanently suppressing models that failed on one map but might
be valid assets on another (or after a client update). Clear it in the
world reset path alongside the existing gameObjectDisplayIdModelCache_
clear, so model loads get a fresh attempt on each zone change.
2026-03-13 01:53:59 -07:00
Kelsi
d58c55ce8d fix: allow ribbon-only M2 models to load and silence transport doodad load errors
Two follow-up fixes for the ribbon emitter implementation and the
transport-doodad stall fix:

1. loadModel() rejected any M2 with no vertices AND no particles, but
   ribbon-only spell-effect models (e.g. weapon trail or aura ribbons)
   have neither. These models were silently invisible even though the
   ribbon rendering pipeline added in 1108aa9 is fully capable of
   rendering them. Extended the guard to also accept models that have
   ribbon emitters, matching the particle-emitter precedent.

2. processPendingTransportDoodads() ignored the bool return of
   loadModel(), calling createInstance() even when the model was
   rejected, generating spurious "Cannot create instance: model X not
   loaded" warnings for every failed doodad path. Check the return
   value and continue to the next doodad on failure.
2026-03-13 01:49:22 -07:00
Kelsi
f855327054 fix: eliminate 490ms transport-doodad stall and GPU device-loss crash
Three root causes identified from wowee.log crash at frame 134368:

1. processPendingTransportDoodads() was doing N separate synchronous
   GPU uploads (vkQueueSubmit + vkWaitForFences per texture per doodad).
   With 30+ doodads × multiple textures, this caused the 489ms stall in
   the 'gameobject/transport queues' update stage. Fixed by wrapping the
   entire batch in beginUploadBatch()/endUploadBatch() so all texture
   layout transitions are submitted in a single async command buffer.

2. Game objects whose M2 model has no geometry/particles (empty or
   unsupported format) were retried every frame because loadModel()
   returns false without adding to gameObjectDisplayIdModelCache_.
   Added gameObjectDisplayIdFailedCache_ to permanently skip these
   display IDs after the first failure, stopping the per-frame spam.

3. renderM2Ribbons() only checked ribbonPipeline_ != null, not
   ribbonAdditivePipeline_. If additive pipeline creation failed, any
   ribbon with additive blending would call vkCmdBindPipeline with
   VK_NULL_HANDLE, causing VK_ERROR_DEVICE_LOST on the GPU side.
   Extended the early-return guard to cover both ribbon pipelines.
2026-03-13 01:45:31 -07:00
Kelsi
367b48af6b fix: handle short loot-failure response in LootResponseParser
Servers send a 9-byte packet (guid+lootType) with lootType=LOOT_NONE when
loot is unavailable (locked chest, another player looting, needs a key).
The previous parser required ≥14 bytes (guid+lootType+gold+itemCount) and
logged a spurious WARNING for every such failure response.

Now:
- Accept the 9-byte form; return false so the caller skips opening the
  loot window (correct behaviour for a failure/empty response).
- Log at DEBUG level instead of WARNING for the short form.
- Keep the original WARNING for genuinely malformed packets < 9 bytes.
2026-03-13 01:29:21 -07:00
Kelsi
13c096f3e9 fix: resolve keybinding conflicts for Q, M, and grave keys
- TOGGLE_QUEST_LOG: change default from Q to None — Q conflicts with
  strafe-left in camera_controller; quest log already accessible via
  TOGGLE_QUESTS (L, the standard WoW binding)
- Equipment Set Manager: remove hardcoded SDL_SCANCODE_GRAVE shortcut
  (~` should not be used for this)
- World map M key: remove duplicate SDL_SCANCODE_M self-handler from
  world_map.cpp::render() that was desync-ing with game_screen's
  TOGGLE_WORLD_MAP binding; game_screen now owns open/close, render()
  handles initial zone load and ESC-close signalling via isOpen()
2026-03-13 01:27:30 -07:00
Kelsi
1108aa9ae6 feat: implement M2 ribbon emitter rendering for spell trail effects
Parse M2RibbonEmitter data (WotLK format) from M2 files — bone index,
position, color/alpha/height tracks, edgesPerSecond, edgeLifetime,
gravity. Add CPU-side trail simulation per instance (edge birth at bone
world position, lifetime expiry, gravity droop). New m2_ribbon.vert/frag
shaders render a triangle-strip quad per emitter using the existing
particleTexLayout_ descriptor set. Supports both alpha-blend and additive
pipeline variants based on material blend mode. Fixes invisible spell
trail effects (~5-10%% of spell visuals) that were silently skipped.
2026-03-13 01:17:30 -07:00
Kelsi
022d387d95 fix: correct corpse retrieval coordinate mismatch and detect corpse objects
- canReclaimCorpse() and getCorpseDistance() compared canonical movementInfo
  (x=north=server_y, y=west=server_x) against raw server corpseX_/Y_ causing
  the proximity check to always report wrong distance even when standing on corpse
- Fix: use corpseY_ for canonical north and corpseX_ for canonical west
- Also detect OBJECT_TYPE_CORPSE update blocks owned by the player to set
  corpse coordinates at login-as-ghost (before SMSG_DEATH_RELEASE_LOC arrives)
2026-03-13 00:59:43 -07:00
Kelsi
acf99354b3 feat: add ghost mode grayscale screen effect
- FXAA path: repurpose _pad field as 'desaturate' push constant; when
  ghostMode_ is true, convert final pixel to grayscale with slight cool
  blue tint using luma(0.299,0.587,0.114) mix
- Non-FXAA path: apply a high-opacity gray overlay (rgba 0.5,0.5,0.55,0.82)
  over the scene for a washed-out look
- Both parallel (SEC_POST) and single-threaded render paths covered
- ghostMode_ flag set each frame from gameHandler->isPlayerGhost()
2026-03-13 00:59:36 -07:00
Kelsi
d3159791de fix: rewrite handleTalentsInfo with correct WotLK SMSG_TALENTS_INFO packet format
TalentsInfoParser used a completely wrong byte layout (expected big-endian
counts, wrong field order), causing unspentTalentPoints to always be misread.
This made canLearn always false so clicking talents did nothing.

New format matches the actual WoW 3.3.5a wire format:
  uint8 talentType, uint32 unspentTalents, uint8 groupCount, uint8 activeGroup,
  per-group: uint8 talentCount, [uint32 id + uint8 rank]×N, uint8 glyphCount, [uint16]×M

Matches the proven parsing logic in handleInspectResults.
2026-03-13 00:47:04 -07:00
Kelsi
e4fd4b4e6d feat: parse SMSG_SET_FLAT/PCT_SPELL_MODIFIER and apply talent modifiers to spell tooltips
Implements SMSG_SET_FLAT_SPELL_MODIFIER and SMSG_SET_PCT_SPELL_MODIFIER
(previously consumed silently). Parses per-group (uint8 groupIndex, uint8
SpellModOp, int32 value) tuples sent by the server after login and talent
changes, and stores them in spellFlatMods_/spellPctMods_ maps keyed by
(SpellModOp, groupIndex).

Exposes getSpellFlatMod(op)/getSpellPctMod(op) accessors and a static
applySpellMod() helper. Clears both maps on character login alongside
spellCooldowns. Surfaces talent-modified mana cost and cast time in the
spellbook tooltip via SpellModOp::Cost and SpellModOp::CastingTime lookups.
2026-03-12 23:59:38 -07:00
Kelsi
74d5984ee2 feat: parse arena header in MSG_PVP_LOG_DATA and show arena scoreboard
Previously the arena path in handlePvpLogData consumed the packet and
returned early with no data. Now the two-team header is parsed (rating
change, new rating, team name), followed by the same player list and
winner fields as battlegrounds.

The BgScoreboardData struct gains ArenaTeamScore fields (teamName,
ratingChange, newRating) populated when isArena=true.

The BG scoreboard UI is updated to:
- Use "Arena Score" window title for arenas
- Show each team's name and rating delta at the top
- Identify the winner by team name instead of faction label
2026-03-12 23:46:38 -07:00
Kelsi
de5c122307 feat: parse SMSG_SET_FACTION_ATWAR/VISIBLE and show at-war status in reputation panel
- Parse SMSG_SET_FACTION_ATWAR (uint32 repListId + uint8 set) to track
  per-faction at-war flags in initialFactions_ flags byte
- Parse SMSG_SET_FACTION_VISIBLE (uint32 repListId + uint8 visible) to
  track faction visibility changes from the server
- Add FACTION_FLAG_* constants (VISIBLE, AT_WAR, HIDDEN, etc.) to GameHandler
- Build repListId <-> factionId bidirectional maps when loading Faction.dbc
  (ReputationListID field 1); used to correlate flag packets with standings
- Fix Faction.dbc field layout comment: field 1=ReputationListID, field 23=Name
  (was incorrectly documented as field 22 with no ReputationListID field)
- Add isFactionAtWar(), isFactionVisible(), getFactionIdByRepListId(),
  getRepListIdByFactionId() accessors on GameHandler
- Reputation panel now shows watched faction at top, highlights at-war
  factions in red with "(At War)" label, and marks tracked faction in gold
2026-03-12 23:30:44 -07:00
Kelsi
1d9dc6dcae feat: parse SMSG_RESPOND_INSPECT_ACHIEVEMENTS and request on inspect
When the player inspects another player on WotLK 3.3.5a, also send
CMSG_QUERY_INSPECT_ACHIEVEMENTS so the server responds with
SMSG_RESPOND_INSPECT_ACHIEVEMENTS.  The new handler parses the
achievement-id/date sentinel-terminated block (same layout as
SMSG_ALL_ACHIEVEMENT_DATA but prefixed with a packed guid) and stores
the earned achievement IDs keyed by GUID in
inspectedPlayerAchievements_.  The new public getter
getInspectedPlayerAchievements(guid) exposes this data for the inspect
UI.  The cache is cleared on world entry to prevent stale data.
QueryInspectAchievementsPacket::build() handles the CMSG wire format
(uint64 guid + uint8 unk=0).
2026-03-12 23:23:02 -07:00
Kelsi
0089b3a160 feat: extend SMSG_SPELLLOGEXECUTE to parse power drain, health leech, interrupt cast, and feed pet effects
- Effect 10 (POWER_DRAIN): show PERIODIC_DAMAGE text on victim, ENERGIZE on caster;
  handles Drain Mana, Viper Sting, Fel Drain, etc.
- Effect 11 (HEALTH_LEECH): show SPELL_DAMAGE on victim, HEAL on caster;
  handles Drain Life, Death Coil, etc.
- Effect 24/114 (CREATE_ITEM/CREATE_ITEM2): existing profession crafting feedback
  extended to also cover CREATE_ITEM2 (engineering/enchanting recipes using alt effect)
- Effect 26 (INTERRUPT_CAST): clear the interrupted unit's cast bar from unitCastStates_
  so the cast bar dismisses immediately rather than waiting for the next update packet
- Effect 49 (FEED_PET): show "You feed your pet <item>." message for hunter pet feeding

All effects are expansion-aware: TBC/Classic use full uint64 GUIDs, WotLK uses packed GUIDs.
2026-03-12 23:09:04 -07:00
Kelsi
e029e8649f feat: parse SMSG_SPELLLOGEXECUTE CREATE_ITEM effects for profession crafting feedback
- Implement SMSG_SPELLLOGEXECUTE handler with expansion-aware caster GUID reading
  (packed_guid for WotLK/Classic, full uint64 for TBC)
- Parse effect type 24 (SPELL_EFFECT_CREATE_ITEM): show "You create <item> using
  <spell>." in chat when the player uses a profession or any create-item spell
- Look up item name via ensureItemInfo/getItemInfo and spell name via spellNameCache_
- Fall back to "You create: <item>." when the spell name is not cached
- Safely consume unknown effect types by stopping parse at first unrecognized effect
  to avoid packet misalignment on variable-length sub-records
- Adds visible crafting feedback complementary to SMSG_ITEM_PUSH_RESULT (which shows
  "Received:" for looted/obtained items) with a profession-specific "create" message
2026-03-12 22:53:33 -07:00
Kelsi
d52c49c9fa fix: FXAA sharpening and MSAA exclusion
- Post-FXAA unsharp mask: when FSR2 is active alongside FXAA, forward
  the FSR2 sharpness value (0–2) to the FXAA fragment shader via a new
  vec4 push constant. A contrast-adaptive sharpening step (unsharp mask
  scaled to 0–0.3) is applied after FXAA blending, recovering the
  crispness that FXAA's sub-pixel blend removes.  At sharpness=2.0 the
  output matches RCAS quality; at sharpness=0 the step is a no-op.

- MSAA guard: setFXAAEnabled() refuses to activate FXAA when hardware
  MSAA is in use. FXAA's role is to supplement FSR temporal AA, not to
  stack on top of MSAA which already resolves jaggies during the scene
  render pass.
2026-03-12 22:38:37 -07:00
Kelsi
b832940509 fix: separate SMSG_QUEST_POI_QUERY_RESPONSE from consume-only stubs; add SMSG_SERVERTIME, SMSG_KICK_REASON, SMSG_GROUPACTION_THROTTLED, SMSG_GMRESPONSE_RECEIVED handlers
- Fix bug where SMSG_REDIRECT_CLIENT, SMSG_PVP_QUEUE_STATS, SMSG_PLAYER_SKINNED, etc.
  were incorrectly falling through to handleQuestPoiQueryResponse instead of being
  silently consumed; add separate setReadPos break for those opcodes
- Implement SMSG_SERVERTIME: sync gameTime_ from server's unix timestamp
- Implement SMSG_KICK_REASON: show player a chat message with reason for group removal
- Implement SMSG_GROUPACTION_THROTTLED: notify player of rate-limit with wait time
- Implement SMSG_GMRESPONSE_RECEIVED: display GM ticket response in chat and UI error
- Implement SMSG_GMRESPONSE_STATUS_UPDATE: show ticket status changes in chat
- Silence voice chat, dance, commentator, and debug/cheat opcodes with explicit consume
  cases rather than falling to the unhandled-opcode warning log
2026-03-12 22:35:37 -07:00
Kelsi
c5a6979d69 feat: handle SMSG_BATTLEFIELD_MGR_* and SMSG_CALENDAR_* opcodes
Implements WotLK Outdoor Battlefield Manager (Wintergrasp/Tol Barad):
- Parse SMSG_BATTLEFIELD_MGR_ENTRY_INVITE, ENTERED, QUEUE_INVITE,
  QUEUE_REQUEST_RESPONSE, EJECT_PENDING, EJECTED, STATE_CHANGE
- Store bfMgrInvitePending_/bfMgrActive_/bfMgrZoneId_ state
- Send CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE via acceptBfMgrInvite() /
  declineBfMgrInvite() accessors
- Add renderBfMgrInvitePopup() UI dialog with Enter/Decline buttons;
  recognises Wintergrasp (zone 4197) and Tol Barad (zone 5095) by name

Implements WotLK Calendar notifications:
- SMSG_CALENDAR_SEND_NUM_PENDING: track pending invite count
- SMSG_CALENDAR_COMMAND_RESULT: map 15 error codes to friendly messages
- SMSG_CALENDAR_EVENT_INVITE_ALERT: notify player of new event invite with title
- SMSG_CALENDAR_EVENT_STATUS: show per-event RSVP status changes (9 statuses)
- SMSG_CALENDAR_RAID_LOCKOUT_ADDED/REMOVED: log raid lockout calendar entries
- Remaining SMSG_CALENDAR_* packets safely consumed
- requestCalendar() sends CMSG_CALENDAR_GET_CALENDAR + GET_NUM_PENDING
2026-03-12 22:25:46 -07:00
Kelsi
dd38026b23 feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG
Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
  open ticket, closed, suspended), extracts ticket text, age and
  server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
  goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
  automatically when the GM Ticket UI window is opened, so the player
  sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
  wait time, and hides the Delete button when no ticket is active.

Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
2026-03-12 22:14:46 -07:00
Kelsi
9b60108fa6 feat: handle SMSG_MEETINGSTONE, LFG timeout, SMSG_WHOIS, and SMSG_MIRRORIMAGE_DATA
Add handlers for 14 previously-unhandled server opcodes:

LFG error/timeout states (WotLK Dungeon Finder):
- SMSG_LFG_TIMEDOUT: invite timed out, shows message and re-opens LFG UI
- SMSG_LFG_OTHER_TIMEDOUT: another player's response timed out
- SMSG_LFG_AUTOJOIN_FAILED: auto-join failed with reason code
- SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: no players available for auto-join
- SMSG_LFG_LEADER_IS_LFM: party leader is in LFM mode

Meeting Stone (Classic/TBC era group-finding feature):
- SMSG_MEETINGSTONE_SETQUEUE: shows zone and level range in chat
- SMSG_MEETINGSTONE_COMPLETE: group ready notification
- SMSG_MEETINGSTONE_IN_PROGRESS: search ongoing notification
- SMSG_MEETINGSTONE_MEMBER_ADDED: player name resolved and shown in chat
- SMSG_MEETINGSTONE_JOINFAILED: localized error message (4 reason codes)
- SMSG_MEETINGSTONE_LEAVE: queue departure notification

Other:
- SMSG_WHOIS: displays GM /whois result line-by-line in system chat
- SMSG_MIRRORIMAGE_DATA: parses WotLK mirror image unit display ID and
  applies it to the entity so mirror images render with correct appearance
2026-03-12 22:07:03 -07:00
Kelsi
ebaf95cc42 fix: remove Y-flip counter-hacks in FSR shaders; invert mouse by default; FSR1 disables MSAA
FSR EASU and FSR2 sharpen fragment shaders had a manual Y-flip to undo
the now-removed postprocess.vert flip.  Strip those since the vertex
shader no longer flips, making all postprocess paths consistent.

Also flip the default mouse Y-axis to match user expectation (mouse
down = look up / flight-sim style) and make FSR1 disable MSAA on
enable, matching FSR2 behaviour (FSR provides its own spatial AA).
2026-03-12 21:59:41 -07:00
Kelsi
f8f57411f2 feat: implement SMSG_BATTLEFIELD_LIST handler
Parse the battleground availability list sent by the server when the
player opens the BG finder.  Handles all three expansion wire formats:
- Classic: bgTypeId + isRegistered + count + instanceIds
- TBC:     adds isHoliday byte
- WotLK:   adds minLevel/maxLevel for bracket display

Stores results in availableBgs_ (public via getAvailableBgs()) so the
UI can show available battlegrounds and running instance counts without
an additional server round-trip.
2026-03-12 21:54:48 -07:00
Kelsi
793c2b5611 fix: remove incorrect Y-flip in postprocess vertex shader
postprocess.vert.glsl had `TexCoord.y = 1.0 - TexCoord.y` which
inverted the vertical sampling of the scene texture.  Vulkan textures
use v=0 at the top, matching framebuffer row 0, so no flip is needed.
The camera already flips the projection matrix (mat[1][1] *= -1) so
the scene is rendered correctly oriented; the extra inversion in the
postprocess pass flipped FXAA output upside down.

Fixes: FXAA shows camera upside down
Also fixes: FSR1 upscale and FSR3 sharpen passes (same vertex shader)
2026-03-12 21:52:00 -07:00
Kelsi
4c1bc842bc fix: normalize TBC SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE harmful bit to WotLK debuff convention
TBC aura packets (SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE / SMSG_SET_EXTRA_AURA_INFO_OBSOLETE)
use flag bit 0x02 for harmful (debuff) auras, same as Classic 1.12. The UI checks bit 0x80
for debuff display, following the WotLK SMSG_AURA_UPDATE convention. Without normalization,
all TBC debuffs were displayed in the buff bar instead of the debuff bar.

Normalize using (flags & 0x02) ? 0x80 : 0, matching the fix applied to Classic in 9b09278.
2026-03-12 21:39:22 -07:00
Kelsi
9b092782c9 fix: normalize Classic UNIT_FIELD_AURAFLAGS harmful bit to WotLK debuff convention
The buff/debuff bar uses 0x80 (WotLK convention) to identify debuffs.
Classic UNIT_FIELD_AURAFLAGS uses 0x02 for harmful auras instead.
Map Classic 0x02 → 0x80 during aura rebuild so the UI correctly
separates buffs from debuffs for Classic players.
2026-03-12 21:34:16 -07:00
Kelsi
18d0e6a252 feat: use UNIT_FIELD_AURAFLAGS to correctly classify Classic buffs vs debuffs
Classic WoW stores aura flags in UNIT_FIELD_AURAFLAGS (12 uint32 fields
packed 4 bytes per uint32, one byte per aura slot). Flag bit 0x02 = harmful
(debuff), 0x04 = helpful (buff).

- Add UNIT_FIELD_AURAFLAGS to update_field_table.hpp (Classic wire index 98)
- Add wire index 98 to Classic and Turtle WoW JSON update field tables
- Both Classic aura rebuild paths (CREATE_OBJECT and VALUES) now read the
  flag byte for each aura slot to populate AuraSlot.flags, enabling the
  buff/debuff bar to correctly separate buffs from debuffs on Classic
2026-03-12 21:33:19 -07:00
Kelsi
fb8c251a82 feat: implement SMSG_ACHIEVEMENT_DELETED, SMSG_CRITERIA_DELETED, SMSG_FORCED_DEATH_UPDATE
- SMSG_ACHIEVEMENT_DELETED: removes achievement from earnedAchievements_ and
  achievementDates_ so the achievements UI stays accurate after revocation
- SMSG_CRITERIA_DELETED: removes criteria from criteriaProgress_ tracking
- SMSG_FORCED_DEATH_UPDATE: sets playerDead_ when server force-kills the
  player (GM command, scripted events) instead of silently consuming
2026-03-12 21:28:24 -07:00
Kelsi
758ca76bd3 feat: parse MSG_INSPECT_ARENA_TEAMS and display in inspect window
Implements MSG_INSPECT_ARENA_TEAMS (WotLK): reads the inspected player's
arena team data (2v2/3v3/5v5 bracket, team name, personal rating,
week/season W-L) and stores it in InspectResult.arenaTeams.

The inspect window now shows an "Arena Teams" section below the gear list
when arena team data is available, displaying bracket, team name, rating,
and win/loss record.

Also implement SMSG_COMPLAIN_RESULT with user-visible feedback for
report-player results.
2026-03-12 21:27:02 -07:00
Kelsi
a1edddd1f0 feat: open dungeon finder UI when server sends SMSG_OPEN_LFG_DUNGEON_FINDER
Previously SMSG_OPEN_LFG_DUNGEON_FINDER was consumed silently with no UI
response. Now it fires an OpenLfgCallback wired to openDungeonFinder() on
the GameScreen, so the dungeon finder window opens as the server requests.
2026-03-12 21:24:42 -07:00
Kelsi
e68ffbc711 feat: populate Classic playerAuras from UNIT_FIELD_AURAS update fields
Classic WoW (1.12) does not use SMSG_AURA_UPDATE like WotLK or TBC.
Instead, active aura spell IDs are sent via 48 consecutive UNIT_FIELD_AURAS
slots in SMSG_UPDATE_OBJECT CREATE_OBJECT and VALUES blocks.

Previously these fields were only used for mount spell ID detection.
Now on CREATE_OBJECT and VALUES updates for the player entity (Classic
only), any changed UNIT_FIELD_AURAS slot triggers a full rebuild of
playerAuras from the entity's accumulated field state, enabling the
buff/debuff bar to display active auras for Classic players.
2026-03-12 21:19:17 -07:00
Kelsi
470421879a feat: implement SMSG_GROUP_SET_LEADER and BG player join/leave notifications
- SMSG_GROUP_SET_LEADER: parse leader name, update partyData.leaderGuid
  by name lookup, display system message announcing the new leader
- SMSG_BATTLEGROUND_PLAYER_JOINED: parse guid, show named entry message
  when player is in nameCache
- SMSG_BATTLEGROUND_PLAYER_LEFT: parse guid, show named exit message
  when player is in nameCache

Replaces three LOG_INFO/ignore stubs with functional packet handlers.
2026-03-12 21:08:40 -07:00
Kelsi
7b3578420a fix: correct camera mouse-Y inversion (mouse-down should look down by default)
SDL yrel > 0 means the mouse moved downward. In WoW, moving the mouse
down should decrease pitch (look down), but the previous code did
+= yrel which increased pitch (look up). This made the camera appear
inverted — moving the mouse down tilted the view upward. The invertMouse
option accidentally produced the correct WoW-default behaviour.

Fix: negate the default invert factor so mouse-down = look down without
InvertMouse, and mouse-down = look up when InvertMouse is enabled.
2026-03-12 21:06:07 -07:00
Kelsi
91535fa9ae feat: parse SMSG_ARENA_TEAM_ROSTER and display member list in Arena UI
Add ArenaTeamMember / ArenaTeamRoster structs, parse the WotLK 3.3.5a
roster packet (guid, online flag, name, per-player week/season W/L,
personal rating), store per-teamId, and render a 4-column table
(Name / Rating / Week / Season) inside the existing Arena social tab.
Online members are highlighted green; offline members are greyed out.
2026-03-12 21:01:51 -07:00
Kelsi
a728952058 feat: add defensive and penetration stats to character sheet
Display Defense Rating, Dodge Rating, Parry Rating, Block Rating,
Block Value, Armor Penetration, and Spell Penetration from equipped
items in the Stats tab. Previously these stat types (12-15, 44, 47,
48) were parsed from item data but silently dropped.
2026-03-12 20:55:39 -07:00
Kelsi
5684b16721 fix: talent screen hang (uint8_t overflow) and camera pitch limit
- Change maxRow/maxCol from uint8_t to int in renderTalentTree to prevent
  infinite loop: uint8_t col <= 255 never exits since col wraps 255→0.
  Add sanity cap of 15 rows/cols to guard against corrupt DBC data.
- Fix dangling reference warning in getFormattedTitle (lambda reference)
- Raise MAX_PITCH from 35° to 88° to match WoW standard upward look range
2026-03-12 20:52:58 -07:00
Kelsi
f5d23a3a12 feat: add equipment set manager window (backtick key)
Lists all saved equipment sets from SMSG_EQUIPMENT_SET_LIST with
icon placeholder and an Equip button per set. Clicking either the
icon or the Equip button sends CMSG_EQUIPMENT_SET_USE to swap the
player's gear to that set. Window toggled with the ` (backtick) key.
2026-03-12 20:28:03 -07:00
Kelsi
1bf4c2442a feat: add title selection window with CMSG_SET_TITLE support
Track player titles from SMSG_TITLE_EARNED into knownTitleBits_ set,
read active title from PLAYER_CHOSEN_TITLE update field (WotLK index
1349), expose via getFormattedTitle()/sendSetTitle() on GameHandler.

Add SetTitlePacket builder (CMSG_SET_TITLE: int32 titleBit, -1=clear).

Titles window (H key) lists all earned titles from CharTitles.dbc,
highlights the active one in gold, and lets the player click to equip
or unequip a title with a single server round-trip.
2026-03-12 20:23:36 -07:00
40 changed files with 3241 additions and 259 deletions

View file

@ -14,6 +14,7 @@
"UNIT_FIELD_DISPLAYID": 131,
"UNIT_FIELD_MOUNTDISPLAYID": 133,
"UNIT_FIELD_AURAS": 50,
"UNIT_FIELD_AURAFLAGS": 98,
"UNIT_NPC_FLAGS": 147,
"UNIT_DYNAMIC_FLAGS": 143,
"UNIT_FIELD_RESISTANCES": 154,

View file

@ -14,6 +14,7 @@
"UNIT_FIELD_DISPLAYID": 131,
"UNIT_FIELD_MOUNTDISPLAYID": 133,
"UNIT_FIELD_AURAS": 50,
"UNIT_FIELD_AURAFLAGS": 98,
"UNIT_NPC_FLAGS": 147,
"UNIT_DYNAMIC_FLAGS": 143,
"UNIT_FIELD_RESISTANCES": 154,

View file

@ -37,6 +37,7 @@
"PLAYER_FIELD_BANKBAG_SLOT_1": 458,
"PLAYER_SKILL_INFO_START": 636,
"PLAYER_EXPLORED_ZONES_START": 1041,
"PLAYER_CHOSEN_TITLE": 1349,
"GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 60,

View file

@ -10,9 +10,7 @@ layout(push_constant) uniform PushConstants {
} pc;
void main() {
// Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay,
// but we need standard UV coords for texture sampling)
vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y);
vec2 tc = TexCoord;
vec2 texelSize = pc.params.xy;
float sharpness = pc.params.z;

Binary file not shown.

View file

@ -21,9 +21,7 @@ vec3 fsrFetch(vec2 p, vec2 off) {
}
void main() {
// Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay,
// but we need standard UV coords for texture sampling)
vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y);
vec2 tc = TexCoord;
// Map output pixel to input space
vec2 pp = tc * fsr.con2.xy; // output pixel position

Binary file not shown.

View file

@ -2,7 +2,7 @@
// FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass.
// Reads the resolved scene color and outputs a smoothed result.
// Push constant: rcpFrame = vec2(1/width, 1/height).
// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), desaturate (1=ghost grayscale).
layout(set = 0, binding = 0) uniform sampler2D uScene;
@ -11,6 +11,8 @@ layout(location = 0) out vec4 outColor;
layout(push_constant) uniform PC {
vec2 rcpFrame;
float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range)
float desaturate; // 1 = full grayscale (ghost mode), 0 = normal color
} pc;
// Quality tuning
@ -128,5 +130,26 @@ void main() {
if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign;
if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign;
outColor = vec4(texture(uScene, finalUV).rgb, 1.0);
vec3 fxaaResult = texture(uScene, finalUV).rgb;
// Post-FXAA contrast-adaptive sharpening (unsharp mask).
// Counteracts FXAA's sub-pixel blur when sharpness > 0.
if (pc.sharpness > 0.0) {
vec2 r = pc.rcpFrame;
vec3 blur = (texture(uScene, uv + vec2(-r.x, 0)).rgb
+ texture(uScene, uv + vec2( r.x, 0)).rgb
+ texture(uScene, uv + vec2(0, -r.y)).rgb
+ texture(uScene, uv + vec2(0, r.y)).rgb) * 0.25;
// scale sharpness from [0,2] to a modest [0, 0.3] boost factor
float s = pc.sharpness * 0.15;
fxaaResult = clamp(fxaaResult + s * (fxaaResult - blur), 0.0, 1.0);
}
// Ghost mode: desaturate to grayscale (with a slight cool blue tint).
if (pc.desaturate > 0.5) {
float gray = dot(fxaaResult, vec3(0.299, 0.587, 0.114));
fxaaResult = mix(fxaaResult, vec3(gray, gray, gray * 1.05), pc.desaturate);
}
outColor = vec4(fxaaResult, 1.0);
}

Binary file not shown.

View file

@ -0,0 +1,25 @@
#version 450
// M2 ribbon emitter fragment shader.
// Samples the ribbon texture, multiplied by vertex color and alpha.
// Uses additive blending (pipeline-level) for magic/spell trails.
layout(set = 1, binding = 0) uniform sampler2D uTexture;
layout(location = 0) in vec3 vColor;
layout(location = 1) in float vAlpha;
layout(location = 2) in vec2 vUV;
layout(location = 3) in float vFogFactor;
layout(location = 0) out vec4 outColor;
void main() {
vec4 tex = texture(uTexture, vUV);
// For additive ribbons alpha comes from texture luminance; multiply by vertex alpha.
float a = tex.a * vAlpha;
if (a < 0.01) discard;
vec3 rgb = tex.rgb * vColor;
// Ribbons fade slightly with fog (additive blend attenuated toward black = invisible in fog).
rgb *= vFogFactor;
outColor = vec4(rgb, a);
}

Binary file not shown.

View file

@ -0,0 +1,43 @@
#version 450
// M2 ribbon emitter vertex shader.
// Ribbon geometry is generated CPU-side as a triangle strip.
// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats.
layout(set = 0, binding = 0) uniform PerFrame {
mat4 view;
mat4 projection;
mat4 lightSpaceMatrix;
vec4 lightDir;
vec4 lightColor;
vec4 ambientColor;
vec4 viewPos;
vec4 fogColor;
vec4 fogParams;
vec4 shadowParams;
};
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in float aAlpha;
layout(location = 3) in vec2 aUV;
layout(location = 0) out vec3 vColor;
layout(location = 1) out float vAlpha;
layout(location = 2) out vec2 vUV;
layout(location = 3) out float vFogFactor;
void main() {
vec4 worldPos = vec4(aPos, 1.0);
vec4 viewPos4 = view * worldPos;
gl_Position = projection * viewPos4;
float dist = length(viewPos4.xyz);
float fogStart = fogParams.x;
float fogEnd = fogParams.y;
vFogFactor = clamp((fogEnd - dist) / max(fogEnd - fogStart, 0.001), 0.0, 1.0);
vColor = aColor;
vAlpha = aAlpha;
vUV = aUV;
}

Binary file not shown.

View file

@ -6,5 +6,7 @@ void main() {
// Fullscreen triangle trick: 3 vertices, no vertex buffer
TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
gl_Position = vec4(TexCoord * 2.0 - 1.0, 0.0, 1.0);
TexCoord.y = 1.0 - TexCoord.y; // flip Y for Vulkan
// No Y-flip: scene textures use Vulkan convention (v=0 at top),
// and NDC y=-1 already maps to framebuffer top, so the triangle
// naturally samples the correct row without any inversion.
}

Binary file not shown.

View file

@ -271,6 +271,7 @@ private:
};
std::unordered_map<uint32_t, std::string> gameObjectDisplayIdToPath_;
std::unordered_map<uint32_t, uint32_t> gameObjectDisplayIdModelCache_; // displayId → M2 modelId
std::unordered_set<uint32_t> gameObjectDisplayIdFailedCache_; // displayIds that permanently fail to load
std::unordered_map<uint32_t, uint32_t> gameObjectDisplayIdWmoCache_; // displayId → WMO modelId
std::unordered_map<uint64_t, GameObjectInstanceInfo> gameObjectInstances_; // guid → instance info
struct PendingTransportMove {

View file

@ -339,6 +339,16 @@ public:
// Inspection
void inspectTarget();
struct InspectArenaTeam {
uint32_t teamId = 0;
uint8_t type = 0; // bracket size: 2, 3, or 5
uint32_t weekGames = 0;
uint32_t weekWins = 0;
uint32_t seasonGames = 0;
uint32_t seasonWins = 0;
std::string name;
uint32_t personalRating = 0;
};
struct InspectResult {
uint64_t guid = 0;
std::string playerName;
@ -348,6 +358,7 @@ public:
uint8_t activeTalentGroup = 0;
std::array<uint32_t, 19> itemEntries{}; // 0=head…18=ranged
std::array<uint16_t, 19> enchantIds{}; // permanent enchant per slot (0 = none)
std::vector<InspectArenaTeam> arenaTeams; // from MSG_INSPECT_ARENA_TEAMS (WotLK)
};
const InspectResult* getInspectResult() const {
return inspectResult_.guid ? &inspectResult_ : nullptr;
@ -394,11 +405,22 @@ public:
std::chrono::steady_clock::time_point inviteReceivedTime{};
};
// Available BG list (populated by SMSG_BATTLEFIELD_LIST)
struct AvailableBgInfo {
uint32_t bgTypeId = 0;
bool isRegistered = false;
bool isHoliday = false;
uint32_t minLevel = 0;
uint32_t maxLevel = 0;
std::vector<uint32_t> instanceIds;
};
// 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_; }
const std::vector<AvailableBgInfo>& getAvailableBgs() const { return availableBgs_; }
// BG scoreboard (MSG_PVP_LOG_DATA)
struct BgPlayerScore {
@ -411,11 +433,18 @@ public:
uint32_t bonusHonor = 0;
std::vector<std::pair<std::string, uint32_t>> bgStats; // BG-specific fields
};
struct ArenaTeamScore {
std::string teamName;
uint32_t ratingChange = 0; // signed delta packed as uint32
uint32_t newRating = 0;
};
struct BgScoreboardData {
std::vector<BgPlayerScore> players;
bool hasWinner = false;
uint8_t winner = 0; // 0=Horde, 1=Alliance
bool isArena = false;
// Arena-only fields (valid when isArena=true)
ArenaTeamScore arenaTeams[2]; // team 0 = first, team 1 = second
};
void requestPvpLog();
const BgScoreboardData* getBgScoreboard() const {
@ -472,6 +501,24 @@ public:
// GM Ticket
void submitGmTicket(const std::string& text);
void deleteGmTicket();
void requestGmTicket(); ///< Send CMSG_GMTICKET_GETTICKET to query open ticket
// GM ticket status accessors
bool hasActiveGmTicket() const { return gmTicketActive_; }
const std::string& getGmTicketText() const { return gmTicketText_; }
bool isGmSupportAvailable() const { return gmSupportAvailable_; }
float getGmTicketWaitHours() const { return gmTicketWaitHours_; }
// Battlefield Manager (Wintergrasp)
bool hasBfMgrInvite() const { return bfMgrInvitePending_; }
bool isInBfMgrZone() const { return bfMgrActive_; }
uint32_t getBfMgrZoneId() const { return bfMgrZoneId_; }
void acceptBfMgrInvite();
void declineBfMgrInvite();
// WotLK Calendar
uint32_t getCalendarPendingInvites() const { return calendarPendingInvites_; }
void requestCalendar(); ///< Send CMSG_CALENDAR_GET_CALENDAR to the server
void queryGuildInfo(uint32_t guildId);
void createGuild(const std::string& guildName);
void addGuildRank(const std::string& rankName);
@ -1059,8 +1106,10 @@ public:
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
float getCorpseDistance() const {
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f;
float dx = movementInfo.x - corpseX_;
float dy = movementInfo.y - corpseY_;
// movementInfo is canonical (x=north=server_y, y=west=server_x);
// corpse coords are raw server (x=west, y=north) — swap to compare.
float dx = movementInfo.x - corpseY_;
float dy = movementInfo.y - corpseX_;
float dz = movementInfo.z - corpseZ_;
return std::sqrt(dx*dx + dy*dy + dz*dz);
}
@ -1247,6 +1296,29 @@ public:
};
const std::vector<ArenaTeamStats>& getArenaTeamStats() const { return arenaTeamStats_; }
// ---- Arena Team Roster ----
struct ArenaTeamMember {
uint64_t guid = 0;
std::string name;
bool online = false;
uint32_t weekGames = 0;
uint32_t weekWins = 0;
uint32_t seasonGames = 0;
uint32_t seasonWins = 0;
uint32_t personalRating = 0;
};
struct ArenaTeamRoster {
uint32_t teamId = 0;
std::vector<ArenaTeamMember> members;
};
// Returns roster for the given teamId, or nullptr if not yet received
const ArenaTeamRoster* getArenaTeamRoster(uint32_t teamId) const {
for (const auto& r : arenaTeamRosters_) {
if (r.teamId == teamId) return &r;
}
return nullptr;
}
// ---- Phase 5: Loot ----
void lootTarget(uint64_t guid);
void lootItem(uint8_t slotIndex);
@ -1421,12 +1493,115 @@ public:
};
const std::array<RuneSlot, 6>& getPlayerRunes() const { return playerRunes_; }
// Talent-driven spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER)
// SpellModOp matches WotLK SpellModOp enum (server-side).
enum class SpellModOp : uint8_t {
Damage = 0,
Duration = 1,
Threat = 2,
Effect1 = 3,
Charges = 4,
Range = 5,
Radius = 6,
CritChance = 7,
AllEffects = 8,
NotLoseCastingTime = 9,
CastingTime = 10,
Cooldown = 11,
Effect2 = 12,
IgnoreArmor = 13,
Cost = 14,
CritDamageBonus = 15,
ResistMissChance = 16,
JumpTargets = 17,
ChanceOfSuccess = 18,
ActivationTime = 19,
Efficiency = 20,
MultipleValue = 21,
ResistDispelChance = 22,
Effect3 = 23,
BonusMultiplier = 24,
ProcPerMinute = 25,
ValueMultiplier = 26,
ResistPushback = 27,
MechanicDuration = 28,
StartCooldown = 29,
PeriodicBonus = 30,
AttackPower = 31,
};
static constexpr int SPELL_MOD_OP_COUNT = 32;
// Key: (SpellModOp, groupIndex) — value: accumulated flat or pct modifier
// pct values are stored in integer percent (e.g. -20 means -20% reduction).
struct SpellModKey {
SpellModOp op;
uint8_t group;
bool operator==(const SpellModKey& o) const {
return op == o.op && group == o.group;
}
};
struct SpellModKeyHash {
std::size_t operator()(const SpellModKey& k) const {
return std::hash<uint32_t>()(
(static_cast<uint32_t>(static_cast<uint8_t>(k.op)) << 8) | k.group);
}
};
// Returns the sum of all flat modifiers for a given op across all groups.
// (Callers that need per-group resolution can use getSpellFlatMods() directly.)
int32_t getSpellFlatMod(SpellModOp op) const {
int32_t total = 0;
for (const auto& [k, v] : spellFlatMods_)
if (k.op == op) total += v;
return total;
}
// Returns the sum of all pct modifiers for a given op across all groups (in %).
int32_t getSpellPctMod(SpellModOp op) const {
int32_t total = 0;
for (const auto& [k, v] : spellPctMods_)
if (k.op == op) total += v;
return total;
}
// Convenience: apply flat+pct modifier to a base value.
// result = (base + flatMod) * (1.0 + pctMod/100.0), clamped to >= 0.
static int32_t applySpellMod(int32_t base, int32_t flat, int32_t pct) {
int64_t v = static_cast<int64_t>(base) + flat;
if (pct != 0) v = v + (v * pct + 50) / 100; // round half-up
return static_cast<int32_t>(v < 0 ? 0 : v);
}
struct FactionStandingInit {
uint8_t flags = 0;
int32_t standing = 0;
};
// Faction flag bitmask constants (from Faction.dbc ReputationFlags / SMSG_INITIALIZE_FACTIONS)
static constexpr uint8_t FACTION_FLAG_VISIBLE = 0x01; // shown in reputation list
static constexpr uint8_t FACTION_FLAG_AT_WAR = 0x02; // player is at war
static constexpr uint8_t FACTION_FLAG_HIDDEN = 0x04; // never shown
static constexpr uint8_t FACTION_FLAG_INVISIBLE_FORCED = 0x08;
static constexpr uint8_t FACTION_FLAG_PEACE_FORCED = 0x10;
const std::vector<FactionStandingInit>& getInitialFactions() const { return initialFactions_; }
const std::unordered_map<uint32_t, int32_t>& getFactionStandings() const { return factionStandings_; }
// Returns true if the player has "at war" toggled for the faction at repListId
bool isFactionAtWar(uint32_t repListId) const {
if (repListId >= initialFactions_.size()) return false;
return (initialFactions_[repListId].flags & FACTION_FLAG_AT_WAR) != 0;
}
// Returns true if the faction is visible in the reputation list
bool isFactionVisible(uint32_t repListId) const {
if (repListId >= initialFactions_.size()) return false;
const uint8_t f = initialFactions_[repListId].flags;
if (f & FACTION_FLAG_HIDDEN) return false;
if (f & FACTION_FLAG_INVISIBLE_FORCED) return false;
return (f & FACTION_FLAG_VISIBLE) != 0;
}
// Returns the faction ID for a given repListId (0 if unknown)
uint32_t getFactionIdByRepListId(uint32_t repListId) const;
// Returns the repListId for a given faction ID (0xFFFFFFFF if not found)
uint32_t getRepListIdByFactionId(uint32_t factionId) const;
// Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air)
struct TotemSlot {
uint32_t spellId = 0;
@ -1505,6 +1680,14 @@ public:
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
// Title system — earned title bits and the currently displayed title
const std::unordered_set<uint32_t>& getKnownTitleBits() const { return knownTitleBits_; }
int32_t getChosenTitleBit() const { return chosenTitleBit_; }
/// Returns the formatted title string for a given bit (replaces %s with player name), or empty.
std::string getFormattedTitle(uint32_t bit) const;
/// Send CMSG_SET_TITLE to activate a title (bit >= 0) or clear it (bit = -1).
void sendSetTitle(int32_t bit);
// Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received
using AreaDiscoveryCallback = std::function<void(const std::string& areaName, uint32_t xpGained)>;
void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); }
@ -1540,6 +1723,12 @@ public:
auto it = achievementPointsCache_.find(id);
return (it != achievementPointsCache_.end()) ? it->second : 0u;
}
/// Returns the set of achievement IDs earned by an inspected player (via SMSG_RESPOND_INSPECT_ACHIEVEMENTS).
/// Returns nullptr if no inspect data is available for the given GUID.
const std::unordered_set<uint32_t>* getInspectedPlayerAchievements(uint64_t guid) const {
auto it = inspectedPlayerAchievements_.find(guid);
return (it != inspectedPlayerAchievements_.end()) ? &it->second : nullptr;
}
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
@ -1594,6 +1783,10 @@ public:
using TaxiFlightStartCallback = std::function<void()>;
void setTaxiFlightStartCallback(TaxiFlightStartCallback cb) { taxiFlightStartCallback_ = std::move(cb); }
// Callback fired when server sends SMSG_OPEN_LFG_DUNGEON_FINDER (open dungeon finder UI)
using OpenLfgCallback = std::function<void()>;
void setOpenLfgCallback(OpenLfgCallback cb) { openLfgCallback_ = std::move(cb); }
bool isMounted() const { return currentMountDisplayId_ != 0; }
bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
float getServerRunSpeed() const { return serverRunSpeed_; }
@ -2006,6 +2199,7 @@ private:
// ---- Other player movement (MSG_MOVE_* from server) ----
void handleOtherPlayerMovement(network::Packet& packet);
void handleMoveSetSpeed(network::Packet& packet);
// ---- Phase 5 handlers ----
void handleLootResponse(network::Packet& packet);
@ -2072,6 +2266,7 @@ private:
void handleInstanceDifficulty(network::Packet& packet);
void handleArenaTeamCommandResult(network::Packet& packet);
void handleArenaTeamQueryResponse(network::Packet& packet);
void handleArenaTeamRoster(network::Packet& packet);
void handleArenaTeamInvite(network::Packet& packet);
void handleArenaTeamEvent(network::Packet& packet);
void handleArenaTeamStats(network::Packet& packet);
@ -2428,6 +2623,10 @@ private:
// ---- Battleground queue state ----
std::array<BgQueueSlot, 3> bgQueues_{};
// ---- Available battleground list (SMSG_BATTLEFIELD_LIST) ----
std::vector<AvailableBgInfo> availableBgs_;
void handleBattlefieldList(network::Packet& packet);
// Instance difficulty
uint32_t instanceDifficulty_ = 0;
bool instanceIsHeroic_ = false;
@ -2447,6 +2646,8 @@ private:
// Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS)
std::vector<ArenaTeamStats> arenaTeamStats_;
// Arena team rosters (updated by SMSG_ARENA_TEAM_ROSTER)
std::vector<ArenaTeamRoster> arenaTeamRosters_;
// BG scoreboard (MSG_PVP_LOG_DATA)
BgScoreboardData bgScoreboard_;
@ -2478,6 +2679,10 @@ private:
std::unordered_map<uint32_t, int32_t> factionStandings_;
// Faction name cache (factionId → name), populated lazily from Faction.dbc
std::unordered_map<uint32_t, std::string> factionNameCache_;
// repListId → factionId mapping (populated with factionNameCache)
std::unordered_map<uint32_t, uint32_t> factionRepListToId_;
// factionId → repListId reverse mapping
std::unordered_map<uint32_t, uint32_t> factionIdToRepList_;
bool factionNameCacheLoaded_ = false;
void loadFactionNameCache();
std::string getFactionName(uint32_t factionId) const;
@ -2734,6 +2939,10 @@ private:
std::unordered_map<uint32_t, std::string> titleNameCache_;
bool titleNameCacheLoaded_ = false;
void loadTitleNameCache();
// Set of title bit-indices known to the player (from SMSG_TITLE_EARNED).
std::unordered_set<uint32_t> knownTitleBits_;
// Currently selected title bit, or -1 for no title. Updated from PLAYER_CHOSEN_TITLE.
int32_t chosenTitleBit_ = -1;
// Achievement caches (lazy-loaded from Achievement.dbc on first earned event)
std::unordered_map<uint32_t, std::string> achievementNameCache_;
@ -2749,6 +2958,11 @@ private:
std::unordered_map<uint32_t, uint64_t> criteriaProgress_;
void handleAllAchievementData(network::Packet& packet);
// Per-player achievement data from SMSG_RESPOND_INSPECT_ACHIEVEMENTS
// Key: inspected player's GUID; value: set of earned achievement IDs
std::unordered_map<uint64_t, std::unordered_set<uint32_t>> inspectedPlayerAchievements_;
void handleRespondInspectAchievements(network::Packet& packet);
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
std::unordered_map<uint32_t, std::string> areaNameCache_;
bool areaNameCacheLoaded_ = false;
@ -2878,6 +3092,7 @@ private:
TaxiPrecacheCallback taxiPrecacheCallback_;
TaxiOrientationCallback taxiOrientationCallback_;
TaxiFlightStartCallback taxiFlightStartCallback_;
OpenLfgCallback openLfgCallback_;
uint32_t currentMountDisplayId_ = 0;
uint32_t mountAuraSpellId_ = 0; // Spell ID of the aura that caused mounting (for CMSG_CANCEL_AURA fallback)
float serverRunSpeed_ = 7.0f;
@ -2952,6 +3167,25 @@ private:
// ---- Quest completion callback ----
QuestCompleteCallback questCompleteCallback_;
// ---- GM Ticket state (SMSG_GMTICKET_GETTICKET / SMSG_GMTICKET_SYSTEMSTATUS) ----
bool gmTicketActive_ = false; ///< True when an open ticket exists on the server
std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET)
float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours
bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS)
// ---- Battlefield Manager state (WotLK Wintergrasp / outdoor battlefields) ----
bool bfMgrInvitePending_ = false; ///< True when an entry/queue invite is pending acceptance
bool bfMgrActive_ = false; ///< True while the player is inside an outdoor battlefield
uint32_t bfMgrZoneId_ = 0; ///< Zone ID of the pending/active battlefield
// ---- WotLK Calendar: pending invite counter ----
uint32_t calendarPendingInvites_ = 0; ///< Unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING)
// ---- Spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) ----
// Keyed by (SpellModOp, groupIndex); cleared on logout/character change.
std::unordered_map<SpellModKey, int32_t, SpellModKeyHash> spellFlatMods_;
std::unordered_map<SpellModKey, int32_t, SpellModKeyHash> spellPctMods_;
};
} // namespace game

View file

@ -31,6 +31,7 @@ enum class UF : uint16_t {
UNIT_FIELD_DISPLAYID,
UNIT_FIELD_MOUNTDISPLAYID,
UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only)
UNIT_FIELD_AURAFLAGS, // Aura flags packed 4-per-uint32 (12 uint32 slots); 0x01=cancelable,0x02=harmful,0x04=helpful
UNIT_NPC_FLAGS,
UNIT_DYNAMIC_FLAGS,
UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array)
@ -56,6 +57,7 @@ enum class UF : uint16_t {
PLAYER_FIELD_BANKBAG_SLOT_1,
PLAYER_SKILL_INFO_START,
PLAYER_EXPLORED_ZONES_START,
PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title)
// GameObject fields
GAMEOBJECT_DISPLAYID,

View file

@ -1448,6 +1448,12 @@ public:
static network::Packet build(uint64_t targetGuid);
};
/** CMSG_QUERY_INSPECT_ACHIEVEMENTS packet builder (WotLK 3.3.5a) */
class QueryInspectAchievementsPacket {
public:
static network::Packet build(uint64_t targetGuid);
};
/** CMSG_NAME_QUERY packet builder */
class NameQueryPacket {
public:
@ -2727,5 +2733,13 @@ public:
static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0);
};
/** CMSG_SET_TITLE packet builder.
* titleBit >= 0: activate the title with that bit index.
* titleBit == -1: clear the current title (show no title). */
class SetTitlePacket {
public:
static network::Packet build(int32_t titleBit);
};
} // namespace game
} // namespace wowee

View file

@ -165,6 +165,29 @@ struct M2ParticleEmitter {
bool enabled = true;
};
// Ribbon emitter definition parsed from M2 (WotLK format)
struct M2RibbonEmitter {
int32_t ribbonId = 0;
uint32_t bone = 0; // Bone that drives the ribbon spine
glm::vec3 position{0.0f}; // Offset from bone pivot
uint16_t textureIndex = 0; // First texture lookup index
uint16_t materialIndex = 0; // First material lookup index (blend mode)
// Animated tracks
M2AnimationTrack colorTrack; // RGB 0..1
M2AnimationTrack alphaTrack; // float 0..1 (stored as fixed16 on disk)
M2AnimationTrack heightAboveTrack; // Half-width above bone
M2AnimationTrack heightBelowTrack; // Half-width below bone
M2AnimationTrack visibilityTrack; // 0=hidden, 1=visible
float edgesPerSecond = 15.0f; // How many edge points are generated per second
float edgeLifetime = 0.5f; // Seconds before edges expire
float gravity = 0.0f; // Downward pull on edges per s²
uint16_t textureRows = 1;
uint16_t textureCols = 1;
};
// Complete M2 model structure
struct M2Model {
// Model metadata
@ -213,6 +236,9 @@ struct M2Model {
// Particle emitters
std::vector<M2ParticleEmitter> particleEmitters;
// Ribbon emitters
std::vector<M2RibbonEmitter> ribbonEmitters;
// Collision mesh (simplified geometry for physics)
std::vector<glm::vec3> collisionVertices;
std::vector<uint16_t> collisionIndices; // 3 per triangle

View file

@ -162,7 +162,7 @@ private:
// Mouse settings
float mouseSensitivity = 0.2f;
bool invertMouse = false;
bool invertMouse = true;
bool mouseButtonDown = false;
bool leftMouseDown = false;
bool rightMouseDown = false;
@ -186,7 +186,7 @@ private:
static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 20.0f; // Reduced for performance
static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 20.0f;
static constexpr float MIN_PITCH = -88.0f; // Look almost straight down
static constexpr float MAX_PITCH = 35.0f; // Limited upward look
static constexpr float MAX_PITCH = 88.0f; // Look almost straight up (WoW standard)
glm::vec3* followTarget = nullptr;
glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement
float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised)

View file

@ -9,6 +9,7 @@
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <deque>
#include <string>
#include <optional>
#include <random>
@ -130,6 +131,11 @@ struct M2ModelGPU {
std::vector<VkTexture*> particleTextures; // Resolved Vulkan textures per emitter
std::vector<VkDescriptorSet> particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc)
// Ribbon emitter data (kept from M2Model)
std::vector<pipeline::M2RibbonEmitter> ribbonEmitters;
std::vector<VkTexture*> ribbonTextures; // Resolved texture per ribbon emitter
std::vector<VkDescriptorSet> ribbonTexSets; // Descriptor sets per ribbon emitter
// Texture transform data for UV animation
std::vector<pipeline::M2TextureTransform> textureTransforms;
std::vector<uint16_t> textureTransformLookup;
@ -180,6 +186,19 @@ struct M2Instance {
std::vector<float> emitterAccumulators; // fractional particle counter per emitter
std::vector<M2Particle> particles;
// Ribbon emitter state
struct RibbonEdge {
glm::vec3 worldPos; // Spine world position when this edge was born
glm::vec3 color; // Interpolated color at birth
float alpha; // Interpolated alpha at birth
float heightAbove;// Half-width above spine
float heightBelow;// Half-width below spine
float age; // Seconds since spawned
};
// One deque of edges per ribbon emitter on this instance
std::vector<std::deque<RibbonEdge>> ribbonEdges;
std::vector<float> ribbonEdgeAccumulators; // fractional edge counter per emitter
// Cached model flags (set at creation to avoid per-frame hash lookups)
bool cachedHasAnimation = false;
bool cachedDisableAnimation = false;
@ -295,6 +314,11 @@ public:
*/
void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet);
/**
* Render M2 ribbon emitters (spell trails / wing effects)
*/
void renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet);
void setInstancePosition(uint32_t instanceId, const glm::vec3& position);
void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform);
void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen);
@ -374,6 +398,11 @@ private:
VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles
VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE;
// Ribbon pipelines (additive + alpha-blend)
VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; // Alpha-blend ribbons
VkPipeline ribbonAdditivePipeline_ = VK_NULL_HANDLE; // Additive ribbons
VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE;
// Descriptor set layouts
VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1
VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2
@ -385,6 +414,12 @@ private:
static constexpr uint32_t MAX_MATERIAL_SETS = 8192;
static constexpr uint32_t MAX_BONE_SETS = 8192;
// Dynamic ribbon vertex buffer (CPU-written triangle strip)
static constexpr size_t MAX_RIBBON_VERTS = 2048; // 9 floats each
::VkBuffer ribbonVB_ = VK_NULL_HANDLE;
VmaAllocation ribbonVBAlloc_ = VK_NULL_HANDLE;
void* ribbonVBMapped_ = nullptr;
// Dynamic particle buffers
::VkBuffer smokeVB_ = VK_NULL_HANDLE;
VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE;
@ -535,6 +570,7 @@ private:
glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio);
void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt);
void updateParticles(M2Instance& inst, float dt);
void updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt);
// Helper to allocate descriptor sets
VkDescriptorSet allocateMaterialSet();

View file

@ -638,6 +638,8 @@ private:
bool terrainEnabled = true;
bool terrainLoaded = false;
bool ghostMode_ = false; // set each frame from gameHandler->isPlayerGhost()
// CPU timing stats (last frame/update).
double lastUpdateMs = 0.0;
double lastRenderMs = 0.0;

View file

@ -69,6 +69,12 @@ public:
*/
bool loadModel(const pipeline::WMOModel& model, uint32_t id);
/**
* Check if a WMO model is currently resident in the renderer
* @param id WMO model identifier
*/
bool isModelLoaded(uint32_t id) const;
/**
* Unload WMO model and free GPU resources
* @param id WMO model identifier

View file

@ -158,6 +158,8 @@ private:
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
bool chatWindowPosInit_ = false;
ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default
ImVec2 questTrackerSize_ = ImVec2(220.0f, 200.0f); // saved size
float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default
bool questTrackerPosInit_ = false;
bool showEscapeMenu = false;
bool showEscapeSettingsNotice = false;
@ -361,6 +363,7 @@ private:
void renderGuildInvitePopup(game::GameHandler& gameHandler);
void renderReadyCheckPopup(game::GameHandler& gameHandler);
void renderBgInvitePopup(game::GameHandler& gameHandler);
void renderBfMgrInvitePopup(game::GameHandler& gameHandler);
void renderLfgProposalPopup(game::GameHandler& gameHandler);
void renderChatBubbles(game::GameHandler& gameHandler);
void renderMailWindow(game::GameHandler& gameHandler);
@ -429,8 +432,17 @@ private:
char achievementSearchBuf_[128] = {};
void renderAchievementWindow(game::GameHandler& gameHandler);
// Titles window
bool showTitlesWindow_ = false;
void renderTitlesWindow(game::GameHandler& gameHandler);
// Equipment Set Manager window
bool showEquipSetWindow_ = false;
void renderEquipSetWindow(game::GameHandler& gameHandler);
// GM Ticket window
bool showGmTicketWindow_ = false;
bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query
char gmTicketBuf_[2048] = {};
void renderGmTicketWindow(game::GameHandler& gameHandler);
@ -633,6 +645,7 @@ public:
uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0,
uint32_t intel = 0, uint32_t spi = 0);
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
void openDungeonFinder() { showDungeonFinder_ = true; }
};
} // namespace ui

View file

@ -2550,6 +2550,11 @@ void Application::setupUICallbacks() {
}
});
// Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER
gameHandler->setOpenLfgCallback([this]() {
if (uiManager) uiManager->getGameScreen().openDungeonFinder();
});
// Creature move callback (online mode) - update creature positions
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
if (!renderer || !renderer->getCharacterRenderer()) return;
@ -4108,6 +4113,8 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
gameObjectInstances_.clear();
gameObjectDisplayIdModelCache_.clear();
gameObjectDisplayIdWmoCache_.clear();
gameObjectDisplayIdFailedCache_.clear();
// Force player character re-spawn on new map
playerCharacterSpawned = false;
@ -4458,7 +4465,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
glm::vec3 worldPos = glm::vec3(worldMatrix[3]);
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(m2Path));
m2Renderer->loadModel(m2Model, doodadModelId);
if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue;
uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos);
if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true);
loadedDoodads++;
@ -6606,7 +6613,7 @@ void Application::spawnOnlinePlayer(uint64_t guid,
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (!model.isValid() || model.vertices.empty()) {
if (model.vertices.empty()) {
LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path);
return;
}
@ -6618,6 +6625,12 @@ void Application::spawnOnlinePlayer(uint64_t guid,
pipeline::M2Loader::loadSkin(skinData, model);
}
// After skin loading, full model must be valid (vertices + indices)
if (!model.isValid()) {
LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path);
return;
}
// Load only core external animations (stand/walk/run) to avoid stalls
for (uint32_t si = 0; si < model.sequences.size(); si++) {
if (!(model.sequences[si].flags & 0x20)) {
@ -7103,8 +7116,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
auto itCache = gameObjectDisplayIdWmoCache_.find(displayId);
if (itCache != gameObjectDisplayIdWmoCache_.end()) {
modelId = itCache->second;
// Only use cached entry if the model is still resident in the renderer
if (wmoRenderer->isModelLoaded(modelId)) {
loadedAsWmo = true;
} else {
gameObjectDisplayIdWmoCache_.erase(itCache);
modelId = 0;
}
}
if (!loadedAsWmo && modelId == 0) {
auto wmoData = assetManager->readFile(modelPath);
if (!wmoData.empty()) {
pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData);
@ -7229,6 +7249,11 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
auto* m2Renderer = renderer->getM2Renderer();
if (!m2Renderer) return;
// Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s).
// Without this guard the same empty model is re-parsed every frame, causing
// sustained log spam and wasted CPU.
if (gameObjectDisplayIdFailedCache_.count(displayId)) return;
uint32_t modelId = 0;
auto itCache = gameObjectDisplayIdModelCache_.find(displayId);
if (itCache != gameObjectDisplayIdModelCache_.end()) {
@ -7247,12 +7272,14 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
auto m2Data = assetManager->readFile(modelPath);
if (m2Data.empty()) {
LOG_WARNING("Failed to read gameobject M2: ", modelPath);
gameObjectDisplayIdFailedCache_.insert(displayId);
return;
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (model.vertices.empty()) {
LOG_WARNING("Failed to parse gameobject M2: ", modelPath);
gameObjectDisplayIdFailedCache_.insert(displayId);
return;
}
@ -7264,6 +7291,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
if (!m2Renderer->loadModel(model, modelId)) {
LOG_WARNING("Failed to load gameobject model: ", modelPath);
gameObjectDisplayIdFailedCache_.insert(displayId);
return;
}
@ -8184,6 +8212,13 @@ void Application::processPendingTransportDoodads() {
auto startTime = std::chrono::steady_clock::now();
static constexpr float kDoodadBudgetMs = 4.0f;
// Batch all GPU uploads into a single async command buffer submission so that
// N doodads with multiple textures each don't each block on vkQueueSubmit +
// vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds
// of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST.
auto* vkCtx = renderer->getVkContext();
if (vkCtx) vkCtx->beginUploadBatch();
size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME;
for (auto it = pendingTransportDoodadBatches_.begin();
it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) {
@ -8227,7 +8262,7 @@ void Application::processPendingTransportDoodads() {
}
if (!m2Model.isValid()) continue;
m2Renderer->loadModel(m2Model, doodadModelId);
if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue;
uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f);
if (m2InstanceId == 0) continue;
m2Renderer->setSkipCollision(m2InstanceId, true);
@ -8251,6 +8286,9 @@ void Application::processPendingTransportDoodads() {
++it;
}
}
// Finalize the upload batch — submit all GPU copies in one shot (async, no wait).
if (vkCtx) vkCtx->endUploadBatch();
}
void Application::processPendingMount() {

File diff suppressed because it is too large Load diff

View file

@ -1031,6 +1031,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Legacy UPDATE_OBJECT spline layout used by many servers:
// timePassed, duration, splineId, durationMod, durationModNext,
// [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)],
// verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint.
const size_t legacyStart = packet.getReadPos();
if (!bytesAvailable(12 + 8 + 8 + 4)) return false;
@ -1039,6 +1040,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
/*uint32_t splineId =*/ packet.readUInt32();
/*float durationMod =*/ packet.readFloat();
/*float durationModNext =*/ packet.readFloat();
// Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
if (!bytesAvailable(5)) return false;
packet.readUInt8(); // animationType
packet.readUInt32(); // animTime
}
/*float verticalAccel =*/ packet.readFloat();
/*uint32_t effectStartTime =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
@ -1722,6 +1729,15 @@ network::Packet InspectPacket::build(uint64_t targetGuid) {
return packet;
}
network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) {
// CMSG_QUERY_INSPECT_ACHIEVEMENTS: uint64 targetGuid + uint8 unk (always 0)
network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS));
packet.writeUInt64(targetGuid);
packet.writeUInt8(0); // unk / achievementSlot — always 0 for WotLK
LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec);
return packet;
}
// ============================================================
// Server Info Commands
// ============================================================
@ -3225,17 +3241,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
data.totalDamage = static_cast<int32_t>(packet.readUInt32());
data.subDamageCount = packet.readUInt8();
// Cap subDamageCount to prevent OOM (each entry is 20 bytes: 4+4+4+4+4)
if (data.subDamageCount > 64) {
LOG_WARNING("AttackerStateUpdate: subDamageCount capped (requested=", (int)data.subDamageCount, ")");
// Cap subDamageCount: each entry is 20 bytes. If the claimed count
// exceeds what the remaining bytes can hold, a GUID was mis-parsed
// (off by one byte), causing the school-mask byte to be read as count.
// In that case silently clamp to the number of full entries that fit.
{
size_t remaining = packet.getSize() - packet.getReadPos();
size_t maxFit = remaining / 20;
if (data.subDamageCount > maxFit) {
data.subDamageCount = static_cast<uint8_t>(maxFit > 0 ? 1 : 0);
} else if (data.subDamageCount > 64) {
data.subDamageCount = 64;
}
}
if (data.subDamageCount == 0) return false;
data.subDamages.reserve(data.subDamageCount);
for (uint8_t i = 0; i < data.subDamageCount; ++i) {
// Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4)
if (packet.getSize() - packet.getReadPos() < 20) {
LOG_WARNING("AttackerStateUpdate: truncated subDamage at index ", (int)i, "/", (int)data.subDamageCount);
data.subDamageCount = i;
break;
}
@ -3250,21 +3274,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
// Validate victimState + overkill fields (8 bytes)
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("AttackerStateUpdate: truncated victimState/overkill");
data.victimState = 0;
data.overkill = 0;
return !data.subDamages.empty();
}
data.victimState = packet.readUInt32();
data.overkill = static_cast<int32_t>(packet.readUInt32());
// WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill.
// Older parsers omitted these, reading overkill from the wrong offset.
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() >= 4) packet.readUInt32(); // unk1 (always 0)
if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack)
data.overkill = (rem() >= 4) ? static_cast<int32_t>(packet.readUInt32()) : -1;
// Read blocked amount (optional, 4 bytes)
if (packet.getSize() - packet.getReadPos() >= 4) {
data.blocked = packet.readUInt32();
} else {
data.blocked = 0;
}
// hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40)
if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32();
else data.blocked = 0;
// RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip
if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain
if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total
LOG_DEBUG("Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
@ -3962,13 +3990,27 @@ network::Packet LootReleasePacket::build(uint64_t lootGuid) {
bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) {
data = LootResponseData{};
if (packet.getSize() - packet.getReadPos() < 14) {
LOG_WARNING("LootResponseParser: packet too short");
size_t avail = packet.getSize() - packet.getReadPos();
// Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with
// lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened,
// needs a key, or another player is looting). We treat this as an empty-loot
// signal and return false so the caller knows not to open the loot window.
if (avail < 9) {
LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)");
return false;
}
data.lootGuid = packet.readUInt64();
data.lootType = packet.readUInt8();
// Short failure packet — no gold/item data follows.
avail = packet.getSize() - packet.getReadPos();
if (avail < 5) {
LOG_DEBUG("LootResponseParser: lootType=", (int)data.lootType, " (empty/failure response)");
return false;
}
data.gold = packet.readUInt32();
uint8_t itemCount = packet.readUInt8();
@ -5429,5 +5471,12 @@ network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name
return p;
}
network::Packet SetTitlePacket::build(int32_t titleBit) {
// CMSG_SET_TITLE: int32 titleBit (-1 = remove active title)
network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE));
p.writeUInt32(static_cast<uint32_t>(titleBit));
return p;
}
} // namespace game
} // namespace wowee

View file

@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
} // end size check
}
// Parse ribbon emitters (WotLK only; vanilla format TBD).
// WotLK M2RibbonEmitter = 0xAC (172) bytes per entry.
static constexpr uint32_t RIBBON_SIZE_WOTLK = 0xAC;
if (header.nRibbonEmitters > 0 && header.ofsRibbonEmitters > 0 &&
header.nRibbonEmitters < 64 && header.version >= 264) {
if (static_cast<size_t>(header.ofsRibbonEmitters) +
static_cast<size_t>(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) {
// Build sequence flags for parseAnimTrack
std::vector<uint32_t> ribSeqFlags;
ribSeqFlags.reserve(model.sequences.size());
for (const auto& seq : model.sequences) {
ribSeqFlags.push_back(seq.flags);
}
for (uint32_t ri = 0; ri < header.nRibbonEmitters; ri++) {
uint32_t base = header.ofsRibbonEmitters + ri * RIBBON_SIZE_WOTLK;
M2RibbonEmitter rib;
rib.ribbonId = readValue<int32_t>(m2Data, base + 0x00);
rib.bone = readValue<uint32_t>(m2Data, base + 0x04);
rib.position.x = readValue<float>(m2Data, base + 0x08);
rib.position.y = readValue<float>(m2Data, base + 0x0C);
rib.position.z = readValue<float>(m2Data, base + 0x10);
// textureIndices M2Array (0x14): count + offset → first element = texture lookup index
{
uint32_t nTex = readValue<uint32_t>(m2Data, base + 0x14);
uint32_t ofsTex = readValue<uint32_t>(m2Data, base + 0x18);
if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) {
rib.textureIndex = readValue<uint16_t>(m2Data, ofsTex);
}
}
// materialIndices M2Array (0x1C): count + offset → first element = material index
{
uint32_t nMat = readValue<uint32_t>(m2Data, base + 0x1C);
uint32_t ofsMat = readValue<uint32_t>(m2Data, base + 0x20);
if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) {
rib.materialIndex = readValue<uint16_t>(m2Data, ofsMat);
}
}
// colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1)
if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x24);
parseAnimTrack(m2Data, disk, rib.colorTrack, TrackType::VEC3, ribSeqFlags);
}
// alphaTrack M2TrackDisk at 0x38 (fixed16: int16/32767)
// Same nested-array layout as parseAnimTrack but keys are int16.
if (base + 0x38 + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x38);
auto& track = rib.alphaTrack;
track.interpolationType = disk.interpolationType;
track.globalSequence = disk.globalSequence;
uint32_t nSeqs = disk.nTimestamps;
if (nSeqs > 0 && nSeqs <= 4096) {
track.sequences.resize(nSeqs);
for (uint32_t s = 0; s < nSeqs; s++) {
if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue;
uint32_t tsHdr = disk.ofsTimestamps + s * 8;
uint32_t keyHdr = disk.ofsKeys + s * 8;
if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue;
uint32_t tsCount = readValue<uint32_t>(m2Data, tsHdr);
uint32_t tsOfs = readValue<uint32_t>(m2Data, tsHdr + 4);
uint32_t kCount = readValue<uint32_t>(m2Data, keyHdr);
uint32_t kOfs = readValue<uint32_t>(m2Data, keyHdr + 4);
if (tsCount == 0 || kCount == 0) continue;
if (tsOfs + tsCount * 4 > m2Data.size()) continue;
if (kOfs + kCount * sizeof(int16_t) > m2Data.size()) continue;
track.sequences[s].timestamps = readArray<uint32_t>(m2Data, tsOfs, tsCount);
auto raw = readArray<int16_t>(m2Data, kOfs, kCount);
track.sequences[s].floatValues.reserve(raw.size());
for (auto v : raw) {
track.sequences[s].floatValues.push_back(
static_cast<float>(v) / 32767.0f);
}
}
}
}
// heightAboveTrack M2TrackDisk at 0x4C (float)
if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x4C);
parseAnimTrack(m2Data, disk, rib.heightAboveTrack, TrackType::FLOAT, ribSeqFlags);
}
// heightBelowTrack M2TrackDisk at 0x60 (float)
if (base + 0x60 + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x60);
parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags);
}
rib.edgesPerSecond = readValue<float>(m2Data, base + 0x74);
rib.edgeLifetime = readValue<float>(m2Data, base + 0x78);
rib.gravity = readValue<float>(m2Data, base + 0x7C);
rib.textureRows = readValue<uint16_t>(m2Data, base + 0x80);
rib.textureCols = readValue<uint16_t>(m2Data, base + 0x82);
if (rib.textureRows == 0) rib.textureRows = 1;
if (rib.textureCols == 0) rib.textureCols = 1;
// Clamp to sane values
if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f;
if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f;
// visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1)
if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) {
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x98);
parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags);
}
model.ribbonEmitters.push_back(std::move(rib));
}
core::Logger::getInstance().debug(" Ribbon emitters: ", model.ribbonEmitters.size());
}
}
// Read collision mesh (bounding triangles/vertices/normals)
if (header.nBoundingVertices > 0 && header.ofsBoundingVertices > 0) {
struct Vec3Disk { float x, y, z; };

View file

@ -1903,7 +1903,9 @@ void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) {
// Directly update stored yaw/pitch (no lossy forward-vector derivation)
yaw -= event.xrel * mouseSensitivity;
float invert = invertMouse ? -1.0f : 1.0f;
// SDL yrel > 0 = mouse moved DOWN. In WoW, mouse-down = look down = pitch decreases.
// invertMouse flips to flight-sim style (mouse-down = look up).
float invert = invertMouse ? 1.0f : -1.0f;
pitch += event.yrel * mouseSensitivity * invert;
// WoW-style pitch limits: can look almost straight down, limited upward

View file

@ -540,6 +540,54 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout
.build(device);
}
// --- Build ribbon pipelines ---
// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats = 36 bytes
{
rendering::VkShaderModule ribVert, ribFrag;
ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv");
ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv");
if (ribVert.isValid() && ribFrag.isValid()) {
// Reuse particleTexLayout_ for set 1 (single texture sampler)
VkDescriptorSetLayout ribLayouts[] = {perFrameLayout, particleTexLayout_};
VkPipelineLayoutCreateInfo lci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
lci.setLayoutCount = 2;
lci.pSetLayouts = ribLayouts;
vkCreatePipelineLayout(device, &lci, nullptr, &ribbonPipelineLayout_);
VkVertexInputBindingDescription rBind{};
rBind.binding = 0;
rBind.stride = 9 * sizeof(float);
rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> rAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // pos
{1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // color
{2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, // alpha
{3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, // uv
};
auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline {
return PipelineBuilder()
.setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({rBind}, rAttrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL)
.setColorBlendAttachment(blend)
.setMultisample(vkCtx_->getMsaaSamples())
.setLayout(ribbonPipelineLayout_)
.setRenderPass(mainPass)
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
};
ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha());
ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive());
}
ribVert.destroy(); ribFrag.destroy();
}
// Clean up shader modules
m2Vert.destroy(); m2Frag.destroy();
particleVert.destroy(); particleFrag.destroy();
@ -570,6 +618,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout
bci.size = MAX_GLOW_SPRITES * 9 * sizeof(float);
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &glowVB_, &glowVBAlloc_, &allocInfo);
glowVBMapped_ = allocInfo.pMappedData;
// Ribbon vertex buffer — triangle strip: pos(3)+color(3)+alpha(1)+uv(2)=9 floats/vert
bci.size = MAX_RIBBON_VERTS * 9 * sizeof(float);
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &ribbonVB_, &ribbonVBAlloc_, &allocInfo);
ribbonVBMapped_ = allocInfo.pMappedData;
}
// --- Create white fallback texture ---
@ -666,10 +719,11 @@ void M2Renderer::shutdown() {
whiteTexture_.reset();
glowTexture_.reset();
// Clean up particle buffers
// Clean up particle/ribbon buffers
if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; }
if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; }
if (glowVB_) { vmaDestroyBuffer(alloc, glowVB_, glowVBAlloc_); glowVB_ = VK_NULL_HANDLE; }
if (ribbonVB_) { vmaDestroyBuffer(alloc, ribbonVB_, ribbonVBAlloc_); ribbonVB_ = VK_NULL_HANDLE; }
smokeParticles.clear();
// Destroy pipelines
@ -681,10 +735,13 @@ void M2Renderer::shutdown() {
destroyPipeline(particlePipeline_);
destroyPipeline(particleAdditivePipeline_);
destroyPipeline(smokePipeline_);
destroyPipeline(ribbonPipeline_);
destroyPipeline(ribbonAdditivePipeline_);
if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; }
if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; }
if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; }
if (ribbonPipelineLayout_) { vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); ribbonPipelineLayout_ = VK_NULL_HANDLE; }
// Destroy descriptor pools and layouts
if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; }
@ -719,6 +776,11 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) {
if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; }
}
model.particleTexSets.clear();
// Free ribbon texture descriptor sets
for (auto& rSet : model.ribbonTexSets) {
if (rSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &rSet); rSet = VK_NULL_HANDLE; }
}
model.ribbonTexSets.clear();
}
void M2Renderer::destroyInstanceBones(M2Instance& inst) {
@ -882,8 +944,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
bool hasGeometry = !model.vertices.empty() && !model.indices.empty();
bool hasParticles = !model.particleEmitters.empty();
if (!hasGeometry && !hasParticles) {
LOG_WARNING("M2 model has no geometry and no particles: ", model.name);
bool hasRibbons = !model.ribbonEmitters.empty();
if (!hasGeometry && !hasParticles && !hasRibbons) {
LOG_WARNING("M2 model has no renderable content: ", model.name);
return false;
}
@ -1345,6 +1408,43 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
}
}
// Copy ribbon emitter data and resolve textures
gpuModel.ribbonEmitters = model.ribbonEmitters;
if (!model.ribbonEmitters.empty()) {
VkDevice device = vkCtx_->getDevice();
gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get());
gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE);
for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) {
// Resolve texture via textureLookup table
uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex;
uint32_t texIdx = (texLookupIdx < model.textureLookup.size())
? model.textureLookup[texLookupIdx] : UINT32_MAX;
if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) {
gpuModel.ribbonTextures[ri] = allTextures[texIdx];
}
// Allocate descriptor set (reuse particleTexLayout_ = single sampler)
if (particleTexLayout_ && materialDescPool_) {
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = materialDescPool_;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &particleTexLayout_;
if (vkAllocateDescriptorSets(device, &ai, &gpuModel.ribbonTexSets[ri]) == VK_SUCCESS) {
VkTexture* tex = gpuModel.ribbonTextures[ri];
VkDescriptorImageInfo imgInfo = tex->descriptorInfo();
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = gpuModel.ribbonTexSets[ri];
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
}
}
}
LOG_DEBUG(" Ribbon emitters loaded: ", model.ribbonEmitters.size());
}
// Copy texture transform data for UV animation
gpuModel.textureTransforms = model.textureTransforms;
gpuModel.textureTransformLookup = model.textureTransformLookup;
@ -2241,6 +2341,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
if (!instance.cachedModel) continue;
emitParticles(instance, *instance.cachedModel, deltaTime);
updateParticles(instance, deltaTime);
if (!instance.cachedModel->ribbonEmitters.empty()) {
updateRibbons(instance, *instance.cachedModel, deltaTime);
}
}
}
@ -3375,6 +3478,214 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) {
}
}
// ---------------------------------------------------------------------------
// Ribbon emitter simulation
// ---------------------------------------------------------------------------
void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) {
const auto& emitters = gpu.ribbonEmitters;
if (emitters.empty()) return;
// Grow per-instance state arrays if needed
if (inst.ribbonEdges.size() != emitters.size()) {
inst.ribbonEdges.resize(emitters.size());
}
if (inst.ribbonEdgeAccumulators.size() != emitters.size()) {
inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f);
}
for (size_t ri = 0; ri < emitters.size(); ri++) {
const auto& em = emitters[ri];
auto& edges = inst.ribbonEdges[ri];
auto& accum = inst.ribbonEdgeAccumulators[ri];
// Determine bone world position for spine
glm::vec3 spineWorld = inst.position;
if (em.bone < inst.boneMatrices.size()) {
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local);
} else {
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
spineWorld = glm::vec3(inst.modelMatrix * local);
}
// Evaluate animated tracks (use first available sequence key, or fallback value)
auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float {
for (const auto& seq : track.sequences) {
if (!seq.floatValues.empty()) return seq.floatValues[0];
}
return fallback;
};
auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 {
for (const auto& seq : track.sequences) {
if (!seq.vec3Values.empty()) return seq.vec3Values[0];
}
return fallback;
};
float visibility = getFloatVal(em.visibilityTrack, 1.0f);
float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f);
float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f);
glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f));
float alpha = getFloatVal(em.alphaTrack, 1.0f);
// Age existing edges and remove expired ones
for (auto& e : edges) {
e.age += dt;
// Apply gravity
if (em.gravity != 0.0f) {
e.worldPos.z -= em.gravity * dt * dt * 0.5f;
}
}
while (!edges.empty() && edges.front().age >= em.edgeLifetime) {
edges.pop_front();
}
// Emit new edges based on edgesPerSecond
if (visibility > 0.5f) {
accum += em.edgesPerSecond * dt;
while (accum >= 1.0f) {
accum -= 1.0f;
M2Instance::RibbonEdge e;
e.worldPos = spineWorld;
e.color = color;
e.alpha = alpha;
e.heightAbove = heightAbove;
e.heightBelow = heightBelow;
e.age = 0.0f;
edges.push_back(e);
// Cap trail length
if (edges.size() > 128) edges.pop_front();
}
} else {
accum = 0.0f;
}
}
}
// ---------------------------------------------------------------------------
// Ribbon rendering
// ---------------------------------------------------------------------------
void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return;
// Build camera right vector for billboard orientation
// For ribbons we orient the quad strip along the spine with screen-space up.
// Simple approach: use world-space Z=up for the ribbon cross direction.
const glm::vec3 upWorld(0.0f, 0.0f, 1.0f);
float* dst = static_cast<float*>(ribbonVBMapped_);
size_t written = 0;
struct DrawCall {
VkDescriptorSet texSet;
VkPipeline pipeline;
uint32_t firstVertex;
uint32_t vertexCount;
};
std::vector<DrawCall> draws;
for (const auto& inst : instances) {
if (!inst.cachedModel) continue;
const auto& gpu = *inst.cachedModel;
if (gpu.ribbonEmitters.empty()) continue;
for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) {
if (ri >= inst.ribbonEdges.size()) continue;
const auto& edges = inst.ribbonEdges[ri];
if (edges.size() < 2) continue;
const auto& em = gpu.ribbonEmitters[ri];
// Select blend pipeline based on material blend mode
bool additive = false;
if (em.materialIndex < gpu.batches.size()) {
additive = (gpu.batches[em.materialIndex].blendMode >= 3);
}
VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_;
// Descriptor set for texture
VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size())
? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE;
if (!texSet) continue;
uint32_t firstVert = static_cast<uint32_t>(written);
// Emit triangle strip: 2 verts per edge (top + bottom)
for (size_t ei = 0; ei < edges.size(); ei++) {
if (written + 2 > MAX_RIBBON_VERTS) break;
const auto& e = edges[ei];
float t = (em.edgeLifetime > 0.0f)
? 1.0f - (e.age / em.edgeLifetime) : 1.0f;
float a = e.alpha * t;
float u = static_cast<float>(ei) / static_cast<float>(edges.size() - 1);
// Top vertex (above spine along upWorld)
glm::vec3 top = e.worldPos + upWorld * e.heightAbove;
dst[written * 9 + 0] = top.x;
dst[written * 9 + 1] = top.y;
dst[written * 9 + 2] = top.z;
dst[written * 9 + 3] = e.color.r;
dst[written * 9 + 4] = e.color.g;
dst[written * 9 + 5] = e.color.b;
dst[written * 9 + 6] = a;
dst[written * 9 + 7] = u;
dst[written * 9 + 8] = 0.0f; // v = top
written++;
// Bottom vertex (below spine)
glm::vec3 bot = e.worldPos - upWorld * e.heightBelow;
dst[written * 9 + 0] = bot.x;
dst[written * 9 + 1] = bot.y;
dst[written * 9 + 2] = bot.z;
dst[written * 9 + 3] = e.color.r;
dst[written * 9 + 4] = e.color.g;
dst[written * 9 + 5] = e.color.b;
dst[written * 9 + 6] = a;
dst[written * 9 + 7] = u;
dst[written * 9 + 8] = 1.0f; // v = bottom
written++;
}
uint32_t vertCount = static_cast<uint32_t>(written) - firstVert;
if (vertCount >= 4) {
draws.push_back({texSet, pipe, firstVert, vertCount});
} else {
// Rollback if too few verts
written = firstVert;
}
}
}
if (draws.empty() || written == 0) return;
VkExtent2D ext = vkCtx_->getSwapchainExtent();
VkViewport vp{};
vp.x = 0; vp.y = 0;
vp.width = static_cast<float>(ext.width);
vp.height = static_cast<float>(ext.height);
vp.minDepth = 0.0f; vp.maxDepth = 1.0f;
VkRect2D sc{};
sc.offset = {0, 0};
sc.extent = ext;
vkCmdSetViewport(cmd, 0, 1, &vp);
vkCmdSetScissor(cmd, 0, 1, &sc);
VkPipeline lastPipe = VK_NULL_HANDLE;
for (const auto& dc : draws) {
if (dc.pipeline != lastPipe) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr);
lastPipe = dc.pipeline;
}
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr);
VkDeviceSize offset = 0;
vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset);
vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0);
}
}
void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
if (!particlePipeline_ || !m2ParticleVB_) return;
@ -4505,6 +4816,8 @@ void M2Renderer::recreatePipelines() {
if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; }
if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; }
if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; }
if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; }
if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; }
// --- Load shaders ---
rendering::VkShaderModule m2Vert, m2Frag;
@ -4624,6 +4937,46 @@ void M2Renderer::recreatePipelines() {
.build(device);
}
// --- Ribbon pipelines ---
{
rendering::VkShaderModule ribVert, ribFrag;
ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv");
ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv");
if (ribVert.isValid() && ribFrag.isValid()) {
VkVertexInputBindingDescription rBind{};
rBind.binding = 0;
rBind.stride = 9 * sizeof(float);
rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> rAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0},
{1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)},
{2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)},
{3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)},
};
auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline {
return PipelineBuilder()
.setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({rBind}, rAttrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL)
.setColorBlendAttachment(blend)
.setMultisample(vkCtx_->getMsaaSamples())
.setLayout(ribbonPipelineLayout_)
.setRenderPass(mainPass)
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
};
ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha());
ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive());
}
ribVert.destroy(); ribFrag.destroy();
}
m2Vert.destroy(); m2Frag.destroy();
particleVert.destroy(); particleFrag.destroy();
smokeVert.destroy(); smokeFrag.destroy();

View file

@ -3865,7 +3865,13 @@ void Renderer::setFSREnabled(bool enabled) {
if (fsr_.enabled == enabled) return;
fsr_.enabled = enabled;
if (!enabled) {
if (enabled) {
// FSR1 upscaling renders its own AA — disable MSAA to avoid redundant work
if (vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) {
pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT;
msaaChangePending_ = true;
}
} else {
// Defer destruction to next beginFrame() — can't destroy mid-render
fsr_.needsRecreate = true;
}
@ -4962,11 +4968,11 @@ bool Renderer::initFXAAResources() {
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
// Pipeline layout — push constant holds vec2 rcpFrame
// Pipeline layout — push constant holds vec4(rcpFrame.xy, sharpness, pad)
VkPushConstantRange pc{};
pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
pc.offset = 0;
pc.size = 8; // vec2
pc.size = 16; // vec4
VkPipelineLayoutCreateInfo plCI{};
plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
plCI.setLayoutCount = 1;
@ -5038,19 +5044,31 @@ void Renderer::renderFXAAPass() {
vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr);
// Push rcpFrame = vec2(1/width, 1/height)
float rcpFrame[2] = {
// Pass rcpFrame + sharpness + desaturate (vec4, 16 bytes).
// When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the
// post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes.
float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f;
float pc[4] = {
1.0f / static_cast<float>(ext.width),
1.0f / static_cast<float>(ext.height)
1.0f / static_cast<float>(ext.height),
sharpness,
ghostMode_ ? 1.0f : 0.0f // desaturate: 1=ghost grayscale, 0=normal
};
vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout,
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame);
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc);
vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle
}
void Renderer::setFXAAEnabled(bool enabled) {
if (fxaa_.enabled == enabled) return;
// FXAA is a post-process AA pass intended to supplement FSR temporal output.
// It conflicts with MSAA (which resolves AA during the scene render pass), so
// refuse to enable FXAA when hardware MSAA is active.
if (enabled && vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) {
LOG_INFO("FXAA: blocked while MSAA is active — disable MSAA first");
return;
}
fxaa_.enabled = enabled;
if (!enabled) {
fxaa_.needsRecreate = true; // defer destruction to next beginFrame()
@ -5074,6 +5092,9 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
lastWMORenderMs = 0.0;
lastM2RenderMs = 0.0;
// Cache ghost state for use in overlay and FXAA passes this frame.
ghostMode_ = (gameHandler && gameHandler->isPlayerGhost());
uint32_t frameIdx = vkCtx->getCurrentFrame();
VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx];
const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f);
@ -5138,6 +5159,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
m2Renderer->render(cmd, perFrameSet, *camera);
m2Renderer->renderSmokeParticles(cmd, perFrameSet);
m2Renderer->renderM2Particles(cmd, perFrameSet);
m2Renderer->renderM2Ribbons(cmd, perFrameSet);
vkEndCommandBuffer(cmd);
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - t0).count();
@ -5219,6 +5241,12 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
renderOverlay(tint, cmd);
}
}
// Ghost mode desaturation overlay (non-FXAA path approximation).
// When FXAA is active the FXAA shader applies true per-pixel desaturation;
// otherwise a high-opacity gray overlay gives a similar washed-out effect.
if (ghostMode_ && overlayPipeline && !fxaa_.enabled) {
renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f), cmd);
}
if (minimap && minimap->isEnabled() && camera && window) {
glm::vec3 minimapCenter = camera->getPosition();
if (cameraController && cameraController->isThirdPerson())
@ -5228,14 +5256,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
if (cameraController) {
float facingRad = glm::radians(characterYaw);
glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f);
minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y);
// atan2(-x,y) = canonical yaw (0=North); negate for shader convention.
minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y);
hasMinimapPlayerOrientation = true;
} else if (gameHandler) {
// 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);
// movementInfo.orientation is canonical yaw: 0=North, π/2=East.
// Minimap shader: arrowRotation=0 points up (North), positive rotates CW
// (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw.
minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation;
hasMinimapPlayerOrientation = true;
}
minimap->render(cmd, *camera, minimapCenter,
@ -5317,6 +5345,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
m2Renderer->render(currentCmd, perFrameSet, *camera);
m2Renderer->renderSmokeParticles(currentCmd, perFrameSet);
m2Renderer->renderM2Particles(currentCmd, perFrameSet);
m2Renderer->renderM2Ribbons(currentCmd, perFrameSet);
lastM2RenderMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - m2Start).count();
}
@ -5351,6 +5380,10 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
renderOverlay(tint);
}
}
// Ghost mode desaturation overlay (non-FXAA path approximation).
if (ghostMode_ && overlayPipeline && !fxaa_.enabled) {
renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f));
}
if (minimap && minimap->isEnabled() && camera && window) {
glm::vec3 minimapCenter = camera->getPosition();
if (cameraController && cameraController->isThirdPerson())
@ -5360,14 +5393,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
if (cameraController) {
float facingRad = glm::radians(characterYaw);
glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f);
minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y);
// atan2(-x,y) = canonical yaw (0=North); negate for shader convention.
minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y);
hasMinimapPlayerOrientation = true;
} else if (gameHandler) {
// 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);
// movementInfo.orientation is canonical yaw: 0=North, π/2=East.
// Minimap shader: arrowRotation=0 points up (North), positive rotates CW
// (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw.
minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation;
hasMinimapPlayerOrientation = true;
}
minimap->render(currentCmd, *camera, minimapCenter,

View file

@ -1377,6 +1377,10 @@ void TerrainManager::unloadTile(int x, int y) {
// Water may have already been loaded in TERRAIN phase, so clean it up.
for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) {
if (fit->pending && fit->pending->coord == coord) {
// If terrain chunks were already uploaded, free their descriptor sets
if (fit->terrainMeshDone && terrainRenderer) {
terrainRenderer->removeTile(x, y);
}
// If past TERRAIN phase, water was already loaded — remove it
if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) {
waterRenderer->removeTile(x, y);

View file

@ -805,6 +805,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
return true;
}
bool WMORenderer::isModelLoaded(uint32_t id) const {
return loadedModels.find(id) != loadedModels.end();
}
void WMORenderer::unloadModel(uint32_t id) {
auto it = loadedModels.find(id);
if (it == loadedModels.end()) {
@ -2063,6 +2067,18 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,
return;
}
// If the camera group has no portal refs, it's a dead-end group (utility/transition group).
// Fall back to showing all groups to avoid the rest of the WMO going invisible.
if (cameraGroup < static_cast<int>(model.groupPortalRefs.size())) {
auto [portalStart, portalCount] = model.groupPortalRefs[cameraGroup];
if (portalCount == 0) {
for (size_t gi = 0; gi < model.groups.size(); gi++) {
outVisibleGroups.insert(static_cast<uint32_t>(gi));
}
return;
}
}
// BFS through portals from camera's group
std::vector<bool> visited(model.groups.size(), false);
std::vector<uint32_t> queue;

View file

@ -842,22 +842,10 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr
if (!zones.empty()) updateExploration(playerRenderPos);
if (open) {
if (input.isKeyJustPressed(SDL_SCANCODE_M) ||
input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) {
open = false;
return;
}
auto& io = ImGui::GetIO();
float wheelDelta = io.MouseWheel;
if (std::abs(wheelDelta) < 0.001f)
wheelDelta = input.getMouseWheelDelta();
if (wheelDelta > 0.0f) zoomIn(playerRenderPos);
else if (wheelDelta < 0.0f) zoomOut();
} else {
auto& io = ImGui::GetIO();
if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) {
// game_screen owns the open/close toggle (via showWorldMap_ + TOGGLE_WORLD_MAP keybinding).
// render() is only called when showWorldMap_ is true, so treat each call as "should be open".
if (!open) {
// First time shown: load zones and navigate to player's location.
open = true;
if (zones.empty()) loadZonesFromDBC();
@ -881,6 +869,20 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr
viewLevel = ViewLevel::CONTINENT;
}
}
// ESC closes the map; game_screen will sync showWorldMap_ via wm->isOpen() next frame.
if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) {
open = false;
return;
}
{
auto& io = ImGui::GetIO();
float wheelDelta = io.MouseWheel;
if (std::abs(wheelDelta) < 0.001f)
wheelDelta = input.getMouseWheelDelta();
if (wheelDelta > 0.0f) zoomIn(playerRenderPos);
else if (wheelDelta < 0.0f) zoomOut();
}
if (!open) return;

View file

@ -687,6 +687,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderGuildInvitePopup(gameHandler);
renderReadyCheckPopup(gameHandler);
renderBgInvitePopup(gameHandler);
renderBfMgrInvitePopup(gameHandler);
renderLfgProposalPopup(gameHandler);
renderGuildRoster(gameHandler);
renderSocialFrame(gameHandler);
@ -710,6 +711,8 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderWhoWindow(gameHandler);
renderCombatLog(gameHandler);
renderAchievementWindow(gameHandler);
renderTitlesWindow(gameHandler);
renderEquipSetWindow(gameHandler);
renderGmTicketWindow(gameHandler);
renderInspectWindow(gameHandler);
renderBookWindow(gameHandler);
@ -2333,6 +2336,12 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
showAchievementWindow_ = !showAchievementWindow_;
}
// Toggle Titles window with H (hero/title screen — no conflicting keybinding)
if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) {
showTitlesWindow_ = !showTitlesWindow_;
}
// Action bar keys (1-9, 0, -, =)
static const SDL_Scancode actionBarKeys[] = {
SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4,
@ -6374,6 +6383,9 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) {
int screenW = window ? window->getWidth() : 1280;
int screenH = window ? window->getHeight() : 720;
wm->render(playerPos, screenW, screenH);
// Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay).
if (!wm->isOpen()) showWorldMap_ = false;
}
// ============================================================
@ -7899,18 +7911,25 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f;
// Default position: top-right, below minimap + buff bar space
if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) {
questTrackerPos_ = ImVec2(screenW - TRACKER_W - RIGHT_MARGIN, 320.0f);
// Default position: top-right, below minimap + buff bar space.
// questTrackerRightOffset_ stores pixels from the right edge so the tracker
// stays anchored to the right side when the window is resized.
if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) {
questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned
questTrackerPos_.y = 320.0f;
questTrackerPosInit_ = true;
}
// Recompute X from right offset every frame (handles window resize)
questTrackerPos_.x = screenW - questTrackerRightOffset_;
ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always);
ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus;
ImGuiWindowFlags_NoBringToFrontOnFocus;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f));
@ -7926,7 +7945,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
: ImVec4(1.0f, 1.0f, 0.85f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_Text, titleCol);
if (ImGui::Selectable(q.title.c_str(), false,
ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) {
ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) {
questLogScreen.openAndSelectQuest(q.questId);
}
if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) {
@ -8049,15 +8068,28 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
}
}
// Capture position after drag
// Capture position and size after drag/resize
ImVec2 newPos = ImGui::GetWindowPos();
ImVec2 newSize = ImGui::GetWindowSize();
bool changed = false;
// Clamp within screen
newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x);
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f ||
std::abs(newPos.y - questTrackerPos_.y) > 0.5f) {
newPos.x = std::clamp(newPos.x, 0.0f, screenW - TRACKER_W);
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
questTrackerPos_ = newPos;
saveSettings();
// Update right offset so resizes keep the new position anchored
questTrackerRightOffset_ = screenW - newPos.x;
changed = true;
}
if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f ||
std::abs(newSize.y - questTrackerSize_.y) > 0.5f) {
questTrackerSize_ = newSize;
changed = true;
}
if (changed) saveSettings();
}
ImGui::End();
@ -10935,6 +10967,63 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) {
ImGui::PopStyleColor(3);
}
void GameScreen::renderBfMgrInvitePopup(game::GameHandler& gameHandler) {
// Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager)
if (!gameHandler.hasBfMgrInvite()) 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 - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
const ImGuiWindowFlags flags =
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
if (ImGui::Begin("Battlefield", nullptr, flags)) {
// Resolve zone name for Wintergrasp (zoneId 4197)
uint32_t zoneId = gameHandler.getBfMgrZoneId();
const char* zoneName = nullptr;
if (zoneId == 4197) zoneName = "Wintergrasp";
else if (zoneId == 5095) zoneName = "Tol Barad";
if (zoneName) {
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName);
} else {
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield");
}
ImGui::Spacing();
ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?");
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 Battlefield", ImVec2(178, 28))) {
gameHandler.acceptBfMgrInvite();
}
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(175, 28))) {
gameHandler.declineBfMgrInvite();
}
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;
@ -11842,11 +11931,11 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) {
ImGui::EndTabItem();
}
// ---- Arena tab (WotLK: shows per-team rating/record) ----
// ---- Arena tab (WotLK: shows per-team rating/record + roster) ----
const auto& arenaStats = gameHandler.getArenaTeamStats();
if (!arenaStats.empty()) {
if (ImGui::BeginTabItem("Arena")) {
ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false);
ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false);
for (size_t ai = 0; ai < arenaStats.size(); ++ai) {
const auto& ts = arenaStats[ai];
@ -11875,6 +11964,49 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) {
? ts.seasonGames - ts.seasonWins : 0;
ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses);
// Roster members (from SMSG_ARENA_TEAM_ROSTER)
const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId);
if (roster && !roster->members.empty()) {
ImGui::Spacing();
ImGui::TextDisabled("-- Roster (%zu members) --",
roster->members.size());
// Column headers
ImGui::Columns(4, "##arenaRosterCols", false);
ImGui::SetColumnWidth(0, 110.0f);
ImGui::SetColumnWidth(1, 60.0f);
ImGui::SetColumnWidth(2, 60.0f);
ImGui::SetColumnWidth(3, 60.0f);
ImGui::TextDisabled("Name"); ImGui::NextColumn();
ImGui::TextDisabled("Rating"); ImGui::NextColumn();
ImGui::TextDisabled("Week"); ImGui::NextColumn();
ImGui::TextDisabled("Season"); ImGui::NextColumn();
ImGui::Separator();
for (const auto& m : roster->members) {
// Name coloured green (online) or grey (offline)
if (m.online)
ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f),
"%s", m.name.c_str());
else
ImGui::TextDisabled("%s", m.name.c_str());
ImGui::NextColumn();
ImGui::Text("%u", m.personalRating);
ImGui::NextColumn();
uint32_t wL = m.weekGames > m.weekWins
? m.weekGames - m.weekWins : 0;
ImGui::Text("%uW/%uL", m.weekWins, wL);
ImGui::NextColumn();
uint32_t sL = m.seasonGames > m.seasonWins
? m.seasonGames - m.seasonWins : 0;
ImGui::Text("%uW/%uL", m.seasonWins, sL);
ImGui::NextColumn();
}
ImGui::Columns(1);
}
ImGui::Unindent(8.0f);
if (ai + 1 < arenaStats.size())
@ -15443,7 +15575,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
float sinB = 0.0f;
if (minimap->isRotateWithCamera()) {
glm::vec3 fwd = camera->getForward();
bearing = std::atan2(-fwd.x, fwd.y);
// Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)).
// Clockwise bearing from North: atan2(fwd.y, -fwd.x).
bearing = std::atan2(fwd.y, -fwd.x);
cosB = std::cos(bearing);
sinB = std::sin(bearing);
}
@ -15481,7 +15615,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
// The player is always at centerX, centerY on the minimap.
// Draw a yellow arrow pointing in the player's facing direction.
glm::vec3 fwd = camera->getForward();
float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north
float facing = std::atan2(fwd.y, -fwd.x); // clockwise bearing from North
float cosF = std::cos(facing - bearing);
float sinF = std::sin(facing - bearing);
float arrowLen = 8.0f;
@ -16846,9 +16980,11 @@ void GameScreen::saveSettings() {
out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n";
out << "fov=" << pendingFov << "\n";
// Quest tracker position
out << "quest_tracker_x=" << questTrackerPos_.x << "\n";
// Quest tracker position/size
out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n";
out << "quest_tracker_y=" << questTrackerPos_.y << "\n";
out << "quest_tracker_w=" << questTrackerSize_.x << "\n";
out << "quest_tracker_h=" << questTrackerSize_.y << "\n";
// Chat
out << "chat_active_tab=" << activeChatTab_ << "\n";
@ -16994,15 +17130,25 @@ void GameScreen::loadSettings() {
if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov);
}
}
// Quest tracker position
// Quest tracker position/size
else if (key == "quest_tracker_x") {
questTrackerPos_.x = std::stof(val);
// Legacy: ignore absolute X (right_offset supersedes it)
(void)val;
}
else if (key == "quest_tracker_right_offset") {
questTrackerRightOffset_ = std::stof(val);
questTrackerPosInit_ = true;
}
else if (key == "quest_tracker_y") {
questTrackerPos_.y = std::stof(val);
questTrackerPosInit_ = true;
}
else if (key == "quest_tracker_w") {
questTrackerSize_.x = std::max(100.0f, std::stof(val));
}
else if (key == "quest_tracker_h") {
questTrackerSize_.y = std::max(60.0f, std::stof(val));
}
// Chat
else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3);
else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0);
@ -20107,9 +20253,15 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) {
// ─── GM Ticket Window ─────────────────────────────────────────────────────────
void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
// Fire a one-shot query when the window first becomes visible
if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) {
gameHandler.requestGmTicket();
}
gmTicketWindowWasOpen_ = showGmTicketWindow_;
if (!showGmTicketWindow_) return;
ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_,
@ -20118,10 +20270,33 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
return;
}
// Show GM support availability
if (!gameHandler.isGmSupportAvailable()) {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "GM support is currently unavailable.");
ImGui::Spacing();
}
// Show existing open ticket if any
if (gameHandler.hasActiveGmTicket()) {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "You have an open GM ticket.");
const std::string& existingText = gameHandler.getGmTicketText();
if (!existingText.empty()) {
ImGui::TextWrapped("Current ticket: %s", existingText.c_str());
}
float waitHours = gameHandler.getGmTicketWaitHours();
if (waitHours > 0.0f) {
char waitBuf[64];
std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours);
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf);
}
ImGui::Separator();
ImGui::Spacing();
}
ImGui::TextWrapped("Describe your issue and a Game Master will contact you.");
ImGui::Spacing();
ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_),
ImVec2(-1, 160));
ImVec2(-1, 120));
ImGui::Spacing();
bool hasText = (gmTicketBuf_[0] != '\0');
@ -20138,8 +20313,11 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
showGmTicketWindow_ = false;
}
ImGui::SameLine();
if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) {
if (gameHandler.hasActiveGmTicket()) {
if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) {
gameHandler.deleteGmTicket();
showGmTicketWindow_ = false;
}
}
ImGui::End();
@ -20255,7 +20433,8 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) {
ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver);
const char* title = "Battleground Score###BgScore";
const char* title = data && data->isArena ? "Arena Score###BgScore"
: "Battleground Score###BgScore";
if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
return;
@ -20263,16 +20442,46 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) {
if (!data) {
ImGui::TextDisabled("No score data yet.");
ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground.");
ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena.");
ImGui::End();
return;
}
// Arena team rating banner (shown only for arenas)
if (data->isArena) {
for (int t = 0; t < 2; ++t) {
const auto& at = data->arenaTeams[t];
if (at.teamName.empty()) continue;
int32_t ratingDelta = static_cast<int32_t>(at.ratingChange);
ImVec4 teamCol = (t == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) // team 0: red
: ImVec4(0.4f, 0.6f, 1.0f, 1.0f); // team 1: blue
ImGui::TextColored(teamCol, "%s", at.teamName.c_str());
ImGui::SameLine();
char ratingBuf[32];
if (ratingDelta >= 0)
std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta);
else
std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta);
ImGui::TextDisabled("%s", ratingBuf);
}
ImGui::Separator();
}
// Winner banner
if (data->hasWinner) {
const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde";
ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f)
const char* winnerStr;
ImVec4 winnerColor;
if (data->isArena) {
// For arenas, winner byte 0/1 refers to team index in arenaTeams[]
const auto& winTeam = data->arenaTeams[data->winner & 1];
winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str();
winnerColor = (data->winner == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f)
: ImVec4(0.4f, 0.6f, 1.0f, 1.0f);
} else {
winnerStr = (data->winner == 1) ? "Alliance" : "Horde";
winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f)
: ImVec4(1.0f, 0.35f, 0.35f, 1.0f);
}
float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x;
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f);
ImGui::TextColored(winnerColor, "%s", winnerStr);
@ -20642,6 +20851,161 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) {
ImGui::EndChild();
}
// Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS)
if (!result->arenaTeams.empty()) {
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams");
ImGui::Spacing();
for (const auto& team : result->arenaTeams) {
const char* bracket = (team.type == 2) ? "2v2"
: (team.type == 3) ? "3v3"
: (team.type == 5) ? "5v5" : "?v?";
ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f),
"[%s] %s", bracket, team.name.c_str());
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f),
" Rating: %u", team.personalRating);
if (team.weekGames > 0 || team.seasonGames > 0) {
ImGui::TextDisabled(" Week: %u/%u Season: %u/%u",
team.weekWins, team.weekGames,
team.seasonWins, team.seasonGames);
}
}
}
ImGui::End();
}
// ─── Titles Window ────────────────────────────────────────────────────────────
void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) {
if (!showTitlesWindow_) return;
ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Titles", &showTitlesWindow_)) {
ImGui::End();
return;
}
const auto& knownBits = gameHandler.getKnownTitleBits();
const int32_t chosen = gameHandler.getChosenTitleBit();
if (knownBits.empty()) {
ImGui::TextDisabled("No titles earned yet.");
ImGui::End();
return;
}
ImGui::TextUnformatted("Select a title to display:");
ImGui::Separator();
// "No Title" option
bool noTitle = (chosen < 0);
if (ImGui::Selectable("(No Title)", noTitle)) {
if (!noTitle) gameHandler.sendSetTitle(-1);
}
if (noTitle) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "<-- active");
}
ImGui::Separator();
// Sort known bits for stable display order
std::vector<uint32_t> sortedBits(knownBits.begin(), knownBits.end());
std::sort(sortedBits.begin(), sortedBits.end());
ImGui::BeginChild("##titlelist", ImVec2(0, 0), false);
for (uint32_t bit : sortedBits) {
const std::string title = gameHandler.getFormattedTitle(bit);
const std::string display = title.empty()
? ("Title #" + std::to_string(bit)) : title;
bool isActive = (chosen >= 0 && static_cast<uint32_t>(chosen) == bit);
ImGui::PushID(static_cast<int>(bit));
if (isActive) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f));
}
if (ImGui::Selectable(display.c_str(), isActive)) {
if (!isActive) gameHandler.sendSetTitle(static_cast<int32_t>(bit));
}
if (isActive) {
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextDisabled("<-- active");
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::End();
}
// ─── Equipment Set Manager Window ─────────────────────────────────────────────
void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) {
if (!showEquipSetWindow_) return;
ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) {
ImGui::End();
return;
}
const auto& sets = gameHandler.getEquipmentSets();
if (sets.empty()) {
ImGui::TextDisabled("No equipment sets saved.");
ImGui::Spacing();
ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button).");
ImGui::End();
return;
}
ImGui::TextUnformatted("Click a set to equip it:");
ImGui::Separator();
ImGui::Spacing();
ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false);
for (const auto& set : sets) {
ImGui::PushID(static_cast<int>(set.setId));
// Icon placeholder (use a coloured square if no icon texture available)
ImVec2 iconSize(32.0f, 32.0f);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f));
if (ImGui::Button("##icon", iconSize)) {
gameHandler.useEquipmentSet(set.setId);
}
ImGui::PopStyleColor(3);
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Equip set: %s", set.name.c_str());
}
ImGui::SameLine();
// Name and equip button
ImGui::BeginGroup();
ImGui::TextUnformatted(set.name.c_str());
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f));
if (ImGui::SmallButton("Equip")) {
gameHandler.useEquipmentSet(set.setId);
}
ImGui::PopStyleColor(2);
ImGui::EndGroup();
ImGui::Spacing();
ImGui::PopID();
}
ImGui::EndChild();
ImGui::End();
}

View file

@ -1420,10 +1420,13 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
ImGui::BeginChild("##ReputationList", ImVec2(0, 0), true);
// Sort factions alphabetically by name
// Sort: watched faction first, then alphabetically by name
uint32_t watchedFactionId = gameHandler.getWatchedFactionId();
std::vector<std::pair<uint32_t, int32_t>> sortedFactions(standings.begin(), standings.end());
std::sort(sortedFactions.begin(), sortedFactions.end(),
[&](const auto& a, const auto& b) {
if (a.first == watchedFactionId) return true;
if (b.first == watchedFactionId) return false;
const std::string& na = gameHandler.getFactionNamePublic(a.first);
const std::string& nb = gameHandler.getFactionNamePublic(b.first);
return na < nb;
@ -1435,10 +1438,25 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
const std::string& factionName = gameHandler.getFactionNamePublic(factionId);
const char* displayName = factionName.empty() ? "Unknown Faction" : factionName.c_str();
// Faction name + tier label on same line
// Determine at-war status via repListId lookup
uint32_t repListId = gameHandler.getRepListIdByFactionId(factionId);
bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId);
bool isWatched = (factionId == watchedFactionId);
// Faction name + tier label on same line; mark at-war and watched factions
ImGui::TextColored(tier.color, "[%s]", tier.name);
ImGui::SameLine(90.0f);
if (atWar) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", displayName);
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "(At War)");
} else if (isWatched) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "%s", displayName);
ImGui::SameLine();
ImGui::TextDisabled("(Tracked)");
} else {
ImGui::Text("%s", displayName);
}
// Progress bar showing position within current tier
float ratio = 0.0f;
@ -1594,6 +1612,8 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
// Secondary stat sums from extraStats
int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0;
int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0;
int32_t itemDefense = 0, itemDodge = 0, itemParry = 0, itemBlock = 0, itemBlockVal = 0;
int32_t itemArmorPen = 0, itemSpellPen = 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;
@ -1604,6 +1624,10 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
itemSpi += slot.item.spirit;
for (const auto& es : slot.item.extraStats) {
switch (es.statType) {
case 12: itemDefense += es.statValue; break;
case 13: itemDodge += es.statValue; break;
case 14: itemParry += es.statValue; break;
case 15: itemBlock += es.statValue; break;
case 16: case 17: case 18: case 31: itemHit += es.statValue; break;
case 19: case 20: case 21: case 32: itemCrit += es.statValue; break;
case 28: case 29: case 30: case 36: itemHaste += es.statValue; break;
@ -1612,7 +1636,10 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
case 38: case 39: itemAP += es.statValue; break;
case 41: case 42: case 45: itemSP += es.statValue; break;
case 43: itemMp5 += es.statValue; break;
case 44: itemArmorPen += es.statValue; break;
case 46: itemHp5 += es.statValue; break;
case 47: itemSpellPen += es.statValue; break;
case 48: itemBlockVal += es.statValue; break;
default: break;
}
}
@ -1699,7 +1726,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
// Secondary stats from equipped items
bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste ||
itemResil || itemExpertise || itemMp5 || itemHp5;
itemResil || itemExpertise || itemMp5 || itemHp5 ||
itemDefense || itemDodge || itemParry || itemBlock || itemBlockVal ||
itemArmorPen || itemSpellPen;
if (hasSecondary) {
ImGui::Spacing();
ImGui::Separator();
@ -1715,8 +1744,15 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
renderSecondary("Haste Rating", itemHaste);
renderSecondary("Resilience", itemResil);
renderSecondary("Expertise", itemExpertise);
renderSecondary("Defense Rating", itemDefense);
renderSecondary("Dodge Rating", itemDodge);
renderSecondary("Parry Rating", itemParry);
renderSecondary("Block Rating", itemBlock);
renderSecondary("Block Value", itemBlockVal);
renderSecondary("Armor Penetration",itemArmorPen);
renderSecondary("Spell Penetration",itemSpellPen);
renderSecondary("Mana per 5 sec", itemMp5);
renderSecondary("Health per 5 sec",itemHp5);
renderSecondary("Health per 5 sec", itemHp5);
}
// Elemental resistances from server update fields

View file

@ -22,15 +22,15 @@ void KeybindingManager::initializeDefaults() {
bindings_[static_cast<int>(Action::TOGGLE_SPELLBOOK)] = ImGuiKey_P; // WoW standard key
bindings_[static_cast<int>(Action::TOGGLE_TALENTS)] = ImGuiKey_N; // WoW standard key
bindings_[static_cast<int>(Action::TOGGLE_QUESTS)] = ImGuiKey_L;
bindings_[static_cast<int>(Action::TOGGLE_MINIMAP)] = ImGuiKey_M;
bindings_[static_cast<int>(Action::TOGGLE_MINIMAP)] = ImGuiKey_None; // minimap is always visible; no default toggle
bindings_[static_cast<int>(Action::TOGGLE_SETTINGS)] = ImGuiKey_Escape;
bindings_[static_cast<int>(Action::TOGGLE_CHAT)] = ImGuiKey_Enter;
bindings_[static_cast<int>(Action::TOGGLE_GUILD_ROSTER)] = ImGuiKey_O;
bindings_[static_cast<int>(Action::TOGGLE_DUNGEON_FINDER)] = ImGuiKey_J; // Originally I, reassigned to avoid conflict
bindings_[static_cast<int>(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_W;
bindings_[static_cast<int>(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map
bindings_[static_cast<int>(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V;
bindings_[static_cast<int>(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset)
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q;
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_None; // Q conflicts with strafe-left; quest log accessible via TOGGLE_QUESTS (L)
bindings_[static_cast<int>(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail)
}

View file

@ -525,7 +525,7 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle
// Resource cost + cast time on same row (WoW style)
if (!info->isPassive()) {
// Left: resource cost
// Left: resource cost (with talent flat/pct modifier applied)
char costBuf[64] = "";
if (info->manaCost > 0) {
const char* powerName = "Mana";
@ -535,16 +535,26 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle
case 4: powerName = "Focus"; break;
default: break;
}
std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName);
// Apply SMSG_SET_FLAT/PCT_SPELL_MODIFIER Cost modifier (SpellModOp::Cost = 14)
int32_t flatCost = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::Cost);
int32_t pctCost = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::Cost);
uint32_t displayCost = static_cast<uint32_t>(
game::GameHandler::applySpellMod(static_cast<int32_t>(info->manaCost), flatCost, pctCost));
std::snprintf(costBuf, sizeof(costBuf), "%u %s", displayCost, powerName);
}
// Right: cast time
// Right: cast time (with talent CastingTime modifier applied)
char castBuf[32] = "";
if (info->castTimeMs == 0) {
std::snprintf(castBuf, sizeof(castBuf), "Instant cast");
} else {
float secs = info->castTimeMs / 1000.0f;
std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs);
// Apply SpellModOp::CastingTime (10) modifiers
int32_t flatCT = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::CastingTime);
int32_t pctCT = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::CastingTime);
int32_t modCT = game::GameHandler::applySpellMod(
static_cast<int32_t>(info->castTimeMs), flatCT, pctCT);
float secs = static_cast<float>(modCT) / 1000.0f;
std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs > 0.0f ? secs : 0.0f);
}
if (costBuf[0] || castBuf[0]) {

View file

@ -201,20 +201,23 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
return a->column < b->column;
});
// Find grid dimensions
uint8_t maxRow = 0, maxCol = 0;
// Find grid dimensions — use int to avoid uint8_t wrap-around infinite loops
int maxRow = 0, maxCol = 0;
for (const auto* talent : talents) {
maxRow = std::max(maxRow, talent->row);
maxCol = std::max(maxCol, talent->column);
maxRow = std::max(maxRow, (int)talent->row);
maxCol = std::max(maxCol, (int)talent->column);
}
// Sanity-cap to prevent runaway loops from corrupt/unexpected DBC data
maxRow = std::min(maxRow, 15);
maxCol = std::min(maxCol, 15);
// WoW talent grids are always 4 columns wide
if (maxCol < 3) maxCol = 3;
const float iconSize = 40.0f;
const float spacing = 8.0f;
const float cellSize = iconSize + spacing;
const float gridWidth = (maxCol + 1) * cellSize + spacing;
const float gridHeight = (maxRow + 1) * cellSize + spacing;
const float gridWidth = (float)(maxCol + 1) * cellSize + spacing;
const float gridHeight = (float)(maxRow + 1) * cellSize + spacing;
// Points in this tree
uint32_t pointsInTree = 0;
@ -300,7 +303,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue;
uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]);
bool met = prereqRank >= talent->prereqRank[i];
bool met = prereqRank > talent->prereqRank[i]; // storage 1-indexed, DBC 0-indexed
ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150);
ImVec2 from = fromIt->second.center;
@ -322,8 +325,8 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
}
// Render talent icons
for (uint8_t row = 0; row <= maxRow; ++row) {
for (uint8_t col = 0; col <= maxCol; ++col) {
for (int row = 0; row <= maxRow; ++row) {
for (int col = 0; col <= maxCol; ++col) {
const game::GameHandler::TalentEntry* talent = nullptr;
for (const auto* t : talents) {
if (t->row == row && t->column == col) {
@ -371,7 +374,7 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
for (int i = 0; i < 3; ++i) {
if (talent.prereqTalent[i] != 0) {
uint8_t prereqRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
if (prereqRank < talent.prereqRank[i]) {
if (prereqRank <= talent.prereqRank[i]) { // storage 1-indexed, DBC 0-indexed
prereqsMet = false;
canLearn = false;
break;
@ -538,14 +541,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
if (!prereq || prereq->rankSpells[0] == 0) continue;
uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
bool met = prereqCurrentRank >= talent.prereqRank[i];
bool met = prereqCurrentRank > talent.prereqRank[i]; // storage 1-indexed, DBC 0-indexed
ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1);
const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]);
ImGui::Spacing();
const uint8_t reqRankDisplay = talent.prereqRank[i] + 1u; // DBC 0-indexed → display 1-indexed
ImGui::TextColored(pColor, "Requires %u point%s in %s",
talent.prereqRank[i],
talent.prereqRank[i] > 1 ? "s" : "",
reqRankDisplay,
reqRankDisplay > 1 ? "s" : "",
prereqName.empty() ? "prerequisite" : prereqName.c_str());
}
@ -570,16 +574,10 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
ImGui::EndTooltip();
}
// Handle click
// Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...)
// CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value)
if (clicked && canLearn && prereqsMet) {
const auto& learned = gameHandler.getLearnedTalents();
uint8_t desiredRank;
if (learned.find(talent.talentId) == learned.end()) {
desiredRank = 0; // First rank (0-indexed on wire)
} else {
desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn
}
gameHandler.learnTalent(talent.talentId, desiredRank);
gameHandler.learnTalent(talent.talentId, currentRank);
}
ImGui::PopID();