Compare commits

...

82 commits

Author SHA1 Message Date
Kelsi
a87d62abf8 feat: add melee swing timer bar to player frame during auto-attack
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
Shows a thin progress bar below the player health/power bars whenever
the player is auto-attacking. The bar fills from the last swing timestamp
to the next expected swing based on the main-hand weapon's delay (from
ItemQueryResponseData::delayMs). Falls back to 2.0s for unarmed. Turns
gold and shows "Swing!" when the timer is complete to signal readiness.
Hides when not auto-attacking.
2026-03-12 20:05:36 -07:00
Kelsi
a7261a0d15 feat: trigger camera rumble shake on storm weather transition
When SMSG_WEATHER sets storm (type 3) with intensity > 0.3,
fire a low-frequency (6Hz) camera shake to simulate thunder.
Magnitude scales with intensity: 0.03–0.07 world units.
2026-03-12 19:46:01 -07:00
Kelsi
bba2f20588 feat: implement CMSG_PET_RENAME with rename dialog in pet frame
- Add PetRenamePacket::build(petGuid, name, isDeclined) builder
- Add GameHandler::renamePet(newName) — sends packet via petGuid_
- Add 'Rename Pet' to pet frame context menu (right-click pet name)
- Modal input dialog with 12-char limit matches server validation
2026-03-12 19:42:31 -07:00
Kelsi
9aa4b223dc feat: implement SMSG_CAMERA_SHAKE with sinusoidal camera shake effect
- Add triggerShake(magnitude, frequency, duration) to CameraController
- Apply envelope-decaying sinusoidal XYZ offset to camera in update()
- Handle SMSG_CAMERA_SHAKE opcode in GameHandler dispatch
- Translate shakeId to magnitude (minor <50: 0.04, larger: 0.08 world units)
- Wire CameraShakeCallback from GameHandler through to CameraController
- Shake uses 18Hz oscillation with 30% fade-out envelope at end of duration
2026-03-12 19:37:53 -07:00
Kelsi
214c1a9ff8 feat: enable FXAA alongside FSR3 in settings and add FXAA to Ultra preset
- Remove fsr2Active guard that prevented FXAA when FSR3 was active
- FXAA checkbox now always enabled; tooltip adapts to explain FSR3+FXAA combo
  when FSR3 is active ('recommended ultra-quality combination')
- Performance HUD shows 'FXAA: ON (FSR3+FXAA combined)' when both active
- Ultra graphics preset now enables FXAA (8x MSAA + FXAA for max smoothness)
- Preset detection updated to require FXAA for Ultra match
2026-03-12 19:27:00 -07:00
Kelsi
284b98d93a feat: implement pet stable system (MSG_LIST_STABLED_PETS, CMSG_STABLE_PET, CMSG_UNSTABLE_PET)
- Parse MSG_LIST_STABLED_PETS (SMSG): populate StabledPet list with
  petNumber, entry, level, name, displayId, and active status
- Detect stable master via gossip option text/keyword matching and
  auto-send MSG_LIST_STABLED_PETS request to open the stable UI
- Refresh list automatically after SMSG_STABLE_RESULT to reflect state
- New packet builders: ListStabledPetsPacket, StablePetPacket, UnstablePetPacket
- New public API: requestStabledPetList(), stablePet(slot), unstablePet(petNumber)
- Stable window UI: shows active/stabled pets with store/retrieve buttons,
  slot count, refresh, and close; opens when server sends pet list
- Clear stable state on world logout/disconnect
2026-03-12 19:15:52 -07:00
Kelsi
81b95b4af7 feat: resolve title names from CharTitles.dbc in SMSG_TITLE_EARNED
Previously SMSG_TITLE_EARNED only showed the numeric bit index.
Now it lazy-loads CharTitles.dbc and formats the full title string
with the player's name (e.g. "Title earned: Commander Kelsi!").

- Add CharTitles layout to WotLK (TitleBit=36) and TBC (TitleBit=20) layouts
- loadTitleNameCache() maps each titleBit to its English title string
- SMSG_TITLE_EARNED substitutes %s placeholder with local player's name
- Falls back to "Title earned (bit N)!" if DBC is unavailable
2026-03-12 19:05:54 -07:00
Kelsi
cd01d07a91 fix: wait for GPU idle before freeing M2/WMO model buffers to prevent device lost
cleanupUnusedModels() runs every 5 seconds and freed vertex/index buffers
without waiting for the GPU to finish the previous frame's command buffer.
This caused VK_ERROR_DEVICE_LOST (-4) after extended gameplay when tiles
stream out and their models are freed mid-render.

Add vkDeviceWaitIdle() before the buffer destroy loop in both M2Renderer
and WMORenderer cleanupUnusedModels(). The wait only happens when there are
models to remove, so quiet sessions have no overhead.
2026-03-12 19:01:15 -07:00
Kelsi
eafd09aca0 feat: allow FXAA alongside FSR1 and FSR3 simultaneously
- Remove !fsr_.enabled / !fsr2_.enabled guards that blocked FXAA init
- FXAA can now coexist with FSR1 and FSR3 simultaneously
- Priority: FSR3 > FXAA > FSR1
  - FSR3 + FXAA: scene renders at FSR3 internal res, temporal AA runs,
    then FXAA reads FSR3 history and applies spatial AA to swapchain
    (replaces RCAS sharpening for ultra-quality native mode)
  - FXAA + FSR1: scene renders at native res, FXAA post-processes;
    FSR1 resources exist but are idle (FXAA wins for better quality)
  - FSR3 only / FSR1 only: unchanged paths
- Fix missing fxaa.frag.spv: shader was present but uncompiled; the
  CMake compile_shaders() function will now pick it up on next build
2026-03-12 18:58:30 -07:00
Kelsi
a97941f062 feat: implement SMSG_PET_ACTION_FEEDBACK with human-readable messages
Parse pet action feedback opcodes and display messages in system chat:
dead, nothing_to_attack, cant_attack_target, target_too_far,
no_path, cant_attack_immune. Replaces consume stub.
2026-03-12 18:25:02 -07:00
Kelsi
5883654e1e feat: replace page-text chat-dump with proper book/scroll window
handlePageTextQueryResponse() now collects pages into bookPages_ vector
instead of dumping lines to system chat. Multi-page items (nextPageId != 0)
are automatically chained by requesting subsequent pages. The book window
opens automatically when pages arrive, shows formatted text in a parchment-
styled ImGui window with Prev/Next page navigation and a Close button.

SMSG_READ_ITEM_OK clears bookPages_ so each item read starts fresh;
handleGameObjectPageText() does the same before querying the first page.
Closes the long-standing issue where reading scrolls and tattered notes
spammed many separate chat messages instead of showing a readable UI.
2026-03-12 18:21:50 -07:00
Kelsi
218d68e275 feat: parse Classic SMSG_INSPECT gear + implement temp weapon enchant timers
Classic 1.12 SMSG_INSPECT (wire 0x115): parse PackedGUID + 19×uint32
itemEntries to populate InspectResult and inspectedPlayerItemEntries_ cache,
enabling gear inspection of other players on Classic servers. Triggers item
queries for all filled slots so the inspect window shows names/ilevels.

SMSG_ITEM_ENCHANT_TIME_UPDATE: parse itemGuid/slot/durationSec/playerGuid and
store per-slot expire timestamps in tempEnchantTimers_. Fires 5min/1min
chat warnings before expiry. getTempEnchantRemainingMs() helper queries live
remaining time. Buff bar renders timed slot buttons (gold/teal/purple per
slot) that pulse red below 60s — useful for Shaman imbues, Rogue poisons,
whetstones and oils across all three expansions.
2026-03-12 18:15:51 -07:00
Kelsi
2f479c6230 feat: implement master loot UI for SMSG_LOOT_MASTER_LIST
Parse master loot candidate GUIDs from SMSG_LOOT_MASTER_LIST and display
a "Give to..." popup menu on item click when master loot is active.
Sends CMSG_LOOT_MASTER_GIVE with loot GUID, slot, and target GUID.
Clears candidates when loot window is closed.
2026-03-12 17:58:24 -07:00
Kelsi
6957ba97ea feat: show stat gains in level-up toast from SMSG_LEVELUP_INFO
Parse hp/mana/str/agi/sta/int/spi deltas from SMSG_LEVELUP_INFO payload
and display them in green below the "You have reached level X!" banner.
Extends DING_DURATION to 4s to give players time to read the gains.
2026-03-12 17:54:49 -07:00
Kelsi
6df8c72cf7 feat: add WotLK equipment set UI to character screen
- Expose equipment sets via public EquipmentSetInfo getter
- Populate equipmentSetInfo_ from handleEquipmentSetList()
- Implement useEquipmentSet() sending CMSG_EQUIPMENT_SET_USE
- Add "Outfits" tab in character screen listing saved sets with Equip button
2026-03-12 17:48:08 -07:00
Kelsi
882cb1bae3 feat: implement WotLK glyph display in talent screen
Store glyph IDs from SMSG_TALENTS_INFO (previously discarded) in
learnedGlyphs_[2][6] per talent spec. Load GlyphProperties.dbc to
map glyphId to spellId and major/minor type. Add a Glyphs tab to
the talent screen showing all 6 slots with spell icons and names.
Also clear vehicleId_ on SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA.
2026-03-12 17:39:35 -07:00
Kelsi
b7c1aa39a9 feat: add WotLK vehicle exit UI with Leave Vehicle button
Parse SMSG_PLAYER_VEHICLE_DATA (PackedGuid + uint32 vehicleId) and
track in-vehicle state. Add sendRequestVehicleExit() which sends
CMSG_REQUEST_VEHICLE_EXIT. Render a floating red "Leave Vehicle"
button above the action bar whenever vehicleId_ is non-zero.
State cleared on world leave and zone transfer.
2026-03-12 17:25:00 -07:00
Kelsi
fbcec9e7bf feat: show FXAA status in performance HUD 2026-03-12 17:01:20 -07:00
Kelsi
806744c483 refactor: consolidate duplicate quest trackers; make primary tracker draggable
Remove the redundant renderObjectiveTracker (simpler, fixed-position) and
apply draggable position tracking to renderQuestObjectiveTracker (the primary
tracker with context menus, item icons, and click-to-open-quest-log).

- questTrackerPos_ / questTrackerPosInit_ now drive the primary tracker
- Default position is top-right (below minimap at y=320)
- Drag saves to settings.cfg immediately (quest_tracker_x/y keys)
- Both trackers were rendering simultaneously — this eliminates the duplicate
2026-03-12 16:52:12 -07:00
Kelsi
925d15713c feat: make quest tracker draggable with persistent position 2026-03-12 16:47:42 -07:00
Kelsi
6e95709b68 feat: add FXAA post-process anti-aliasing, combinable with MSAA 2026-03-12 16:43:48 -07:00
Kelsi
819a690c33 feat: show resurrection flash banner when player transitions ghost→alive 2026-03-12 16:33:08 -07:00
Kelsi
42d66bc876 feat: show quality-coloured loot toast when items are received
SMSG_ITEM_PUSH_RESULT now fires a new ItemLootCallback that
game_screen.cpp uses to push a compact slide-in toast at the
bottom-left of the screen.  Each toast:

- Shows a quality-tinted left accent bar (grey/white/green/blue/
  purple/orange matching WoW quality colours)
- Displays "Loot: <item name>" with the name in quality colour
- Appends " x<N>" for stacked pickups
- Coalesces repeated pickups of the same item (adds count, resets timer)
- Stacks up to 5 entries, 3 s lifetime with 0.15 s slide-in and 0.7 s
  fade-out
2026-03-12 16:24:11 -07:00
Kelsi
129fa84fe3 feat: add PvP honor credit toast on honorable kill
SMSG_PVP_CREDIT previously only wrote a system chat message.  Now it
also fires a new PvpHonorCallback, which game_screen.cpp uses to push
a compact dark-red toast at the top-right of the screen showing
"⚔ +N Honor" with a 3.5 s lifetime and smooth fade in/out.
2026-03-12 16:19:25 -07:00
Kelsi
59fc7cebaf feat: show toast when a nearby player levels up
When the server sends a level-up update for a non-self player entity,
fire the existing OtherPlayerLevelUpCallback which was previously
unregistered in the UI layer.  GameScreen now:

- Registers the callback once and stores {guid, level} entries
- Lazily resolves the player name from the name cache at render time
- Renders gold-bordered toasts bottom-centre with a ★ icon and fade/slide
  animation ("Thrall is now level 60!"), coalescing duplicates
- Prunes entries after 4 s with a 1 s fade-out
2026-03-12 16:12:21 -07:00
Kelsi
b52e9c29c6 feat: highlight quest GO objectives as cyan triangles on the minimap
Quest game-object objectives (negative npcOrGoId entries, e.g. gather 5
crystals) now render as larger bright-cyan triangles distinct from the
standard amber GO markers. Tooltip appends "(quest)" to the name.
Also refactors the minimap quest-entry build to track both NPC and GO
kill-objective entries from the tracked quest log.
2026-03-12 16:05:34 -07:00
Kelsi
3f340ca235 feat: highlight quest kill objective mobs as gold dots on the minimap
Quest kill objective NPCs are now rendered as larger gold dots (3.5px)
with a dark outline on the minimap, distinct from standard hostile (red)
and friendly (white) dots. Only shows mobs for incomplete objectives in
tracked quests (or all active quests if none are tracked). Hovering the
dot shows a tooltip with the unit name and "(quest)" annotation.
2026-03-12 15:59:30 -07:00
Kelsi
c3afe543c6 feat: show quest objective progress toasts on kill and item collection
Adds a visual progress overlay at bottom-right when quest kill counts
or item collection updates arrive. Each toast shows the quest title,
objective name, a fill-progress bar, and an X/Y count. Toasts coalesce
when the same objective updates multiple times, and auto-dismiss after 4s.
Wires a new QuestProgressCallback through GameHandler to trigger the UI.
2026-03-12 15:57:09 -07:00
Kelsi
5216582f15 feat: show whisper toast notification when a player whispers you
Adds a slide-in toast overlay at the bottom-left of the screen whenever
an incoming whisper arrives. Toasts display "Whisper from:", the sender
name in gold, and a truncated message preview. Up to 3 toasts stack with
a 5s lifetime; each fades in over 0.25s and fades out in the final 1s.
2026-03-12 15:53:45 -07:00
Kelsi
77879769d3 feat: show area discovery toast with XP gain when exploring new zones 2026-03-12 15:42:55 -07:00
Kelsi
98ad71df0d feat: show class-colored health bars on player nameplates 2026-03-12 15:36:25 -07:00
Kelsi
9e4c3d67d9 feat: show interactable game object dots on minimap when NPC dots enabled 2026-03-12 15:28:31 -07:00
Kelsi
78ad20f95d feat: add cooldown tracker panel showing all active spell cooldowns
A new opt-in panel (Settings > Interface > Show Cooldown Tracker) lists
all spells currently on cooldown, sorted longest-to-shortest, with
spell icons and color-coded remaining time (red>30s, orange>10s,
yellow>5s, green<5s). Adds getSpellCooldowns() accessor to GameHandler.
Setting persists to ~/.wowee/settings.cfg.
2026-03-12 15:25:07 -07:00
Kelsi
c503bc9432 feat: show nearby other-player dots on minimap when NPC dots are enabled 2026-03-12 15:20:31 -07:00
Kelsi
39fc6a645e feat: show party member dots on minimap with class colors and name tooltip
Small colored squares appear on the minimap for each online party member
at their server-reported position. Dots use WoW class colors when the
entity is loaded, gold for the party leader, and light blue otherwise;
dead members show as gray. Hovering a dot shows the member's name.
2026-03-12 15:19:08 -07:00
Kelsi
bff690ea53 feat: show zone name tooltip on party member name hover in party frame 2026-03-12 15:11:09 -07:00
Kelsi
e2f36f6ac5 feat: show party member dots on world map with name labels and class colors 2026-03-12 15:05:52 -07:00
Kelsi
6dc630c1d8 feat: add Arena tab to Social frame showing per-team rating and weekly/season record 2026-03-12 14:58:48 -07:00
Kelsi
6645845d05 feat: show aura stack/charge count on boss frame aura icons for parity 2026-03-12 14:53:14 -07:00
Kelsi
c0c750a76e feat: show aura stack/charge count on focus frame aura icons for parity with target frame 2026-03-12 14:50:59 -07:00
Kelsi
6044739661 feat: show group leader crown on world nameplate for party/raid leader players 2026-03-12 14:48:53 -07:00
Kelsi
61412ae06d feat: show group leader crown on player frame when you are party/raid leader 2026-03-12 14:47:51 -07:00
Kelsi
d682ec4ca7 feat: show group leader crown on focus frame for parity with target frame 2026-03-12 14:43:58 -07:00
Kelsi
2f234af43b feat: show group leader crown on target frame when targeting party leader 2026-03-12 14:39:29 -07:00
Kelsi
6d21a8cb8d feat: add distance indicator to focus frame for range awareness 2026-03-12 14:34:21 -07:00
Kelsi
b44ff09b63 feat: add pulsing golden glow to Attack action bar slot when auto-attacking 2026-03-12 14:32:15 -07:00
Kelsi
950a4e2991 feat: show raid mark icon on focus frame to match target frame parity 2026-03-12 14:30:15 -07:00
Kelsi
65f19b2d53 feat: show durability warning overlay when gear is damaged or broken
Displays a bottom-center HUD banner when any equipped item drops below
20% durability (yellow) or reaches 0 (red "broken" alert), matching
WoW's own repair reminder UX.
2026-03-12 14:25:37 -07:00
Kelsi
d34f505eea feat: add quest indicator, classification badge, and subtitle to focus frame
Mirrors the target frame improvements: NPC focus targets now show
quest giver ! / ? indicators, Elite/Rare/Boss/Rare Elite badges,
and the creature subtitle (e.g. '<Grand Marshal of the Alliance>').
Keeps the focus and target frames in consistent feature parity.
2026-03-12 14:21:02 -07:00
Kelsi
9494b1e607 feat: show quest giver ! and ? indicators on nameplates
For non-hostile NPCs with quest status data, displays a colored symbol
to the right of the nameplate name:
  !  (gold)  — quest available
  !  (gray)  — low-level quest
  ?  (gold)  — quest ready to turn in
  ?  (gray)  — quest incomplete

Displayed adjacent to the existing quest-kill sword icon, maintaining
the existing icon offset logic so both can coexist.
2026-03-12 14:18:22 -07:00
Kelsi
d2db0b46ff feat: show quest giver ! and ? indicators in target frame
Reads QuestGiverStatus from the existing npcQuestStatus_ cache and
displays a colored badge next to the target's name:
  !  (gold)  — quest available
  !  (gray)  — low-level quest available
  ?  (gold)  — quest ready to turn in
  ?  (gray)  — quest incomplete / in progress
Matches the standard WoW quest indicator convention.
2026-03-12 14:15:45 -07:00
Kelsi
1165aa6e74 feat: display creature subtitle in target frame
Shows the NPC subtitle (e.g. '<Warchief of the Horde>') below the
creature name in the target frame, using the subName field already
parsed from SMSG_CREATURE_QUERY_RESPONSE. Adds getCachedCreatureSubName()
accessor to GameHandler. Matches the official client's presentation.
2026-03-12 14:14:25 -07:00
Kelsi
8cb0f1d0ef feat: show Elite/Rare/Boss classification badge in target frame
Reads creature rank (0=Normal, 1=Elite, 2=RareElite, 3=Boss, 4=Rare)
from the existing creatureInfoCache populated by creature query responses.
Shows a colored badge next to the level: gold for Elite, purple for
Rare Elite, red for Boss, cyan for Rare — each with a tooltip. Adds
getCreatureRank() accessor to GameHandler for UI use.
2026-03-12 14:13:09 -07:00
Kelsi
a03ee33f8c feat: add power bar to boss frames for energy/mana tracking
Energy bosses (e.g. Anub'arak, various WotLK encounters) use energy as
their ability cooldown mechanic — tracking it in the boss frame lets
raiders anticipate major ability casts. Mana, rage, focus, and energy
all shown with type-appropriate colors as a slim 6px bar below HP.
2026-03-12 14:09:01 -07:00
Kelsi
fb843026ad feat: add LFG queue time indicator below minimap with role-check pulsing 2026-03-12 14:00:14 -07:00
Kelsi
e2b2425230 feat: add cast bar to target-of-target frame with pulsing interrupt warning 2026-03-12 13:58:30 -07:00
Kelsi
f39ba56390 feat: show raid mark symbols on raid frame cells beside leader crown 2026-03-12 13:50:46 -07:00
Kelsi
e6f48dd822 feat: show raid mark symbols on minimap party member dots with name tooltip 2026-03-12 13:48:01 -07:00
Kelsi
1e76df7c98 feat: show raid mark symbols on party frame member names 2026-03-12 13:47:02 -07:00
Kelsi
3d1b187986 feat: add aura icons to boss frame with DoT tracking and duration overlays 2026-03-12 13:43:12 -07:00
Kelsi
3665723622 feat: add aura icons to target-of-target frame with debuff coloring and tooltips 2026-03-12 13:39:36 -07:00
Kelsi
abfb6ecdb5 feat: add spell name tooltips to nameplate debuff dots on hover
When hovering over a player-applied DoT/debuff indicator square on an
enemy nameplate, the spell name is now shown as a tooltip. Uses direct
mouse-position hit test since nameplates render into the background
draw list rather than an ImGui window.
2026-03-12 13:36:06 -07:00
Kelsi
8d7391d73e feat: upgrade pet action bar to rich spell tooltips with autocast status
Pet ability buttons now show full spell info (name, description, range,
cost, cooldown) instead of just the spell name. Built-in commands (Follow,
Stay, Attack, etc.) keep their existing simple labels. Autocast-enabled
spells show "Autocast: On" at the bottom of the tooltip.
2026-03-12 13:33:48 -07:00
Kelsi
c76ac579cb feat: add aura icons to focus frame with rich tooltips and duration overlays
The focus frame now shows buff/debuff icons matching the target frame:
debuffs first with dispel-type border colors, buffs after with green
borders, duration countdowns on each icon, and rich spell info tooltips
on hover. Uses getUnitAuras() falling back to getTargetAuras() when
focus happens to also be the current target.
2026-03-12 13:32:10 -07:00
Kelsi
9336b2943c feat: add debuff dots to raid frame and NPC name tooltips on minimap quest markers
- Raid frame now shows dispellable debuff dots (magic/curse/disease/poison)
  in the bottom of each cell, matching the existing party frame behavior;
  hovering a dot shows the debuff type and spell names for that dispel type
- Minimap quest giver dots (! and ?) now show a tooltip with the NPC name
  and whether the NPC has a new quest or a quest ready to turn in
2026-03-12 13:28:49 -07:00
Kelsi
d46feee4fc feat: show debuff type and spell names on party frame debuff dot hover 2026-03-12 13:23:21 -07:00
Kelsi
2268f7ac34 feat: add item tooltips to quest tracker overlay item objectives 2026-03-12 13:22:20 -07:00
Kelsi
6ffc0cec3d feat: show spell tooltip on hover in combat log 2026-03-12 13:21:00 -07:00
Kelsi
fe4fc714c3 feat: add item tooltips to quest objective item tracking 2026-03-12 13:19:10 -07:00
Kelsi
0ffcf001a5 feat: show full item tooltip on action bar item hover 2026-03-12 13:14:24 -07:00
Kelsi
d7c4bdcd57 feat: add item tooltips to quest reward items in quest log 2026-03-12 13:12:24 -07:00
Kelsi
3ea1b96681 feat: show spell description in trainer window tooltip
Trainer spell tooltips now show the spell's effect description from
Spell.dbc (e.g. "Sends a shadowy bolt at the enemy...") above the
status/requirement lines, matching the WoW trainer UI style.
Also styles the spell name yellow (like WoW) and moves status to
TextDisabled for better visual hierarchy.
2026-03-12 13:09:40 -07:00
Kelsi
a10139284d feat: show spell tooltip text instead of name in item spell effects
Item "Equip:" and "Use:" spell effects now display the spell's
description text from Spell.dbc (e.g. "Increases your Spell Power by 30.")
rather than the internal spell name (e.g. "Mana Spring Totem").
Falls back to the name when description is unavailable (e.g. older DBCs).
Adds getSpellDescription() to GameHandler, backed by the existing
loadSpellNameCache() pass which now reads the Tooltip field.
2026-03-12 13:08:41 -07:00
Kelsi
ed2b50af26 fix: look up socket bonus name from SpellItemEnchantment.dbc
socketBonus is a SpellItemEnchantment entry ID, not a spell ID.
Previously getSpellName() was called on it, which produced wrong or
empty results. Now a lazy SpellItemEnchantment.dbc cache in the item
tooltip correctly resolves names like "+6 All Stats".
2026-03-12 12:57:15 -07:00
Kelsi
8921c2ddf4 feat: show criteria description and progress in achievement window
The Criteria tab now loads AchievementCriteria.dbc to display each
criterion's description text, parent achievement name, and a
current/required progress counter (e.g. "25/100") instead of the
raw numeric IDs. The search filter now also matches by achievement name.
AchievementCriteria DBC layout added to wotlk/dbc_layouts.json.
2026-03-12 12:52:08 -07:00
Kelsi
d44f5e6560 feat: show achievement description and point value in tooltip
Hovering an earned achievement now shows its point value (gold badge),
description text from Achievement.dbc field 21, and the earn date.
loadAchievementNameCache() also populates achievementDescCache_ and
achievementPointsCache_ in a single DBC pass; Points field (39) added
to the WotLK Achievement DBC layout.
2026-03-12 12:49:38 -07:00
Kelsi
bc0d98adae feat: show item set piece count and active bonuses in item tooltip 2026-03-12 12:43:53 -07:00
Kelsi
a56b50df2b feat: show average item level in character stats panel 2026-03-12 12:41:05 -07:00
Kelsi
48f12d9ca8 feat: parse item set ID and display set name in item tooltip via ItemSet.dbc 2026-03-12 12:35:56 -07:00
Kelsi
d48e4fb7c3 feat: resolve enchant names from SpellItemEnchantment.dbc in inspect window 2026-03-12 12:32:19 -07:00
Kelsi
60794c6e0f feat: track and display elemental resistances in character stats panel 2026-03-12 12:24:15 -07:00
Kelsi
0a2cd213dc feat: display gem socket slots and socket bonus in item tooltips 2026-03-12 12:15:08 -07:00
25 changed files with 4567 additions and 214 deletions

View file

@ -31,6 +31,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"CharTitles": { "ID": 0, "Title": 2, "TitleBit": 20 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,
@ -97,5 +98,18 @@
"ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3,
"LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7,
"DisplayMapID": 8, "ParentWorldMapID": 10
},
"SpellItemEnchantment": {
"ID": 0, "Name": 8
},
"ItemSet": {
"ID": 0, "Name": 1,
"Item0": 18, "Item1": 19, "Item2": 20, "Item3": 21, "Item4": 22,
"Item5": 23, "Item6": 24, "Item7": 25, "Item8": 26, "Item9": 27,
"Spell0": 28, "Spell1": 29, "Spell2": 30, "Spell3": 31, "Spell4": 32,
"Spell5": 33, "Spell6": 34, "Spell7": 35, "Spell8": 36, "Spell9": 37,
"Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41,
"Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45,
"Threshold8": 46, "Threshold9": 47
}
}

View file

@ -95,5 +95,18 @@
"ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3,
"LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7,
"DisplayMapID": 8, "ParentWorldMapID": 10
},
"SpellItemEnchantment": {
"ID": 0, "Name": 8
},
"ItemSet": {
"ID": 0, "Name": 1,
"Item0": 10, "Item1": 11, "Item2": 12, "Item3": 13, "Item4": 14,
"Item5": 15, "Item6": 16, "Item7": 17, "Item8": 18, "Item9": 19,
"Spell0": 20, "Spell1": 21, "Spell2": 22, "Spell3": 23, "Spell4": 24,
"Spell5": 25, "Spell6": 26, "Spell7": 27, "Spell8": 28, "Spell9": 29,
"Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33,
"Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37,
"Threshold8": 38, "Threshold9": 39
}
}

View file

@ -31,7 +31,9 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"Achievement": { "ID": 0, "Title": 4, "Description": 21 },
"CharTitles": { "ID": 0, "Title": 2, "TitleBit": 36 },
"Achievement": { "ID": 0, "Title": 4, "Description": 21, "Points": 39 },
"AchievementCriteria": { "ID": 0, "AchievementID": 1, "Quantity": 4, "Description": 9 },
"AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,
@ -98,5 +100,18 @@
"ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3,
"LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7,
"DisplayMapID": 8, "ParentWorldMapID": 10
},
"SpellItemEnchantment": {
"ID": 0, "Name": 8
},
"ItemSet": {
"ID": 0, "Name": 1,
"Item0": 18, "Item1": 19, "Item2": 20, "Item3": 21, "Item4": 22,
"Item5": 23, "Item6": 24, "Item7": 25, "Item8": 26, "Item9": 27,
"Spell0": 28, "Spell1": 29, "Spell2": 30, "Spell3": 31, "Spell4": 32,
"Spell5": 33, "Spell6": 34, "Spell7": 35, "Spell8": 36, "Spell9": 37,
"Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41,
"Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45,
"Threshold8": 46, "Threshold9": 47
}
}

View file

@ -0,0 +1,132 @@
#version 450
// 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).
layout(set = 0, binding = 0) uniform sampler2D uScene;
layout(location = 0) in vec2 TexCoord;
layout(location = 0) out vec4 outColor;
layout(push_constant) uniform PC {
vec2 rcpFrame;
} pc;
// Quality tuning
#define FXAA_EDGE_THRESHOLD (1.0/8.0) // minimum edge contrast to process
#define FXAA_EDGE_THRESHOLD_MIN (1.0/24.0) // ignore very dark regions
#define FXAA_SEARCH_STEPS 12
#define FXAA_SEARCH_THRESHOLD (1.0/4.0)
#define FXAA_SUBPIX 0.75
#define FXAA_SUBPIX_TRIM (1.0/4.0)
#define FXAA_SUBPIX_TRIM_SCALE (1.0/(1.0 - FXAA_SUBPIX_TRIM))
#define FXAA_SUBPIX_CAP (3.0/4.0)
float luma(vec3 c) {
return dot(c, vec3(0.299, 0.587, 0.114));
}
void main() {
vec2 uv = TexCoord;
vec2 rcp = pc.rcpFrame;
// --- Centre and cardinal neighbours ---
vec3 rgbM = texture(uScene, uv).rgb;
vec3 rgbN = texture(uScene, uv + vec2( 0.0, -1.0) * rcp).rgb;
vec3 rgbS = texture(uScene, uv + vec2( 0.0, 1.0) * rcp).rgb;
vec3 rgbE = texture(uScene, uv + vec2( 1.0, 0.0) * rcp).rgb;
vec3 rgbW = texture(uScene, uv + vec2(-1.0, 0.0) * rcp).rgb;
float lumaN = luma(rgbN);
float lumaS = luma(rgbS);
float lumaE = luma(rgbE);
float lumaW = luma(rgbW);
float lumaM = luma(rgbM);
float lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaE, lumaW)));
float lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaE, lumaW)));
float range = lumaMax - lumaMin;
// Early exit on smooth regions
if (range < max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD)) {
outColor = vec4(rgbM, 1.0);
return;
}
// --- Diagonal neighbours ---
vec3 rgbNW = texture(uScene, uv + vec2(-1.0, -1.0) * rcp).rgb;
vec3 rgbNE = texture(uScene, uv + vec2( 1.0, -1.0) * rcp).rgb;
vec3 rgbSW = texture(uScene, uv + vec2(-1.0, 1.0) * rcp).rgb;
vec3 rgbSE = texture(uScene, uv + vec2( 1.0, 1.0) * rcp).rgb;
float lumaNW = luma(rgbNW);
float lumaNE = luma(rgbNE);
float lumaSW = luma(rgbSW);
float lumaSE = luma(rgbSE);
// --- Sub-pixel blend factor ---
float lumaL = (lumaN + lumaS + lumaE + lumaW) * 0.25;
float rangeL = abs(lumaL - lumaM);
float blendL = max(0.0, (rangeL / range) - FXAA_SUBPIX_TRIM) * FXAA_SUBPIX_TRIM_SCALE;
blendL = min(FXAA_SUBPIX_CAP, blendL) * FXAA_SUBPIX;
// --- Edge orientation (horizontal vs. vertical) ---
float edgeHorz =
abs(-2.0*lumaW + lumaNW + lumaSW)
+ 2.0*abs(-2.0*lumaM + lumaN + lumaS)
+ abs(-2.0*lumaE + lumaNE + lumaSE);
float edgeVert =
abs(-2.0*lumaS + lumaSW + lumaSE)
+ 2.0*abs(-2.0*lumaM + lumaW + lumaE)
+ abs(-2.0*lumaN + lumaNW + lumaNE);
bool horzSpan = (edgeHorz >= edgeVert);
float lengthSign = horzSpan ? rcp.y : rcp.x;
float luma1 = horzSpan ? lumaN : lumaW;
float luma2 = horzSpan ? lumaS : lumaE;
float grad1 = abs(luma1 - lumaM);
float grad2 = abs(luma2 - lumaM);
lengthSign = (grad1 >= grad2) ? -lengthSign : lengthSign;
// --- Edge search ---
vec2 posB = uv;
vec2 offNP = horzSpan ? vec2(rcp.x, 0.0) : vec2(0.0, rcp.y);
if (!horzSpan) posB.x += lengthSign * 0.5;
if ( horzSpan) posB.y += lengthSign * 0.5;
float lumaMLSS = lumaM - (luma1 + luma2) * 0.5;
float gradientScaled = max(grad1, grad2) * 0.25;
vec2 posN = posB - offNP;
vec2 posP = posB + offNP;
bool done1 = false, done2 = false;
float lumaEnd1 = 0.0, lumaEnd2 = 0.0;
for (int i = 0; i < FXAA_SEARCH_STEPS; ++i) {
if (!done1) lumaEnd1 = luma(texture(uScene, posN).rgb) - lumaMLSS;
if (!done2) lumaEnd2 = luma(texture(uScene, posP).rgb) - lumaMLSS;
done1 = done1 || (abs(lumaEnd1) >= gradientScaled * FXAA_SEARCH_THRESHOLD);
done2 = done2 || (abs(lumaEnd2) >= gradientScaled * FXAA_SEARCH_THRESHOLD);
if (done1 && done2) break;
if (!done1) posN -= offNP;
if (!done2) posP += offNP;
}
float dstN = horzSpan ? (uv.x - posN.x) : (uv.y - posN.y);
float dstP = horzSpan ? (posP.x - uv.x) : (posP.y - uv.y);
bool dirN = (dstN < dstP);
float lumaEndFinal = dirN ? lumaEnd1 : lumaEnd2;
float spanLength = dstN + dstP;
float pixelOffset = (dirN ? dstN : dstP) / spanLength;
bool goodSpan = ((lumaEndFinal < 0.0) != (lumaMLSS < 0.0));
float pixelOffsetFinal = max(goodSpan ? pixelOffset : 0.0, blendL);
vec2 finalUV = uv;
if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign;
if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign;
outColor = vec4(texture(uScene, finalUV).rgb, 1.0);
}

View file

@ -295,6 +295,13 @@ public:
// Server-authoritative armor (UNIT_FIELD_RESISTANCES[0])
int32_t getArmorRating() const { return playerArmorRating_; }
// Server-authoritative elemental resistances (UNIT_FIELD_RESISTANCES[1-6]).
// school: 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane. Returns 0 if not received.
int32_t getResistance(int school) const {
if (school < 1 || school > 6) return 0;
return playerResistances_[school - 1];
}
// Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI).
// Returns -1 if the server hasn't sent the value yet.
int32_t getPlayerStat(int idx) const {
@ -541,6 +548,17 @@ public:
}
std::string getCachedPlayerName(uint64_t guid) const;
std::string getCachedCreatureName(uint32_t entry) const;
// Returns the creature subname/title (e.g. "<Warchief of the Horde>"), empty if not cached
std::string getCachedCreatureSubName(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.subName : "";
}
// Returns the creature rank (0=Normal,1=Elite,2=RareElite,3=Boss,4=Rare)
// or -1 if not cached yet
int getCreatureRank(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? static_cast<int>(it->second.rank) : -1;
}
// ---- Phase 2: Combat ----
void startAutoAttack(uint64_t targetGuid);
@ -555,6 +573,9 @@ public:
}
uint64_t getAutoAttackTargetGuid() const { return autoAttackTarget; }
bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
// Timestamp (ms since epoch) of the most recent player melee auto-attack.
// Zero if no swing has occurred this session.
uint64_t getLastMeleeSwingMs() const { return lastMeleeSwingMs_; }
const std::vector<CombatTextEntry>& getCombatText() const { return combatText; }
void updateCombatText(float deltaTime);
@ -591,6 +612,7 @@ public:
void cancelCast();
void cancelAura(uint32_t spellId);
void dismissPet();
void renamePet(const std::string& newName);
bool hasPet() const { return petGuid_ != 0; }
uint64_t getPetGuid() const { return petGuid_; }
@ -616,6 +638,24 @@ public:
void sendPetAction(uint32_t action, uint64_t targetGuid = 0);
const std::unordered_set<uint32_t>& getKnownSpells() const { return knownSpells; }
// ---- Pet Stable ----
struct StabledPet {
uint32_t petNumber = 0; // server-side pet number (used for unstable/swap)
uint32_t entry = 0; // creature entry ID
uint32_t level = 0;
std::string name;
uint32_t displayId = 0;
bool isActive = false; // true = currently summoned/active slot
};
bool isStableWindowOpen() const { return stableWindowOpen_; }
void closeStableWindow() { stableWindowOpen_ = false; }
uint64_t getStableMasterGuid() const { return stableMasterGuid_; }
uint8_t getStableSlots() const { return stableNumSlots_; }
const std::vector<StabledPet>& getStabledPets() const { return stabledPets_; }
void requestStabledPetList(); // CMSG MSG_LIST_STABLED_PETS
void stablePet(uint8_t slot); // CMSG_STABLE_PET (store active pet in slot)
void unstablePet(uint32_t petNumber); // CMSG_UNSTABLE_PET (retrieve to active)
// Player proficiency bitmasks (from SMSG_SET_PROFICIENCY)
// itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing)
// itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield)
@ -688,6 +728,14 @@ public:
static std::unordered_map<uint32_t, uint8_t> empty;
return spec < 2 ? learnedTalents_[spec] : empty;
}
// Glyphs (WotLK): up to 6 glyph slots per spec (3 major + 3 minor)
static constexpr uint8_t MAX_GLYPH_SLOTS = 6;
const std::array<uint16_t, MAX_GLYPH_SLOTS>& getGlyphs() const { return learnedGlyphs_[activeTalentSpec_]; }
const std::array<uint16_t, MAX_GLYPH_SLOTS>& getGlyphs(uint8_t spec) const {
static std::array<uint16_t, MAX_GLYPH_SLOTS> empty{};
return spec < 2 ? learnedGlyphs_[spec] : empty;
}
uint8_t getTalentRank(uint32_t talentId) const {
auto it = learnedTalents_[activeTalentSpec_].find(talentId);
return (it != learnedTalents_[activeTalentSpec_].end()) ? it->second : 0;
@ -838,6 +886,11 @@ public:
using KnockBackCallback = std::function<void(float vcos, float vsin, float hspeed, float vspeed)>;
void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); }
// Camera shake callback: called when server sends SMSG_CAMERA_SHAKE.
// Parameters: magnitude (world units), frequency (Hz), duration (seconds).
using CameraShakeCallback = std::function<void(float magnitude, float frequency, float duration)>;
void setCameraShakeCallback(CameraShakeCallback cb) { cameraShakeCallback_ = std::move(cb); }
// Unstuck callback (resets player Z to floor height)
using UnstuckCallback = std::function<void()>;
void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); }
@ -965,6 +1018,7 @@ public:
// Cooldowns
float getSpellCooldown(uint32_t spellId) const;
const std::unordered_map<uint32_t, float>& getSpellCooldowns() const { return spellCooldowns; }
// Player GUID
uint64_t getPlayerGuid() const { return playerGuid; }
@ -1203,6 +1257,11 @@ public:
void setAutoLoot(bool enabled) { autoLoot_ = enabled; }
bool isAutoLoot() const { return autoLoot_; }
// Master loot candidates (from SMSG_LOOT_MASTER_LIST)
const std::vector<uint64_t>& getMasterLootCandidates() const { return masterLootCandidates_; }
bool hasMasterLootCandidates() const { return !masterLootCandidates_.empty(); }
void lootMasterGive(uint8_t lootSlot, uint64_t targetGuid);
// Group loot roll
struct LootRollEntry {
uint64_t objectGuid = 0;
@ -1225,6 +1284,16 @@ public:
void sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType);
// rollType: 0=need, 1=greed, 2=disenchant, 96=pass
// Equipment Sets (WotLK): saved gear loadouts
struct EquipmentSetInfo {
uint64_t setGuid = 0;
uint32_t setId = 0;
std::string name;
std::string iconName;
};
const std::vector<EquipmentSetInfo>& getEquipmentSets() const { return equipmentSetInfo_; }
void useEquipmentSet(uint32_t setId);
// NPC Gossip
void interactWithNpc(uint64_t guid);
void interactWithGameObject(uint64_t guid);
@ -1401,6 +1470,32 @@ public:
using LevelUpCallback = std::function<void(uint32_t newLevel)>;
void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback_ = std::move(cb); }
// Stat deltas from the last SMSG_LEVELUP_INFO (valid until next level-up)
struct LevelUpDeltas {
uint32_t hp = 0;
uint32_t mana = 0;
uint32_t str = 0, agi = 0, sta = 0, intel = 0, spi = 0;
};
const LevelUpDeltas& getLastLevelUpDeltas() const { return lastLevelUpDeltas_; }
// Temporary weapon enchant timers (from SMSG_ITEM_ENCHANT_TIME_UPDATE)
// Slot: 0=main-hand, 1=off-hand, 2=ranged. Value: expire time (steady_clock ms).
struct TempEnchantTimer {
uint32_t slot = 0;
uint64_t expireMs = 0; // std::chrono::steady_clock ms timestamp when it expires
};
const std::vector<TempEnchantTimer>& getTempEnchantTimers() const { return tempEnchantTimers_; }
// Returns remaining ms for a given slot, or 0 if absent/expired.
uint32_t getTempEnchantRemainingMs(uint32_t slot) const;
static constexpr const char* kTempEnchantSlotNames[] = { "Main Hand", "Off Hand", "Ranged" };
// ---- Readable text (books / scrolls / notes) ----
// Populated by handlePageTextQueryResponse(); multi-page items chain via nextPageId.
struct BookPage { uint32_t pageId = 0; std::string text; };
const std::vector<BookPage>& getBookPages() const { return bookPages_; }
bool hasBookOpen() const { return !bookPages_.empty(); }
void clearBook() { bookPages_.clear(); }
// Other player level-up callback — fires when another player gains a level
using OtherPlayerLevelUpCallback = std::function<void(uint64_t guid, uint32_t newLevel)>;
void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); }
@ -1409,6 +1504,17 @@ public:
using AchievementEarnedCallback = std::function<void(uint32_t achievementId, const std::string& name)>;
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
// 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); }
// Quest objective progress callback — fires on SMSG_QUESTUPDATE_ADD_KILL / ADD_ITEM
// questTitle: name of the quest; objectiveName: creature/item name; current/required counts
using QuestProgressCallback = std::function<void(const std::string& questTitle,
const std::string& objectiveName,
uint32_t current, uint32_t required)>;
void setQuestProgressCallback(QuestProgressCallback cb) { questProgressCallback_ = std::move(cb); }
const std::unordered_map<uint32_t, uint64_t>& getCriteriaProgress() const { return criteriaProgress_; }
/// Returns the WoW PackedTime earn date for an achievement, or 0 if unknown.
uint32_t getAchievementDate(uint32_t id) const {
@ -1422,6 +1528,18 @@ public:
static const std::string kEmpty;
return kEmpty;
}
/// Returns the description of an achievement by ID, or empty string if unknown.
const std::string& getAchievementDescription(uint32_t id) const {
auto it = achievementDescCache_.find(id);
if (it != achievementDescCache_.end()) return it->second;
static const std::string kEmpty;
return kEmpty;
}
/// Returns the point value of an achievement by ID, or 0 if unknown.
uint32_t getAchievementPoints(uint32_t id) const {
auto it = achievementPointsCache_.find(id);
return (it != achievementPointsCache_.end()) ? it->second : 0u;
}
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
@ -1448,6 +1566,14 @@ public:
using RepChangeCallback = std::function<void(const std::string& factionName, int32_t delta, int32_t standing)>;
void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); }
// PvP honor credit callback (honorable kill or BG reward)
using PvpHonorCallback = std::function<void(uint32_t honorAmount, uint64_t victimGuid, uint32_t victimRank)>;
void setPvpHonorCallback(PvpHonorCallback cb) { pvpHonorCallback_ = std::move(cb); }
// Item looted / received callback (SMSG_ITEM_PUSH_RESULT when showInChat is set)
using ItemLootCallback = std::function<void(uint32_t itemId, uint32_t count, uint32_t quality, const std::string& name)>;
void setItemLootCallback(ItemLootCallback cb) { itemLootCallback_ = std::move(cb); }
// Quest turn-in completion callback
using QuestCompleteCallback = std::function<void(uint32_t questId, const std::string& questTitle)>;
void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); }
@ -1544,6 +1670,11 @@ public:
return it != taxiNpcHasRoutes_.end() && it->second;
}
// Vehicle (WotLK)
bool isInVehicle() const { return vehicleId_ != 0; }
uint32_t getVehicleId() const { return vehicleId_; }
void sendRequestVehicleExit();
// Vendor
void openVendor(uint64_t npcGuid);
void closeVendor();
@ -1660,6 +1791,8 @@ public:
void closeTrainer();
const std::string& getSpellName(uint32_t spellId) const;
const std::string& getSpellRank(uint32_t spellId) const;
/// Returns the tooltip/description text from Spell.dbc (empty if unknown or has no text).
const std::string& getSpellDescription(uint32_t spellId) const;
const std::string& getSkillLineName(uint32_t spellId) const;
/// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other)
uint8_t getSpellDispelType(uint32_t spellId) const;
@ -2199,6 +2332,7 @@ private:
// ---- Phase 3: Spells ----
WorldEntryCallback worldEntryCallback_;
KnockBackCallback knockBackCallback_;
CameraShakeCallback cameraShakeCallback_;
UnstuckCallback unstuckCallback_;
UnstuckCallback unstuckGyCallback_;
UnstuckCallback unstuckHearthCallback_;
@ -2251,6 +2385,7 @@ private:
uint8_t activeTalentSpec_ = 0; // Currently active spec (0 or 1)
uint8_t unspentTalentPoints_[2] = {0, 0}; // Unspent points per spec
std::unordered_map<uint32_t, uint8_t> learnedTalents_[2]; // Learned talents per spec
std::array<std::array<uint16_t, MAX_GLYPH_SLOTS>, 2> learnedGlyphs_{}; // Glyphs per spec
std::unordered_map<uint32_t, TalentEntry> talentCache_; // talentId -> entry
std::unordered_map<uint32_t, TalentTabEntry> talentTabCache_; // tabId -> entry
bool talentDbcLoaded_ = false;
@ -2283,6 +2418,13 @@ private:
std::vector<uint32_t> petSpellList_; // known pet spells
std::unordered_set<uint32_t> petAutocastSpells_; // spells with autocast on
// ---- Pet Stable ----
bool stableWindowOpen_ = false;
uint64_t stableMasterGuid_ = 0;
uint8_t stableNumSlots_ = 0;
std::vector<StabledPet> stabledPets_;
void handleListStabledPets(network::Packet& packet);
// ---- Battleground queue state ----
std::array<BgQueueSlot, 3> bgQueues_{};
@ -2409,6 +2551,7 @@ private:
bool lootWindowOpen = false;
bool autoLoot_ = false;
LootResponseData currentLoot;
std::vector<uint64_t> masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST
// Group loot roll state
bool pendingLootRollActive_ = false;
@ -2436,6 +2579,7 @@ private:
std::unordered_map<uint64_t, float> recentLootMoneyAnnounceCooldowns_;
uint64_t playerMoneyCopper_ = 0;
int32_t playerArmorRating_ = 0;
int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane
// Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet
int32_t playerStats_[5] = {-1, -1, -1, -1, -1};
// Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating
@ -2483,6 +2627,9 @@ private:
return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown
}
// Vehicle (WotLK): non-zero when player is seated in a vehicle
uint32_t vehicleId_ = 0;
// Taxi / Flight Paths
std::unordered_map<uint64_t, bool> taxiNpcHasRoutes_; // guid -> has new/available routes
std::unordered_map<uint32_t, TaxiNode> taxiNodes_;
@ -2578,12 +2725,20 @@ private:
// Trainer
bool trainerWindowOpen_ = false;
TrainerListData currentTrainerList_;
struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; uint8_t dispelType = 0; };
struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; };
std::unordered_map<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;
// Achievement name cache (lazy-loaded from Achievement.dbc on first earned event)
// Title cache: maps titleBit → title string (lazy-loaded from CharTitles.dbc)
// The strings use "%s" as a player-name placeholder (e.g. "Commander %s", "%s the Explorer").
std::unordered_map<uint32_t, std::string> titleNameCache_;
bool titleNameCacheLoaded_ = false;
void loadTitleNameCache();
// Achievement caches (lazy-loaded from Achievement.dbc on first earned event)
std::unordered_map<uint32_t, std::string> achievementNameCache_;
std::unordered_map<uint32_t, std::string> achievementDescCache_;
std::unordered_map<uint32_t, uint32_t> achievementPointsCache_;
bool achievementNameCacheLoaded_ = false;
void loadAchievementNameCache();
// Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA)
@ -2702,6 +2857,7 @@ private:
StandStateCallback standStateCallback_;
GhostStateCallback ghostStateCallback_;
MeleeSwingCallback meleeSwingCallback_;
uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing
SpellCastAnimCallback spellCastAnimCallback_;
UnitAnimHintCallback unitAnimHintCallback_;
UnitMoveFlagsCallback unitMoveFlagsCallback_;
@ -2711,8 +2867,13 @@ private:
NpcVendorCallback npcVendorCallback_;
ChargeCallback chargeCallback_;
LevelUpCallback levelUpCallback_;
LevelUpDeltas lastLevelUpDeltas_;
std::vector<TempEnchantTimer> tempEnchantTimers_;
std::vector<BookPage> bookPages_; // pages collected for the current readable item
OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_;
AchievementEarnedCallback achievementEarnedCallback_;
AreaDiscoveryCallback areaDiscoveryCallback_;
QuestProgressCallback questProgressCallback_;
MountCallback mountCallback_;
TaxiPrecacheCallback taxiPrecacheCallback_;
TaxiOrientationCallback taxiOrientationCallback_;
@ -2766,6 +2927,7 @@ private:
std::array<uint64_t, 19> itemGuids{};
};
std::vector<EquipmentSet> equipmentSets_;
std::vector<EquipmentSetInfo> equipmentSetInfo_; // public-facing copy
// ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ----
std::unordered_map<uint32_t, uint8_t> forcedReactions_; // factionId -> reaction tier
@ -2782,6 +2944,12 @@ private:
RepChangeCallback repChangeCallback_;
uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction
// ---- PvP honor credit callback ----
PvpHonorCallback pvpHonorCallback_;
// ---- Item loot callback ----
ItemLootCallback itemLootCallback_;
// ---- Quest completion callback ----
QuestCompleteCallback questCompleteCallback_;
};

View file

@ -1594,6 +1594,10 @@ struct ItemQueryResponseData {
struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; };
std::vector<ExtraStat> extraStats;
uint32_t startQuestId = 0; // Non-zero: item begins a quest
// Gem socket slots (WotLK/TBC): 0=no socket; color mask: 1=Meta,2=Red,4=Yellow,8=Blue
std::array<uint32_t, 3> socketColor{};
uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none
uint32_t itemSetId = 0; // ItemSet.dbc entry; 0=not part of a set
bool valid = false;
};
@ -2695,5 +2699,33 @@ public:
static bool parse(network::Packet& packet, AuctionCommandResult& data);
};
/** Pet Stable packet builders */
class ListStabledPetsPacket {
public:
/** MSG_LIST_STABLED_PETS (CMSG): request list from stable master */
static network::Packet build(uint64_t stableMasterGuid);
};
class StablePetPacket {
public:
/** CMSG_STABLE_PET: store active pet in the given stable slot (1-based) */
static network::Packet build(uint64_t stableMasterGuid, uint8_t slot);
};
class UnstablePetPacket {
public:
/** CMSG_UNSTABLE_PET: retrieve a stabled pet by its server-side petNumber */
static network::Packet build(uint64_t stableMasterGuid, uint32_t petNumber);
};
class PetRenamePacket {
public:
/** CMSG_PET_RENAME: rename the player's active pet.
* petGuid: the pet's object GUID (from GameHandler::getPetGuid())
* name: new name (max 12 chars; server validates and may reject)
* isDeclined: 0 for non-Cyrillic locales (no declined name forms) */
static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0);
};
} // namespace game
} // namespace wowee

View file

@ -129,6 +129,12 @@ public:
// vspeed: raw packet vspeed field (server sends negative for upward launch)
void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed);
// Trigger a camera shake effect (e.g. from SMSG_CAMERA_SHAKE).
// magnitude: peak positional offset in world units
// frequency: oscillation frequency in Hz
// duration: shake duration in seconds
void triggerShake(float magnitude, float frequency, float duration);
// For first-person player hiding
void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) {
characterRenderer = cr;
@ -369,6 +375,12 @@ private:
glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s)
// Horizontal velocity decays via WoW-like drag so the player doesn't slide forever.
static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s)
// Camera shake state (SMSG_CAMERA_SHAKE)
float shakeElapsed_ = 0.0f;
float shakeDuration_ = 0.0f;
float shakeMagnitude_ = 0.0f;
float shakeFrequency_ = 0.0f;
};
} // namespace rendering

View file

@ -271,6 +271,10 @@ public:
float getShadowDistance() const { return shadowDistance_; }
void setMsaaSamples(VkSampleCountFlagBits samples);
// FXAA post-process anti-aliasing (combinable with MSAA)
void setFXAAEnabled(bool enabled);
bool isFXAAEnabled() const { return fxaa_.enabled; }
// FSR (FidelityFX Super Resolution) upscaling
void setFSREnabled(bool enabled);
bool isFSREnabled() const { return fsr_.enabled; }
@ -398,6 +402,31 @@ private:
void destroyFSRResources();
void renderFSRUpscale();
// FXAA post-process state
struct FXAAState {
bool enabled = false;
bool needsRecreate = false;
// Off-screen scene target (same resolution as swapchain — no scaling)
AllocatedImage sceneColor{}; // 1x resolved color target
AllocatedImage sceneDepth{}; // Depth (matches MSAA sample count)
AllocatedImage sceneMsaaColor{}; // MSAA color target (when MSAA > 1x)
AllocatedImage sceneDepthResolve{}; // Depth resolve (MSAA + depth resolve)
VkFramebuffer sceneFramebuffer = VK_NULL_HANDLE;
VkSampler sceneSampler = VK_NULL_HANDLE;
// FXAA fullscreen pipeline
VkPipeline pipeline = VK_NULL_HANDLE;
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
VkDescriptorSetLayout descSetLayout = VK_NULL_HANDLE;
VkDescriptorPool descPool = VK_NULL_HANDLE;
VkDescriptorSet descSet = VK_NULL_HANDLE;
};
FXAAState fxaa_;
bool initFXAAResources();
void destroyFXAAResources();
void renderFXAAPass();
// FSR 2.2 temporal upscaling state
struct FSR2State {
bool enabled = false;

View file

@ -3,6 +3,7 @@
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
#include <glm/glm.hpp>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
@ -17,6 +18,13 @@ class VkContext;
class VkTexture;
class VkRenderTarget;
/// Party member dot passed in from the UI layer for world map overlay.
struct WorldMapPartyDot {
glm::vec3 renderPos; ///< Position in render-space coordinates
uint32_t color; ///< RGBA packed color (IM_COL32 format)
std::string name; ///< Member name (shown as tooltip on hover)
};
struct WorldMapZone {
uint32_t wmaID = 0;
uint32_t areaID = 0; // 0 = continent level
@ -47,6 +55,7 @@ public:
void setMapName(const std::string& name);
void setServerExplorationMask(const std::vector<uint32_t>& masks, bool hasData);
void setPartyDots(std::vector<WorldMapPartyDot> dots) { partyDots_ = std::move(dots); }
bool isOpen() const { return open; }
void close() { open = false; }
@ -113,6 +122,9 @@ private:
// Texture storage (owns all VkTexture objects for zone tiles)
std::vector<std::unique_ptr<VkTexture>> zoneTextures;
// Party member dots (set each frame from the UI layer)
std::vector<WorldMapPartyDot> partyDots_;
// Exploration / fog of war
std::vector<uint32_t> serverExplorationMask;
bool hasServerExplorationMask = false;

View file

@ -157,6 +157,8 @@ private:
bool chatWindowLocked = true;
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
bool chatWindowPosInit_ = false;
ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default
bool questTrackerPosInit_ = false;
bool showEscapeMenu = false;
bool showEscapeSettingsNotice = false;
bool showSettingsWindow = false;
@ -204,6 +206,7 @@ private:
float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center
int pendingGroundClutterDensity = 100;
int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x
bool pendingFXAA = false; // FXAA post-process (combinable with MSAA)
bool pendingNormalMapping = true; // on by default
float pendingNormalMapStrength = 0.8f; // 0.0-2.0
bool pendingPOM = true; // on by default
@ -238,6 +241,7 @@ private:
bool minimapSettingsApplied_ = false;
bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers
bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer
bool fxaaSettingsApplied_ = false; // True once saved FXAA setting applied to renderer
bool waterRefractionApplied_ = false;
bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied
@ -314,6 +318,7 @@ private:
void renderRepBar(game::GameHandler& gameHandler);
void renderCastBar(game::GameHandler& gameHandler);
void renderMirrorTimers(game::GameHandler& gameHandler);
void renderCooldownTracker(game::GameHandler& gameHandler);
void renderCombatText(game::GameHandler& gameHandler);
void renderRaidWarningOverlay(game::GameHandler& gameHandler);
void renderPartyFrames(game::GameHandler& gameHandler);
@ -339,6 +344,7 @@ private:
void renderQuestOfferRewardWindow(game::GameHandler& gameHandler);
void renderVendorWindow(game::GameHandler& gameHandler);
void renderTrainerWindow(game::GameHandler& gameHandler);
void renderStableWindow(game::GameHandler& gameHandler);
void renderTaxiWindow(game::GameHandler& gameHandler);
void renderDeathScreen(game::GameHandler& gameHandler);
void renderReclaimCorpseButton(game::GameHandler& gameHandler);
@ -368,6 +374,7 @@ private:
void renderNameplates(game::GameHandler& gameHandler);
void renderBattlegroundScore(game::GameHandler& gameHandler);
void renderDPSMeter(game::GameHandler& gameHandler);
void renderDurabilityWarning(game::GameHandler& gameHandler);
/**
* Inventory screen
@ -427,10 +434,19 @@ private:
char gmTicketBuf_[2048] = {};
void renderGmTicketWindow(game::GameHandler& gameHandler);
// Pet rename modal (triggered from pet frame context menu)
bool petRenameOpen_ = false;
char petRenameBuf_[16] = {};
// Inspect window
bool showInspectWindow_ = false;
void renderInspectWindow(game::GameHandler& gameHandler);
// Readable text window (books / scrolls / notes)
bool showBookWindow_ = false;
int bookCurrentPage_ = 0;
void renderBookWindow(game::GameHandler& gameHandler);
// Threat window
bool showThreatWindow_ = false;
void renderThreatWindow(game::GameHandler& gameHandler);
@ -505,9 +521,12 @@ private:
bool leftClickWasPress_ = false;
// Level-up ding animation
static constexpr float DING_DURATION = 3.0f;
static constexpr float DING_DURATION = 4.0f;
float dingTimer_ = 0.0f;
uint32_t dingLevel_ = 0;
uint32_t dingHpDelta_ = 0;
uint32_t dingManaDelta_ = 0;
uint32_t dingStats_[5] = {}; // str/agi/sta/int/spi deltas
void renderDingEffect();
// Achievement toast banner
@ -517,6 +536,80 @@ private:
std::string achievementToastName_;
void renderAchievementToast();
// Area discovery toast ("Discovered! <AreaName> +XP XP")
static constexpr float DISCOVERY_TOAST_DURATION = 4.0f;
float discoveryToastTimer_ = 0.0f;
std::string discoveryToastName_;
uint32_t discoveryToastXP_ = 0;
bool areaDiscoveryCallbackSet_ = false;
void renderDiscoveryToast();
// Whisper toast — brief overlay at screen top when a whisper arrives while chat is not focused
struct WhisperToastEntry {
std::string sender;
std::string preview; // first ~60 chars of message
float age = 0.0f;
};
static constexpr float WHISPER_TOAST_DURATION = 5.0f;
std::vector<WhisperToastEntry> whisperToasts_;
size_t whisperSeenCount_ = 0; // how many chat entries have been scanned for whispers
void renderWhisperToasts();
// Quest objective progress toast ("Quest: <ObjectiveName> X/Y")
struct QuestProgressToastEntry {
std::string questTitle;
std::string objectiveName;
uint32_t current = 0;
uint32_t required = 0;
float age = 0.0f;
};
static constexpr float QUEST_TOAST_DURATION = 4.0f;
std::vector<QuestProgressToastEntry> questToasts_;
bool questProgressCallbackSet_ = false;
void renderQuestProgressToasts();
// Nearby player level-up toast ("<Name> is now level X!")
struct PlayerLevelUpToastEntry {
uint64_t guid = 0;
std::string playerName; // resolved lazily at render time
uint32_t newLevel = 0;
float age = 0.0f;
};
static constexpr float PLAYER_LEVELUP_TOAST_DURATION = 4.0f;
std::vector<PlayerLevelUpToastEntry> playerLevelUpToasts_;
bool otherPlayerLevelUpCallbackSet_ = false;
void renderPlayerLevelUpToasts(game::GameHandler& gameHandler);
// PvP honor credit toast ("+N Honor" shown when an honorable kill is credited)
struct PvpHonorToastEntry {
uint32_t honor = 0;
uint32_t victimRank = 0; // 0 = unranked / not available
float age = 0.0f;
};
static constexpr float PVP_HONOR_TOAST_DURATION = 3.5f;
std::vector<PvpHonorToastEntry> pvpHonorToasts_;
bool pvpHonorCallbackSet_ = false;
void renderPvpHonorToasts();
// Item loot toast — quality-coloured popup when an item is received
struct ItemLootToastEntry {
uint32_t itemId = 0;
uint32_t count = 0;
uint32_t quality = 1; // 0=grey,1=white,2=green,3=blue,4=purple,5=orange
std::string name;
float age = 0.0f;
};
static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f;
std::vector<ItemLootToastEntry> itemLootToasts_;
bool itemLootCallbackSet_ = false;
void renderItemLootToasts();
// Resurrection flash: brief "You have been resurrected!" overlay on ghost→alive transition
float resurrectFlashTimer_ = 0.0f;
static constexpr float kResurrectFlashDuration = 3.0f;
bool ghostStateCallbackSet_ = false;
void renderResurrectFlash();
// Zone discovery text ("Entering: <ZoneName>")
static constexpr float ZONE_TEXT_DURATION = 5.0f;
float zoneTextTimer_ = 0.0f;
@ -524,6 +617,9 @@ private:
std::string lastKnownZoneName_;
void renderZoneText();
// Cooldown tracker
bool showCooldownTracker_ = false;
// DPS / HPS meter
bool showDPSMeter_ = false;
float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS)
@ -533,7 +629,9 @@ private:
size_t dpsLogSeenCount_ = 0; // log entries already scanned
public:
void triggerDing(uint32_t newLevel);
void triggerDing(uint32_t newLevel, uint32_t hpDelta = 0, uint32_t manaDelta = 0,
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 = {});
};

View file

@ -149,7 +149,7 @@ private:
void renderEquipmentPanel(game::Inventory& inventory);
void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false);
void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0,
const int32_t* serverStats = nullptr);
const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr);
void renderReputationPanel(game::GameHandler& gameHandler);
void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot,

View file

@ -28,6 +28,8 @@ private:
void loadSpellDBC(pipeline::AssetManager* assetManager);
void loadSpellIconDBC(pipeline::AssetManager* assetManager);
void loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager);
void renderGlyphs(game::GameHandler& gameHandler);
VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager);
bool open = false;
@ -36,11 +38,16 @@ private:
// DBC caches
bool spellDbcLoaded = false;
bool iconDbcLoaded = false;
bool glyphDbcLoaded = false;
std::unordered_map<uint32_t, uint32_t> spellIconIds; // spellId -> iconId
std::unordered_map<uint32_t, std::string> spellIconPaths; // iconId -> path
std::unordered_map<uint32_t, VkDescriptorSet> spellIconCache; // iconId -> texture
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, VkDescriptorSet> bgTextureCache_; // tabId -> bg texture
// GlyphProperties.dbc cache: glyphId -> { spellId, isMajor }
struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; };
std::unordered_map<uint32_t, GlyphInfo> glyphProperties_; // glyphId -> info
};
} // namespace ui

View file

@ -646,6 +646,11 @@ void Application::setState(AppState newState) {
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
}
});
gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) {
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->triggerShake(magnitude, frequency, duration);
}
});
}
// Load quest marker models
loadQuestMarkerModels();

View file

@ -1701,12 +1701,17 @@ void GameHandler::handlePacket(network::Packet& packet) {
queryItemInfo(itemId, 0);
if (showInChat) {
std::string itemName = "item #" + std::to_string(itemId);
uint32_t quality = 1; // white default
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
if (!info->name.empty()) itemName = info->name;
quality = info->quality;
}
std::string msg = "Received: " + itemName;
if (count > 1) msg += " x" + std::to_string(count);
addSystemChatMessage(msg);
if (itemLootCallback_) {
itemLootCallback_(itemId, count, quality, itemName);
}
}
LOG_INFO("Item push: itemId=", itemId, " count=", count,
" showInChat=", static_cast<int>(showInChat));
@ -1767,6 +1772,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
addSystemChatMessage(msg);
// XP is updated via PLAYER_XP update fields from the server.
if (areaDiscoveryCallback_)
areaDiscoveryCallback_(areaName, xpGained);
}
}
break;
@ -1787,8 +1794,23 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_PET_ACTION_FEEDBACK: {
// uint8 action + uint8 flags
packet.setReadPos(packet.getSize()); // Consume; no UI for pet feedback yet.
// uint8 msg: 1=dead, 2=nothing_to_attack, 3=cant_attack_target,
// 4=target_too_far, 5=no_path, 6=cant_attack_immune
if (packet.getSize() - packet.getReadPos() < 1) break;
uint8_t msg = packet.readUInt8();
static const char* kPetFeedback[] = {
nullptr,
"Your pet is dead.",
"Your pet has nothing to attack.",
"Your pet cannot attack that target.",
"That target is too far away.",
"Your pet cannot find a path to the target.",
"Your pet cannot attack an immune target.",
};
if (msg > 0 && msg < 7 && kPetFeedback[msg]) {
addSystemChatMessage(kPetFeedback[msg]);
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: {
@ -1880,6 +1902,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::dec, " rank=", rank);
std::string msg = "You gain " + std::to_string(honor) + " honor points.";
addSystemChatMessage(msg);
if (pvpHonorCallback_) {
pvpHonorCallback_(honor, victimGuid, rank);
}
}
break;
}
@ -2045,6 +2070,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
// ---- Pet stable list ----
case Opcode::MSG_LIST_STABLED_PETS:
if (state == WorldState::IN_WORLD) handleListStabledPets(packet);
break;
// ---- Pet stable result ----
case Opcode::SMSG_STABLE_RESULT: {
// uint8 result
@ -2061,6 +2091,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
if (msg) addSystemChatMessage(msg);
LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast<int>(result));
// Refresh the stable list after a result to reflect the new state
if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) {
auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_);
socket->send(refreshPkt);
}
break;
}
@ -2070,12 +2105,40 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 8) break;
uint32_t titleBit = packet.readUInt32();
uint32_t isLost = packet.readUInt32();
char buf[128];
std::snprintf(buf, sizeof(buf),
isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!",
titleBit);
addSystemChatMessage(buf);
LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost);
loadTitleNameCache();
// Format the title string using the player's own name
std::string titleStr;
auto tit = titleNameCache_.find(titleBit);
if (tit != titleNameCache_.end() && !tit->second.empty()) {
// Title strings contain "%s" as a player-name placeholder.
// Replace it with the local player's name if known.
auto nameIt = playerNameCache.find(playerGuid);
const std::string& pName = (nameIt != playerNameCache.end())
? nameIt->second : "you";
const std::string& fmt = tit->second;
size_t pos = fmt.find("%s");
if (pos != std::string::npos) {
titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2);
} else {
titleStr = fmt;
}
}
std::string msg;
if (!titleStr.empty()) {
msg = isLost ? ("Title removed: " + titleStr + ".")
: ("Title earned: " + titleStr + "!");
} else {
char buf[64];
std::snprintf(buf, sizeof(buf),
isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!",
titleBit);
msg = buf;
}
addSystemChatMessage(msg);
LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost,
" title='", titleStr, "'");
break;
}
@ -2697,6 +2760,25 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleMoveKnockBack(packet);
break;
case Opcode::SMSG_CAMERA_SHAKE: {
// uint32 shakeID (CameraShakes.dbc), uint32 shakeType
// We don't parse CameraShakes.dbc; apply a hardcoded moderate shake.
if (packet.getSize() - packet.getReadPos() >= 8) {
uint32_t shakeId = packet.readUInt32();
uint32_t shakeType = packet.readUInt32();
(void)shakeType;
// Map shakeId ranges to approximate magnitudes:
// IDs < 50: minor environmental (0.04), others: larger boss effects (0.08)
float magnitude = (shakeId < 50) ? 0.04f : 0.08f;
if (cameraShakeCallback_) {
cameraShakeCallback_(magnitude, 18.0f, 0.5f);
}
LOG_DEBUG("SMSG_CAMERA_SHAKE: id=", shakeId, " type=", shakeType,
" magnitude=", magnitude);
}
break;
}
case Opcode::SMSG_CLIENT_CONTROL_UPDATE: {
// Minimal parse: PackedGuid + uint8 allowMovement.
if (packet.getSize() - packet.getReadPos() < 2) {
@ -3332,10 +3414,19 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_LOOT_ROLL_WON:
handleLootRollWon(packet);
break;
case Opcode::SMSG_LOOT_MASTER_LIST:
// Master looter list — no UI yet; consume to avoid unhandled warning.
packet.setReadPos(packet.getSize());
case Opcode::SMSG_LOOT_MASTER_LIST: {
// uint8 count + count * uint64 guid — eligible recipients for master looter
masterLootCandidates_.clear();
if (packet.getSize() - packet.getReadPos() < 1) break;
uint8_t mlCount = packet.readUInt8();
masterLootCandidates_.reserve(mlCount);
for (uint8_t i = 0; i < mlCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 8) break;
masterLootCandidates_.push_back(packet.readUInt64());
}
LOG_INFO("SMSG_LOOT_MASTER_LIST: ", (int)masterLootCandidates_.size(), " candidates");
break;
}
case Opcode::SMSG_GOSSIP_MESSAGE:
handleGossipMessage(packet);
break;
@ -3813,10 +3904,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_LEVELUP_INFO:
case Opcode::SMSG_LEVELUP_INFO_ALT: {
// Server-authoritative level-up event.
// First field is always the new level in Classic/TBC/WotLK-era layouts.
// WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t newLevel = packet.readUInt32();
if (newLevel > 0) {
// Parse stat deltas (WotLK layout has 7 more uint32s)
lastLevelUpDeltas_ = {};
if (packet.getSize() - packet.getReadPos() >= 28) {
lastLevelUpDeltas_.hp = packet.readUInt32();
lastLevelUpDeltas_.mana = packet.readUInt32();
lastLevelUpDeltas_.str = packet.readUInt32();
lastLevelUpDeltas_.agi = packet.readUInt32();
lastLevelUpDeltas_.sta = packet.readUInt32();
lastLevelUpDeltas_.intel = packet.readUInt32();
lastLevelUpDeltas_.spi = packet.readUInt32();
}
uint32_t oldLevel = serverPlayerLevel_;
serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel);
for (auto& ch : characters) {
@ -3830,7 +3932,6 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
}
}
// Remaining payload (hp/mana/stat deltas) is optional for our client.
packet.setReadPos(packet.getSize());
break;
}
@ -4232,6 +4333,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
weatherIntensity_ = wIntensity;
const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear";
LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity);
// Storm transition: trigger a low-frequency thunder rumble shake
if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) {
float mag = 0.03f + wIntensity * 0.04f; // 0.030.07 units
cameraShakeCallback_(mag, 6.0f, 0.6f);
}
}
break;
}
@ -4482,6 +4588,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
progressMsg += std::to_string(count) + "/" + std::to_string(reqCount);
addSystemChatMessage(progressMsg);
if (questProgressCallback_) {
questProgressCallback_(quest.title, creatureName, count, reqCount);
}
LOG_INFO("Updated kill count for quest ", questId, ": ",
count, "/", reqCount);
break;
@ -4536,6 +4646,26 @@ void GameHandler::handlePacket(network::Packet& packet) {
updatedAny = true;
}
addSystemChatMessage("Quest item: " + itemLabel + " (" + std::to_string(count) + ")");
if (questProgressCallback_ && updatedAny) {
// Find the quest that tracks this item to get title and required count
for (const auto& quest : questLog_) {
if (quest.complete) continue;
if (quest.itemCounts.count(itemId) == 0) continue;
uint32_t required = 0;
auto rIt = quest.requiredItemCounts.find(itemId);
if (rIt != quest.requiredItemCounts.end()) required = rIt->second;
if (required == 0) {
for (const auto& obj : quest.itemObjectives) {
if (obj.itemId == itemId) { required = obj.required; break; }
}
}
if (required == 0) required = count;
questProgressCallback_(quest.title, itemLabel, count, required);
break;
}
}
LOG_INFO("Quest item update: itemId=", itemId, " count=", count,
" trackedQuestsUpdated=", updatedAny);
}
@ -5177,10 +5307,19 @@ void GameHandler::handlePacket(network::Packet& packet) {
// GM ticket status (new/updated); no ticket UI yet
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_PLAYER_VEHICLE_DATA:
// Vehicle data update for player in vehicle; no vehicle UI yet
packet.setReadPos(packet.getSize());
case Opcode::SMSG_PLAYER_VEHICLE_DATA: {
// PackedGuid (player guid) + uint32 vehicleId
// vehicleId == 0 means the player left the vehicle
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet); // player guid (unused)
}
if (packet.getSize() - packet.getReadPos() >= 4) {
vehicleId_ = packet.readUInt32();
} else {
vehicleId_ = 0;
}
break;
}
case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE:
packet.setReadPos(packet.getSize());
break;
@ -5636,9 +5775,56 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
// ---- Misc consume ----
case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: {
// Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid
// slot: 0=main-hand, 1=off-hand, 2=ranged
if (packet.getSize() - packet.getReadPos() < 24) {
packet.setReadPos(packet.getSize()); break;
}
/*uint64_t itemGuid =*/ packet.readUInt64();
uint32_t enchSlot = packet.readUInt32();
uint32_t durationSec = packet.readUInt32();
/*uint64_t playerGuid =*/ packet.readUInt64();
// Clamp to known slots (0-2)
if (enchSlot > 2) { break; }
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
if (durationSec == 0) {
// Enchant expired / removed — erase the slot entry
tempEnchantTimers_.erase(
std::remove_if(tempEnchantTimers_.begin(), tempEnchantTimers_.end(),
[enchSlot](const TempEnchantTimer& t) { return t.slot == enchSlot; }),
tempEnchantTimers_.end());
} else {
uint64_t expireMs = nowMs + static_cast<uint64_t>(durationSec) * 1000u;
bool found = false;
for (auto& t : tempEnchantTimers_) {
if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; }
}
if (!found) tempEnchantTimers_.push_back({enchSlot, expireMs});
// Warn at important thresholds
if (durationSec <= 60 && durationSec > 55) {
const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon";
char buf[80];
std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName);
addSystemChatMessage(buf);
} else if (durationSec <= 300 && durationSec > 295) {
const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon";
char buf[80];
std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName);
addSystemChatMessage(buf);
}
}
LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s");
break;
}
case Opcode::SMSG_COMPLAIN_RESULT:
case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE:
case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE:
case Opcode::SMSG_LOOT_LIST:
// Consume — not yet processed
packet.setReadPos(packet.getSize());
@ -5989,7 +6175,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
// ---- Read item results ----
case Opcode::SMSG_READ_ITEM_OK:
addSystemChatMessage("You read the item.");
bookPages_.clear(); // fresh book for this item read
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_READ_ITEM_FAILED:
@ -6150,10 +6336,58 @@ void GameHandler::handlePacket(network::Packet& packet) {
packet.setReadPos(packet.getSize());
break;
// ---- Inspect (full character inspection) ----
case Opcode::SMSG_INSPECT:
packet.setReadPos(packet.getSize());
// ---- Inspect (Classic 1.12 gear inspection) ----
case Opcode::SMSG_INSPECT: {
// Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19)
// This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to
// SMSG_INSPECT_RESULTS_UPDATE which is handled separately.
if (packet.getSize() - packet.getReadPos() < 2) {
packet.setReadPos(packet.getSize()); break;
}
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (guid == 0) { packet.setReadPos(packet.getSize()); break; }
constexpr int kGearSlots = 19;
size_t needed = kGearSlots * sizeof(uint32_t);
if (packet.getSize() - packet.getReadPos() < needed) {
packet.setReadPos(packet.getSize()); break;
}
std::array<uint32_t, 19> items{};
for (int s = 0; s < kGearSlots; ++s)
items[s] = packet.readUInt32();
// Resolve player name
auto ent = entityManager.getEntity(guid);
std::string playerName = "Target";
if (ent) {
auto pl = std::dynamic_pointer_cast<Player>(ent);
if (pl && !pl->getName().empty()) playerName = pl->getName();
}
// Populate inspect result immediately (no talent data in Classic SMSG_INSPECT)
inspectResult_.guid = guid;
inspectResult_.playerName = playerName;
inspectResult_.totalTalents = 0;
inspectResult_.unspentTalents = 0;
inspectResult_.talentGroups = 0;
inspectResult_.activeTalentGroup = 0;
inspectResult_.itemEntries = items;
inspectResult_.enchantIds = {};
// Also cache for future talent-inspect cross-reference
inspectedPlayerItemEntries_[guid] = items;
// Trigger item queries for non-empty slots
for (int s = 0; s < kGearSlots; ++s) {
if (items[s] != 0) queryItemInfo(items[s], 0);
}
LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ",
std::count_if(items.begin(), items.end(),
[](uint32_t e) { return e != 0; }), "/19 slots");
break;
}
// ---- Multiple aggregated packets/moves ----
case Opcode::SMSG_MULTIPLE_MOVES:
@ -6209,6 +6443,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleQuestPoiQueryResponse(packet);
break;
case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA:
vehicleId_ = 0; // Vehicle ride cancelled; clear UI
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER:
case Opcode::SMSG_PROFILEDATA_RESPONSE:
packet.setReadPos(packet.getSize());
@ -6703,6 +6940,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
onlineEquipDirty_ = false;
playerMoneyCopper_ = 0;
playerArmorRating_ = 0;
std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0);
std::fill(std::begin(playerStats_), std::end(playerStats_), -1);
knownSpells.clear();
spellCooldowns.clear();
@ -6712,6 +6950,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
unitAurasCache_.clear();
unitCastStates_.clear();
petGuid_ = 0;
stableWindowOpen_ = false;
stableMasterGuid_ = 0;
stableNumSlots_ = 0;
stabledPets_.clear();
playerXp_ = 0;
playerNextLevelXp_ = 0;
serverPlayerLevel_ = 1;
@ -6833,6 +7075,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
taxiStartGrace_ = 0.0f;
currentMountDisplayId_ = 0;
taxiMountDisplayId_ = 0;
vehicleId_ = 0;
if (mountCallback_) {
mountCallback_(0);
}
@ -6846,6 +7089,8 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
talentsInitialized_ = false;
learnedTalents_[0].clear();
learnedTalents_[1].clear();
learnedGlyphs_[0].fill(0);
learnedGlyphs_[1].fill(0);
unspentTalentPoints_[0] = 0;
unspentTalentPoints_[1] = 0;
activeTalentSpec_ = 0;
@ -7856,6 +8101,22 @@ void GameHandler::sendPing() {
socket->send(packet);
}
void GameHandler::sendRequestVehicleExit() {
if (state != WorldState::IN_WORLD || vehicleId_ == 0) return;
// CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only
network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT));
socket->send(pkt);
vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0)
}
void GameHandler::useEquipmentSet(uint32_t setId) {
if (state != WorldState::IN_WORLD) return;
// CMSG_EQUIPMENT_SET_USE: uint32 setId
network::Packet pkt(wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE));
pkt.writeUInt32(setId);
socket->send(pkt);
}
void GameHandler::sendMinimapPing(float wowX, float wowY) {
if (state != WorldState::IN_WORLD) return;
@ -8167,6 +8428,7 @@ void GameHandler::forceClearTaxiAndMovementState() {
taxiMountActive_ = false;
taxiMountDisplayId_ = 0;
currentMountDisplayId_ = 0;
vehicleId_ = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
playerDead_ = false;
@ -8807,6 +9069,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerArmorRating_ = static_cast<int32_t>(val);
LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_);
}
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(val);
}
else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) {
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec,
@ -9147,6 +9412,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
else if (ufArmor != 0xFFFF && key == ufArmor) {
playerArmorRating_ = static_cast<int32_t>(val);
}
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(val);
}
else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) {
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
@ -11140,6 +11408,7 @@ void GameHandler::handleGameObjectPageText(network::Packet& packet) {
else if (info.type == 10) pageId = info.data[7];
if (pageId != 0 && socket && state == WorldState::IN_WORLD) {
bookPages_.clear(); // start a fresh book for this interaction
auto req = PageTextQueryPacket::build(pageId, guid);
socket->send(req);
return;
@ -11154,19 +11423,31 @@ void GameHandler::handlePageTextQueryResponse(network::Packet& packet) {
PageTextQueryResponseData data;
if (!PageTextQueryResponseParser::parse(packet, data)) return;
if (!data.text.empty()) {
std::istringstream iss(data.text);
std::string line;
bool wrote = false;
while (std::getline(iss, line)) {
if (line.empty()) continue;
addSystemChatMessage(line);
wrote = true;
if (!data.isValid()) return;
// Append page if not already collected
bool alreadyHave = false;
for (const auto& bp : bookPages_) {
if (bp.pageId == data.pageId) { alreadyHave = true; break; }
}
if (!alreadyHave) {
bookPages_.push_back({data.pageId, data.text});
}
// Follow the chain: if there's a next page we haven't fetched yet, request it
if (data.nextPageId != 0) {
bool nextHave = false;
for (const auto& bp : bookPages_) {
if (bp.pageId == data.nextPageId) { nextHave = true; break; }
}
if (!wrote) {
addSystemChatMessage(data.text);
if (!nextHave && socket && state == WorldState::IN_WORLD) {
auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid);
socket->send(req);
}
}
LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId,
" nextPage=", data.nextPageId,
" totalPages=", bookPages_.size());
}
// ============================================================
@ -11278,10 +11559,12 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
learnedTalents_[g][talentId] = rank;
}
if (packet.getSize() - packet.getReadPos() < 1) break;
learnedGlyphs_[g].fill(0);
uint8_t glyphCount = packet.readUInt8();
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
if (packet.getSize() - packet.getReadPos() < 2) break;
packet.readUInt16(); // glyphId (skip)
uint16_t glyphId = packet.readUInt16();
if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId;
}
}
@ -14045,8 +14328,11 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
bool isPlayerTarget = (data.targetGuid == playerGuid);
if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat
if (isPlayerAttacker && meleeSwingCallback_) {
meleeSwingCallback_();
if (isPlayerAttacker) {
lastMeleeSwingMs_ = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count());
if (meleeSwingCallback_) meleeSwingCallback_();
}
if (!isPlayerAttacker && npcSwingCallback_) {
npcSwingCallback_(data.attackerGuid);
@ -14289,6 +14575,19 @@ void GameHandler::cancelAura(uint32_t spellId) {
socket->send(packet);
}
uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const {
uint64_t nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
for (const auto& t : tempEnchantTimers_) {
if (t.slot == slot) {
return (t.expireMs > nowMs)
? static_cast<uint32_t>(t.expireMs - nowMs) : 0u;
}
}
return 0u;
}
void GameHandler::handlePetSpells(network::Packet& packet) {
const size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 8) {
@ -14364,6 +14663,86 @@ void GameHandler::dismissPet() {
socket->send(packet);
}
void GameHandler::renamePet(const std::string& newName) {
if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return;
if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars
auto packet = PetRenamePacket::build(petGuid_, newName, 0);
socket->send(packet);
LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, petGuid_, std::dec, " name='", newName, "'");
}
void GameHandler::requestStabledPetList() {
if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return;
auto pkt = ListStabledPetsPacket::build(stableMasterGuid_);
socket->send(pkt);
LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, stableMasterGuid_, std::dec);
}
void GameHandler::stablePet(uint8_t slot) {
if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return;
if (petGuid_ == 0) {
addSystemChatMessage("You do not have an active pet to stable.");
return;
}
auto pkt = StablePetPacket::build(stableMasterGuid_, slot);
socket->send(pkt);
LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast<int>(slot));
}
void GameHandler::unstablePet(uint32_t petNumber) {
if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0 || petNumber == 0) return;
auto pkt = UnstablePetPacket::build(stableMasterGuid_, petNumber);
socket->send(pkt);
LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber);
}
void GameHandler::handleListStabledPets(network::Packet& packet) {
// SMSG MSG_LIST_STABLED_PETS:
// uint64 stableMasterGuid
// uint8 petCount
// uint8 numSlots
// per pet:
// uint32 petNumber
// uint32 entry
// uint32 level
// string name (null-terminated)
// uint32 displayId
// uint8 isActive (1 = active/summoned, 0 = stabled)
constexpr size_t kMinHeader = 8 + 1 + 1;
if (packet.getSize() - packet.getReadPos() < kMinHeader) {
LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")");
return;
}
stableMasterGuid_ = packet.readUInt64();
uint8_t petCount = packet.readUInt8();
stableNumSlots_ = packet.readUInt8();
stabledPets_.clear();
stabledPets_.reserve(petCount);
for (uint8_t i = 0; i < petCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 4 + 4 + 4) break;
StabledPet pet;
pet.petNumber = packet.readUInt32();
pet.entry = packet.readUInt32();
pet.level = packet.readUInt32();
pet.name = packet.readString();
if (packet.getSize() - packet.getReadPos() < 4 + 1) break;
pet.displayId = packet.readUInt32();
pet.isActive = (packet.readUInt8() != 0);
stabledPets_.push_back(std::move(pet));
}
stableWindowOpen_ = true;
LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, stableMasterGuid_, std::dec,
" petCount=", (int)petCount, " numSlots=", (int)stableNumSlots_);
for (const auto& p : stabledPets_) {
LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry,
" level=", p.level, " name='", p.name, "' displayId=", p.displayId,
" active=", p.isActive);
}
}
void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) {
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
actionBar[slot].type = type;
@ -15500,6 +15879,7 @@ void GameHandler::lootItem(uint8_t slotIndex) {
void GameHandler::closeLoot() {
if (!lootWindowOpen) return;
lootWindowOpen = false;
masterLootCandidates_.clear();
if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) {
clearTarget();
}
@ -15510,6 +15890,16 @@ void GameHandler::closeLoot() {
currentLoot = LootResponseData{};
}
void GameHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
// CMSG_LOOT_MASTER_GIVE: uint64 lootGuid + uint8 slotIndex + uint64 targetGuid
network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_MASTER_GIVE));
pkt.writeUInt64(currentLoot.lootGuid);
pkt.writeUInt8(lootSlot);
pkt.writeUInt64(targetGuid);
socket->send(pkt);
}
void GameHandler::interactWithNpc(uint64_t guid) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GossipHelloPacket::build(guid);
@ -15689,6 +16079,18 @@ void GameHandler::selectGossipOption(uint32_t optionId) {
socket->send(bindPkt);
LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
}
// Stable master detection: GOSSIP_OPTION_STABLE or text keywords
if (text == "GOSSIP_OPTION_STABLE" ||
textLower.find("stable") != std::string::npos ||
textLower.find("my pet") != std::string::npos) {
stableMasterGuid_ = currentGossip.npcGuid;
stableWindowOpen_ = false; // will open when list arrives
auto listPkt = ListStabledPetsPacket::build(currentGossip.npcGuid);
socket->send(listPkt);
LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to npc=0x",
std::hex, currentGossip.npcGuid, std::dec);
}
break;
}
}
@ -17154,6 +17556,13 @@ void GameHandler::loadSpellNameCache() {
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; }
}
// Tooltip/description field
uint32_t tooltipField = 0xFFFFFFFF;
if (spellL) {
uint32_t f = spellL->field("Tooltip");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f;
}
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
@ -17161,7 +17570,10 @@ void GameHandler::loadSpellNameCache() {
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
if (!name.empty()) {
SpellNameEntry entry{std::move(name), std::move(rank), 0, 0};
SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0};
if (tooltipField != 0xFFFFFFFF) {
entry.description = dbc->getString(i, tooltipField);
}
if (hasSchoolMask) {
entry.schoolMask = dbc->getUInt32(i, schoolMaskField);
} else if (hasSchoolEnum) {
@ -17366,6 +17778,12 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const {
return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING;
}
const std::string& GameHandler::getSpellDescription(uint32_t spellId) const {
const_cast<GameHandler*>(this)->loadSpellNameCache();
auto it = spellNameCache_.find(spellId);
return (it != spellNameCache_.end()) ? it->second.description : EMPTY_STRING;
}
uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const {
const_cast<GameHandler*>(this)->loadSpellNameCache();
auto it = spellNameCache_.find(spellId);
@ -20282,6 +20700,33 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT
// PackedTime date — uint32 bitfield (seconds since epoch)
// uint32 realmFirst — how many on realm also got it (0 = realm first)
// ---------------------------------------------------------------------------
void GameHandler::loadTitleNameCache() {
if (titleNameCacheLoaded_) return;
titleNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("CharTitles.dbc");
if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 5) return;
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("CharTitles") : nullptr;
uint32_t titleField = layout ? layout->field("Title") : 2;
uint32_t bitField = layout ? layout->field("TitleBit") : 36;
if (titleField == 0xFFFFFFFF) titleField = 2;
if (bitField == 0xFFFFFFFF) bitField = static_cast<uint32_t>(dbc->getFieldCount() - 1);
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t bit = dbc->getUInt32(i, bitField);
if (bit == 0) continue;
std::string name = dbc->getString(i, titleField);
if (!name.empty()) titleNameCache_[bit] = std::move(name);
}
LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC");
}
void GameHandler::loadAchievementNameCache() {
if (achievementNameCacheLoaded_) return;
achievementNameCacheLoaded_ = true;
@ -20296,12 +20741,23 @@ void GameHandler::loadAchievementNameCache() {
? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr;
uint32_t titleField = achL ? achL->field("Title") : 4;
if (titleField == 0xFFFFFFFF) titleField = 4;
uint32_t descField = achL ? achL->field("Description") : 0xFFFFFFFF;
uint32_t ptsField = achL ? achL->field("Points") : 0xFFFFFFFF;
uint32_t fieldCount = dbc->getFieldCount();
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t id = dbc->getUInt32(i, 0);
if (id == 0) continue;
std::string title = dbc->getString(i, titleField);
if (!title.empty()) achievementNameCache_[id] = std::move(title);
if (descField != 0xFFFFFFFF && descField < fieldCount) {
std::string desc = dbc->getString(i, descField);
if (!desc.empty()) achievementDescCache_[id] = std::move(desc);
}
if (ptsField != 0xFFFFFFFF && ptsField < fieldCount) {
uint32_t pts = dbc->getUInt32(i, ptsField);
if (pts > 0) achievementPointsCache_[id] = pts;
}
}
LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc");
}
@ -20539,6 +20995,17 @@ void GameHandler::handleEquipmentSetList(network::Packet& packet) {
}
equipmentSets_.push_back(std::move(es));
}
// Populate public-facing info
equipmentSetInfo_.clear();
equipmentSetInfo_.reserve(equipmentSets_.size());
for (const auto& es : equipmentSets_) {
EquipmentSetInfo info;
info.setGuid = es.setGuid;
info.setId = es.setId;
info.name = es.name;
info.iconName = es.iconName;
equipmentSetInfo_.push_back(std::move(info));
}
LOG_INFO("SMSG_EQUIPMENT_SET_LIST: ", equipmentSets_.size(), " equipment sets received");
}

View file

@ -2930,6 +2930,29 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
data.startQuestId = packet.readUInt32(); // StartQuest
}
// WotLK 3.3.5a: additional fields after StartQuest (read up to socket data)
// LockID(4), Material(4), Sheath(4), RandomProperty(4), RandomSuffix(4),
// Block(4), ItemSet(4), MaxDurability(4), Area(4), Map(4), BagFamily(4),
// TotemCategory(4) = 48 bytes before sockets
constexpr size_t kPreSocketSkip = 48;
if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) {
// LockID(0), Material(1), Sheath(2), RandomProperty(3), RandomSuffix(4), Block(5)
for (size_t i = 0; i < 6; ++i) packet.readUInt32();
data.itemSetId = packet.readUInt32(); // ItemSet(6)
// MaxDurability(7), Area(8), Map(9), BagFamily(10), TotemCategory(11)
for (size_t i = 0; i < 5; ++i) packet.readUInt32();
// 3 socket slots: socketColor (4 bytes each)
data.socketColor[0] = packet.readUInt32();
data.socketColor[1] = packet.readUInt32();
data.socketColor[2] = packet.readUInt32();
// 3 socket content (gem enchantment IDs — skip, not currently displayed)
packet.readUInt32();
packet.readUInt32();
packet.readUInt32();
// socketBonus (enchantmentId)
data.socketBonus = packet.readUInt32();
}
data.valid = !data.name.empty();
return true;
}
@ -5374,5 +5397,37 @@ bool AuctionCommandResultParser::parse(network::Packet& packet, AuctionCommandRe
return true;
}
// ============================================================
// Pet Stable System
// ============================================================
network::Packet ListStabledPetsPacket::build(uint64_t stableMasterGuid) {
network::Packet p(wireOpcode(Opcode::MSG_LIST_STABLED_PETS));
p.writeUInt64(stableMasterGuid);
return p;
}
network::Packet StablePetPacket::build(uint64_t stableMasterGuid, uint8_t slot) {
network::Packet p(wireOpcode(Opcode::CMSG_STABLE_PET));
p.writeUInt64(stableMasterGuid);
p.writeUInt8(slot);
return p;
}
network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t petNumber) {
network::Packet p(wireOpcode(Opcode::CMSG_UNSTABLE_PET));
p.writeUInt64(stableMasterGuid);
p.writeUInt32(petNumber);
return p;
}
network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name, uint8_t isDeclined) {
network::Packet p(wireOpcode(Opcode::CMSG_PET_RENAME));
p.writeUInt64(petGuid);
p.writeString(name); // null-terminated
p.writeUInt8(isDeclined);
return p;
}
} // namespace game
} // namespace wowee

View file

@ -140,6 +140,17 @@ std::optional<float> CameraController::getCachedFloorHeight(float x, float y, fl
return result;
}
void CameraController::triggerShake(float magnitude, float frequency, float duration) {
// Allow stronger shake to override weaker; don't allow zero magnitude.
if (magnitude <= 0.0f || duration <= 0.0f) return;
if (magnitude > shakeMagnitude_ || shakeElapsed_ >= shakeDuration_) {
shakeMagnitude_ = magnitude;
shakeFrequency_ = frequency;
shakeDuration_ = duration;
shakeElapsed_ = 0.0f;
}
}
void CameraController::update(float deltaTime) {
if (!enabled || !camera) {
return;
@ -1859,6 +1870,23 @@ void CameraController::update(float deltaTime) {
wasFalling = !grounded && verticalVelocity <= 0.0f;
// R key is now handled above with chat safeguard (WantTextInput check)
// Camera shake (SMSG_CAMERA_SHAKE): apply sinusoidal offset to final camera position.
if (shakeElapsed_ < shakeDuration_) {
shakeElapsed_ += deltaTime;
float t = shakeElapsed_ / shakeDuration_;
// Envelope: fade out over the last 30% of shake duration
float envelope = (t < 0.7f) ? 1.0f : (1.0f - (t - 0.7f) / 0.3f);
float theta = shakeElapsed_ * shakeFrequency_ * 2.0f * 3.14159265f;
glm::vec3 offset(
shakeMagnitude_ * envelope * std::sin(theta),
shakeMagnitude_ * envelope * std::cos(theta * 1.3f),
shakeMagnitude_ * envelope * std::sin(theta * 0.7f) * 0.5f
);
if (camera) {
camera->setPosition(camera->getPosition() + offset);
}
}
}
void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) {

View file

@ -3905,7 +3905,14 @@ void M2Renderer::cleanupUnusedModels() {
}
}
// Delete GPU resources and remove from map
// Delete GPU resources and remove from map.
// Wait for the GPU to finish all in-flight frames before destroying any
// buffers — the previous frame's command buffer may still be referencing
// vertex/index buffers that are about to be freed. Without this wait,
// the GPU reads freed memory, which can cause VK_ERROR_DEVICE_LOST.
if (!toRemove.empty() && vkCtx_) {
vkDeviceWaitIdle(vkCtx_->getDevice());
}
for (uint32_t id : toRemove) {
auto it = models.find(id);
if (it != models.end()) {

View file

@ -219,6 +219,13 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) {
ImGui::Text(" Upscale Dispatches: %zu", renderer->getAmdFsr3UpscaleDispatchCount());
ImGui::Text(" FG Fallbacks: %zu", renderer->getAmdFsr3FallbackCount());
}
if (renderer->isFXAAEnabled()) {
if (renderer->isFSR2Enabled()) {
ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.8f, 1.0f), "FXAA: ON (FSR3+FXAA combined)");
} else {
ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON");
}
}
ImGui::Spacing();
}

View file

@ -858,6 +858,7 @@ void Renderer::shutdown() {
destroyFSRResources();
destroyFSR2Resources();
destroyFXAAResources();
destroyPerFrameResources();
zoneManager.reset();
@ -960,8 +961,9 @@ void Renderer::applyMsaaChange() {
VkDevice device = vkCtx->getDevice();
if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; }
if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; }
if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame()
if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame()
if (fsr2_.sceneFramebuffer) destroyFSR2Resources();
if (fxaa_.sceneFramebuffer) destroyFXAAResources(); // Will be lazily recreated in beginFrame()
// Reinitialize ImGui Vulkan backend with new MSAA sample count
ImGui_ImplVulkan_Shutdown();
@ -1017,6 +1019,21 @@ void Renderer::beginFrame() {
}
}
// FXAA resource management — FXAA can coexist with FSR1 and FSR3.
// When both FXAA and FSR3 are enabled, FXAA runs as a post-FSR3 pass.
// When both FXAA and FSR1 are enabled, FXAA takes priority (native res render).
if (fxaa_.needsRecreate && fxaa_.sceneFramebuffer) {
destroyFXAAResources();
fxaa_.needsRecreate = false;
if (!fxaa_.enabled) LOG_INFO("FXAA: disabled");
}
if (fxaa_.enabled && !fxaa_.sceneFramebuffer) {
if (!initFXAAResources()) {
LOG_ERROR("FXAA: initialization failed, disabling");
fxaa_.enabled = false;
}
}
// Handle swapchain recreation if needed
if (vkCtx->isSwapchainDirty()) {
vkCtx->recreateSwapchain(window->getWidth(), window->getHeight());
@ -1033,6 +1050,12 @@ void Renderer::beginFrame() {
destroyFSR2Resources();
initFSR2Resources();
}
// Recreate FXAA resources for new swapchain dimensions
// FXAA can coexist with FSR1 and FSR3 simultaneously.
if (fxaa_.enabled) {
destroyFXAAResources();
initFXAAResources();
}
}
// Acquire swapchain image and begin command buffer
@ -1119,6 +1142,11 @@ void Renderer::beginFrame() {
if (fsr2_.enabled && fsr2_.sceneFramebuffer) {
rpInfo.framebuffer = fsr2_.sceneFramebuffer;
renderExtent = { fsr2_.internalWidth, fsr2_.internalHeight };
} else if (fxaa_.enabled && fxaa_.sceneFramebuffer) {
// FXAA takes priority over FSR1: renders at native res with AA post-process.
// When both FSR1 and FXAA are enabled, FXAA wins (native res, no downscale).
rpInfo.framebuffer = fxaa_.sceneFramebuffer;
renderExtent = vkCtx->getSwapchainExtent(); // native resolution — no downscaling
} else if (fsr_.enabled && fsr_.sceneFramebuffer) {
rpInfo.framebuffer = fsr_.sceneFramebuffer;
renderExtent = { fsr_.internalWidth, fsr_.internalHeight };
@ -1208,6 +1236,35 @@ void Renderer::endFrame() {
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
}
// FSR3+FXAA combined: re-point FXAA's descriptor to the FSR3 temporal output
// so renderFXAAPass() applies spatial AA on the temporally-stabilized frame.
// This must happen outside the render pass (descriptor updates are CPU-side).
if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler) {
VkImageView fsr3OutputView = VK_NULL_HANDLE;
if (fsr2_.useAmdBackend) {
if (fsr2_.amdFsr3FramegenRuntimeActive && fsr2_.framegenOutput.image)
fsr3OutputView = fsr2_.framegenOutput.imageView;
else if (fsr2_.history[fsr2_.currentHistory].image)
fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView;
} else if (fsr2_.history[fsr2_.currentHistory].image) {
fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView;
}
if (fsr3OutputView) {
VkDescriptorImageInfo imgInfo{};
imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imgInfo.imageView = fsr3OutputView;
imgInfo.sampler = fxaa_.sceneSampler;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = fxaa_.descSet;
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr);
}
}
// Begin swapchain render pass at full resolution for sharpening + ImGui
VkRenderPassBeginInfo rpInfo{};
rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
@ -1237,8 +1294,33 @@ void Renderer::endFrame() {
sc.extent = ext;
vkCmdSetScissor(currentCmd, 0, 1, &sc);
// Draw RCAS sharpening from accumulated history buffer
renderFSR2Sharpen();
// When FXAA is also enabled: apply FXAA on the FSR3 temporal output instead
// of RCAS sharpening. FXAA descriptor is temporarily pointed to the FSR3
// history buffer (which is already in SHADER_READ_ONLY_OPTIMAL). This gives
// FSR3 temporal stability + FXAA spatial edge smoothing ("ultra quality native").
if (fxaa_.enabled && fxaa_.pipeline && fxaa_.descSet) {
renderFXAAPass();
} else {
// Draw RCAS sharpening from accumulated history buffer
renderFSR2Sharpen();
}
// Restore FXAA descriptor to its normal scene color source so standalone
// FXAA frames are not affected by the FSR3 history pointer set above.
if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler && fxaa_.sceneColor.imageView) {
VkDescriptorImageInfo restoreInfo{};
restoreInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
restoreInfo.imageView = fxaa_.sceneColor.imageView;
restoreInfo.sampler = fxaa_.sceneSampler;
VkWriteDescriptorSet restoreWrite{};
restoreWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
restoreWrite.dstSet = fxaa_.descSet;
restoreWrite.dstBinding = 0;
restoreWrite.descriptorCount = 1;
restoreWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
restoreWrite.pImageInfo = &restoreInfo;
vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &restoreWrite, 0, nullptr);
}
// Maintain frame bookkeeping
fsr2_.prevViewProjection = camera->getViewProjectionMatrix();
@ -1249,43 +1331,33 @@ void Renderer::endFrame() {
}
fsr2_.frameIndex = (fsr2_.frameIndex + 1) % 256; // Wrap to keep Halton values well-distributed
} else if (fsr_.enabled && fsr_.sceneFramebuffer) {
} else if (fxaa_.enabled && fxaa_.sceneFramebuffer) {
// End the off-screen scene render pass
vkCmdEndRenderPass(currentCmd);
// Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY
// The render pass finalLayout puts the resolve/color attachment in PRESENT_SRC_KHR
transitionImageLayout(currentCmd, fsr_.sceneColor.image,
// Transition resolved scene color: PRESENT_SRC_KHR → SHADER_READ_ONLY
transitionImageLayout(currentCmd, fxaa_.sceneColor.image,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
// Begin swapchain render pass at full resolution
// Begin swapchain render pass (1x — no MSAA on the output pass)
VkRenderPassBeginInfo rpInfo{};
rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
rpInfo.renderPass = vkCtx->getImGuiRenderPass();
rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex];
rpInfo.renderArea.offset = {0, 0};
rpInfo.renderArea.extent = vkCtx->getSwapchainExtent();
// Clear values must match the render pass attachment count
bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT);
VkClearValue clearValues[4]{};
clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
clearValues[1].depthStencil = {1.0f, 0};
clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
clearValues[3].depthStencil = {1.0f, 0};
if (msaaOn) {
bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE);
rpInfo.clearValueCount = depthRes ? 4 : 3;
} else {
rpInfo.clearValueCount = 2;
}
rpInfo.pClearValues = clearValues;
// The swapchain render pass always has 2 attachments when MSAA is off;
// FXAA output goes to the non-MSAA swapchain directly.
VkClearValue fxaaClear[2]{};
fxaaClear[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
fxaaClear[1].depthStencil = {1.0f, 0};
rpInfo.clearValueCount = 2;
rpInfo.pClearValues = fxaaClear;
vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE);
// Set full-resolution viewport and scissor
VkExtent2D ext = vkCtx->getSwapchainExtent();
VkViewport vp{};
vp.width = static_cast<float>(ext.width);
@ -1296,12 +1368,60 @@ void Renderer::endFrame() {
sc.extent = ext;
vkCmdSetScissor(currentCmd, 0, 1, &sc);
// Draw FSR upscale fullscreen quad
// Draw FXAA pass
renderFXAAPass();
} else if (fsr_.enabled && fsr_.sceneFramebuffer) {
// FSR1 upscale path — only runs when FXAA is not active.
// When both FSR1 and FXAA are enabled, FXAA took priority above.
vkCmdEndRenderPass(currentCmd);
// Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY
transitionImageLayout(currentCmd, fsr_.sceneColor.image,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
// Begin swapchain render pass at full resolution
VkRenderPassBeginInfo fsrRpInfo{};
fsrRpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
fsrRpInfo.renderPass = vkCtx->getImGuiRenderPass();
fsrRpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex];
fsrRpInfo.renderArea.offset = {0, 0};
fsrRpInfo.renderArea.extent = vkCtx->getSwapchainExtent();
bool fsrMsaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT);
VkClearValue fsrClearValues[4]{};
fsrClearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
fsrClearValues[1].depthStencil = {1.0f, 0};
fsrClearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
fsrClearValues[3].depthStencil = {1.0f, 0};
if (fsrMsaaOn) {
bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE);
fsrRpInfo.clearValueCount = depthRes ? 4 : 3;
} else {
fsrRpInfo.clearValueCount = 2;
}
fsrRpInfo.pClearValues = fsrClearValues;
vkCmdBeginRenderPass(currentCmd, &fsrRpInfo, VK_SUBPASS_CONTENTS_INLINE);
VkExtent2D fsrExt = vkCtx->getSwapchainExtent();
VkViewport fsrVp{};
fsrVp.width = static_cast<float>(fsrExt.width);
fsrVp.height = static_cast<float>(fsrExt.height);
fsrVp.maxDepth = 1.0f;
vkCmdSetViewport(currentCmd, 0, 1, &fsrVp);
VkRect2D fsrSc{};
fsrSc.extent = fsrExt;
vkCmdSetScissor(currentCmd, 0, 1, &fsrSc);
renderFSRUpscale();
}
// ImGui rendering — must respect subpass contents mode
if (!fsr_.enabled && !fsr2_.enabled && parallelRecordingEnabled_) {
// Parallel recording only applies when no post-process pass is active.
if (!fsr_.enabled && !fsr2_.enabled && !fxaa_.enabled && parallelRecordingEnabled_) {
// Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS,
// so ImGui must be recorded into a secondary command buffer.
VkCommandBuffer imguiCmd = beginSecondary(SEC_IMGUI);
@ -4698,6 +4818,247 @@ void Renderer::setAmdFsr3FramegenEnabled(bool enabled) {
// ========================= End FSR 2.2 =========================
// ========================= FXAA Post-Process =========================
bool Renderer::initFXAAResources() {
if (!vkCtx) return false;
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
VkExtent2D ext = vkCtx->getSwapchainExtent();
VkSampleCountFlagBits msaa = vkCtx->getMsaaSamples();
bool useMsaa = (msaa > VK_SAMPLE_COUNT_1_BIT);
bool useDepthResolve = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE);
LOG_INFO("FXAA: initializing at ", ext.width, "x", ext.height,
" (MSAA=", static_cast<int>(msaa), "x)");
VkFormat colorFmt = vkCtx->getSwapchainFormat();
VkFormat depthFmt = vkCtx->getDepthFormat();
// sceneColor: 1x resolved color target — FXAA reads from here
fxaa_.sceneColor = createImage(device, alloc, ext.width, ext.height,
colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT);
if (!fxaa_.sceneColor.image) {
LOG_ERROR("FXAA: failed to create scene color image");
return false;
}
// sceneDepth: depth buffer at current MSAA sample count
fxaa_.sceneDepth = createImage(device, alloc, ext.width, ext.height,
depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaa);
if (!fxaa_.sceneDepth.image) {
LOG_ERROR("FXAA: failed to create scene depth image");
destroyFXAAResources();
return false;
}
if (useMsaa) {
fxaa_.sceneMsaaColor = createImage(device, alloc, ext.width, ext.height,
colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, msaa);
if (!fxaa_.sceneMsaaColor.image) {
LOG_ERROR("FXAA: failed to create MSAA color image");
destroyFXAAResources();
return false;
}
if (useDepthResolve) {
fxaa_.sceneDepthResolve = createImage(device, alloc, ext.width, ext.height,
depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT);
if (!fxaa_.sceneDepthResolve.image) {
LOG_ERROR("FXAA: failed to create depth resolve image");
destroyFXAAResources();
return false;
}
}
}
// Framebuffer — same attachment layout as main render pass
VkImageView fbAttachments[4]{};
uint32_t fbCount;
if (useMsaa) {
fbAttachments[0] = fxaa_.sceneMsaaColor.imageView;
fbAttachments[1] = fxaa_.sceneDepth.imageView;
fbAttachments[2] = fxaa_.sceneColor.imageView; // resolve target
fbCount = 3;
if (useDepthResolve) {
fbAttachments[3] = fxaa_.sceneDepthResolve.imageView;
fbCount = 4;
}
} else {
fbAttachments[0] = fxaa_.sceneColor.imageView;
fbAttachments[1] = fxaa_.sceneDepth.imageView;
fbCount = 2;
}
VkFramebufferCreateInfo fbInfo{};
fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
fbInfo.renderPass = vkCtx->getImGuiRenderPass();
fbInfo.attachmentCount = fbCount;
fbInfo.pAttachments = fbAttachments;
fbInfo.width = ext.width;
fbInfo.height = ext.height;
fbInfo.layers = 1;
if (vkCreateFramebuffer(device, &fbInfo, nullptr, &fxaa_.sceneFramebuffer) != VK_SUCCESS) {
LOG_ERROR("FXAA: failed to create scene framebuffer");
destroyFXAAResources();
return false;
}
// Sampler
VkSamplerCreateInfo samplerInfo{};
samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
samplerInfo.minFilter = VK_FILTER_LINEAR;
samplerInfo.magFilter = VK_FILTER_LINEAR;
samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
if (vkCreateSampler(device, &samplerInfo, nullptr, &fxaa_.sceneSampler) != VK_SUCCESS) {
LOG_ERROR("FXAA: failed to create sampler");
destroyFXAAResources();
return false;
}
// Descriptor set layout: binding 0 = combined image sampler
VkDescriptorSetLayoutBinding binding{};
binding.binding = 0;
binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
binding.descriptorCount = 1;
binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &binding;
vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &fxaa_.descSetLayout);
VkDescriptorPoolSize poolSize{};
poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
poolSize.descriptorCount = 1;
VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.maxSets = 1;
poolInfo.poolSizeCount = 1;
poolInfo.pPoolSizes = &poolSize;
vkCreateDescriptorPool(device, &poolInfo, nullptr, &fxaa_.descPool);
VkDescriptorSetAllocateInfo dsAllocInfo{};
dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
dsAllocInfo.descriptorPool = fxaa_.descPool;
dsAllocInfo.descriptorSetCount = 1;
dsAllocInfo.pSetLayouts = &fxaa_.descSetLayout;
vkAllocateDescriptorSets(device, &dsAllocInfo, &fxaa_.descSet);
// Bind the resolved 1x sceneColor
VkDescriptorImageInfo imgInfo{};
imgInfo.sampler = fxaa_.sceneSampler;
imgInfo.imageView = fxaa_.sceneColor.imageView;
imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = fxaa_.descSet;
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &imgInfo;
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
// Pipeline layout — push constant holds vec2 rcpFrame
VkPushConstantRange pc{};
pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
pc.offset = 0;
pc.size = 8; // vec2
VkPipelineLayoutCreateInfo plCI{};
plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
plCI.setLayoutCount = 1;
plCI.pSetLayouts = &fxaa_.descSetLayout;
plCI.pushConstantRangeCount = 1;
plCI.pPushConstantRanges = &pc;
vkCreatePipelineLayout(device, &plCI, nullptr, &fxaa_.pipelineLayout);
// FXAA pipeline — fullscreen triangle into the swapchain render pass
// Uses VK_SAMPLE_COUNT_1_BIT: it always runs after MSAA resolve.
VkShaderModule vertMod, fragMod;
if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") ||
!fragMod.loadFromFile(device, "assets/shaders/fxaa.frag.spv")) {
LOG_ERROR("FXAA: failed to load shaders");
destroyFXAAResources();
return false;
}
fxaa_.pipeline = PipelineBuilder()
.setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({}, {})
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setNoDepthTest()
.setColorBlendAttachment(PipelineBuilder::blendDisabled())
.setMultisample(VK_SAMPLE_COUNT_1_BIT) // swapchain pass is always 1x
.setLayout(fxaa_.pipelineLayout)
.setRenderPass(vkCtx->getImGuiRenderPass())
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
vertMod.destroy();
fragMod.destroy();
if (!fxaa_.pipeline) {
LOG_ERROR("FXAA: failed to create pipeline");
destroyFXAAResources();
return false;
}
LOG_INFO("FXAA: initialized successfully");
return true;
}
void Renderer::destroyFXAAResources() {
if (!vkCtx) return;
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
vkDeviceWaitIdle(device);
if (fxaa_.pipeline) { vkDestroyPipeline(device, fxaa_.pipeline, nullptr); fxaa_.pipeline = VK_NULL_HANDLE; }
if (fxaa_.pipelineLayout) { vkDestroyPipelineLayout(device, fxaa_.pipelineLayout, nullptr); fxaa_.pipelineLayout = VK_NULL_HANDLE; }
if (fxaa_.descPool) { vkDestroyDescriptorPool(device, fxaa_.descPool, nullptr); fxaa_.descPool = VK_NULL_HANDLE; fxaa_.descSet = VK_NULL_HANDLE; }
if (fxaa_.descSetLayout) { vkDestroyDescriptorSetLayout(device, fxaa_.descSetLayout, nullptr); fxaa_.descSetLayout = VK_NULL_HANDLE; }
if (fxaa_.sceneFramebuffer) { vkDestroyFramebuffer(device, fxaa_.sceneFramebuffer, nullptr); fxaa_.sceneFramebuffer = VK_NULL_HANDLE; }
if (fxaa_.sceneSampler) { vkDestroySampler(device, fxaa_.sceneSampler, nullptr); fxaa_.sceneSampler = VK_NULL_HANDLE; }
destroyImage(device, alloc, fxaa_.sceneDepthResolve);
destroyImage(device, alloc, fxaa_.sceneMsaaColor);
destroyImage(device, alloc, fxaa_.sceneDepth);
destroyImage(device, alloc, fxaa_.sceneColor);
}
void Renderer::renderFXAAPass() {
if (!fxaa_.pipeline || currentCmd == VK_NULL_HANDLE) return;
VkExtent2D ext = vkCtx->getSwapchainExtent();
vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipeline);
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] = {
1.0f / static_cast<float>(ext.width),
1.0f / static_cast<float>(ext.height)
};
vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout,
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame);
vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle
}
void Renderer::setFXAAEnabled(bool enabled) {
if (fxaa_.enabled == enabled) return;
fxaa_.enabled = enabled;
if (!enabled) {
fxaa_.needsRecreate = true; // defer destruction to next beginFrame()
}
}
// ========================= End FXAA =========================
void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
(void)world;

View file

@ -835,7 +835,12 @@ void WMORenderer::cleanupUnusedModels() {
}
}
// Delete GPU resources and remove from map
// Delete GPU resources and remove from map.
// Ensure all in-flight frames are complete before freeing vertex/index buffers —
// the GPU may still be reading them from the previous frame's command buffer.
if (!toRemove.empty() && vkCtx_) {
vkDeviceWaitIdle(vkCtx_->getDevice());
}
for (uint32_t id : toRemove) {
unloadModel(id);
}

View file

@ -1017,6 +1017,33 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi
}
}
// Party member dots
if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) {
ImFont* font = ImGui::GetFont();
for (const auto& dot : partyDots_) {
glm::vec2 uv = renderPosToMapUV(dot.renderPos, currentIdx);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
float px = imgMin.x + uv.x * displayW;
float py = imgMin.y + uv.y * displayH;
drawList->AddCircleFilled(ImVec2(px, py), 5.0f, dot.color);
drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(0, 0, 0, 200), 0, 1.5f);
// Name tooltip on hover
if (!dot.name.empty()) {
ImVec2 mp = ImGui::GetMousePos();
float dx = mp.x - px, dy = mp.y - py;
if (dx * dx + dy * dy <= 49.0f) { // radius 7 px hit area
ImGui::SetTooltip("%s", dot.name.c_str());
}
// Draw name label above the dot
ImVec2 nameSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, dot.name.c_str());
float tx = px - nameSz.x * 0.5f;
float ty = py - nameSz.y - 7.0f;
drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), dot.name.c_str());
drawList->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 220), dot.name.c_str());
}
}
}
// Hover coordinate display — show WoW coordinates under cursor
if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) {
auto& io = ImGui::GetIO();

File diff suppressed because it is too large Load diff

View file

@ -1159,7 +1159,9 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
int32_t stats[5];
for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i);
const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr;
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats);
int32_t resists[6];
for (int i = 0; i < 6; ++i) resists[i] = gameHandler.getResistance(i + 1);
renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists);
// Played time (shown if available, fetched on character screen open)
uint32_t totalSec = gameHandler.getTotalTimePlayed();
@ -1340,6 +1342,34 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) {
ImGui::EndTabItem();
}
// Equipment Sets tab (WotLK only)
const auto& eqSets = gameHandler.getEquipmentSets();
if (!eqSets.empty()) {
if (ImGui::BeginTabItem("Outfits")) {
ImGui::Spacing();
ImGui::TextDisabled("Saved Equipment Sets");
ImGui::Separator();
ImGui::BeginChild("##EqSetsList", ImVec2(0, 0), false);
for (const auto& es : eqSets) {
ImGui::PushID(static_cast<int>(es.setId));
// Icon placeholder or name
const char* displayName = es.name.empty() ? "(Unnamed)" : es.name.c_str();
ImGui::Text("%s", displayName);
if (!es.iconName.empty()) {
ImGui::SameLine();
ImGui::TextDisabled("(%s)", es.iconName.c_str());
}
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f);
if (ImGui::SmallButton("Equip")) {
gameHandler.useEquipmentSet(es.setId);
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
@ -1557,7 +1587,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) {
// ============================================================
void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel,
int32_t serverArmor, const int32_t* serverStats) {
int32_t serverArmor, const int32_t* serverStats,
const int32_t* serverResists) {
// Sum equipment stats for item-query bonus display
int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0;
// Secondary stat sums from extraStats
@ -1596,6 +1627,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
}
int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor;
// Average item level (exclude shirt/tabard as WoW convention)
{
uint32_t iLvlSum = 0;
int iLvlCount = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
auto eslot = static_cast<game::EquipSlot>(s);
if (eslot == game::EquipSlot::SHIRT || eslot == game::EquipSlot::TABARD) continue;
const auto& slot = inventory.getEquipSlot(eslot);
if (!slot.empty() && slot.item.itemLevel > 0) {
iLvlSum += slot.item.itemLevel;
++iLvlCount;
}
}
if (iLvlCount > 0) {
float avg = static_cast<float>(iLvlSum) / static_cast<float>(iLvlCount);
ImGui::TextColored(ImVec4(0.7f, 0.9f, 1.0f, 1.0f),
"Average Item Level: %.1f (%d/%d slots)", avg, iLvlCount,
game::Inventory::NUM_EQUIP_SLOTS - 2); // -2 for shirt/tabard
}
ImGui::Separator();
}
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f);
ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f);
@ -1665,6 +1718,28 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
renderSecondary("Mana per 5 sec", itemMp5);
renderSecondary("Health per 5 sec",itemHp5);
}
// Elemental resistances from server update fields
if (serverResists) {
static const char* kResistNames[6] = {
"Holy Resistance", "Fire Resistance", "Nature Resistance",
"Frost Resistance", "Shadow Resistance", "Arcane Resistance"
};
bool hasResist = false;
for (int i = 0; i < 6; ++i) {
if (serverResists[i] > 0) { hasResist = true; break; }
}
if (hasResist) {
ImGui::Spacing();
ImGui::Separator();
for (int i = 0; i < 6; ++i) {
if (serverResists[i] > 0) {
ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 1.0f),
"%s: %d", kResistNames[i], serverResists[i]);
}
}
}
}
}
void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) {
@ -2186,10 +2261,13 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
default: break;
}
if (!trigger) continue;
const std::string& spName = gameHandler_->getSpellName(sp.spellId);
if (!spName.empty()) {
const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId);
const std::string& spText = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc;
if (!spText.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f);
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f),
"%s: %s", trigger, spName.c_str());
"%s: %s", trigger, spText.c_str());
ImGui::PopTextWrapPos();
} else {
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f),
"%s: Spell #%u", trigger, sp.spellId);
@ -2474,11 +2552,161 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
}
if (!trigger) continue;
if (gameHandler_) {
const std::string& spName = gameHandler_->getSpellName(sp.spellId);
if (!spName.empty())
// Prefer the spell's tooltip text (the actual effect description).
// Fall back to the spell name if the description is empty.
const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId);
const std::string& spName = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc;
if (!spName.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f);
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str());
else
ImGui::PopTextWrapPos();
} else {
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId);
}
}
}
// Gem socket slots
{
static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = {
{ 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } },
{ 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } },
{ 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } },
{ 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } },
};
bool hasSocket = false;
for (int i = 0; i < 3; ++i) {
if (info.socketColor[i] == 0) continue;
if (!hasSocket) { ImGui::Spacing(); hasSocket = true; }
for (const auto& st : kSocketTypes) {
if (info.socketColor[i] & st.mask) {
ImGui::TextColored(st.col, "%s", st.label);
break;
}
}
}
if (hasSocket && info.socketBonus != 0) {
// Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc
static std::unordered_map<uint32_t, std::string> s_enchantNames;
static bool s_enchantNamesLoaded = false;
if (!s_enchantNamesLoaded && assetManager_) {
s_enchantNamesLoaded = true;
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
if (dbc && dbc->isLoaded()) {
const auto* lay = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
uint32_t nameField = lay ? lay->field("Name") : 8u;
if (nameField == 0xFFFFFFFF) nameField = 8;
uint32_t fc = dbc->getFieldCount();
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t eid = dbc->getUInt32(r, 0);
if (eid == 0 || nameField >= fc) continue;
std::string ename = dbc->getString(r, nameField);
if (!ename.empty()) s_enchantNames[eid] = std::move(ename);
}
}
}
auto enchIt = s_enchantNames.find(info.socketBonus);
if (enchIt != s_enchantNames.end())
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str());
else
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus);
}
}
// Item set membership
if (info.itemSetId != 0) {
// Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds)
struct SetEntry {
std::string name;
std::array<uint32_t, 10> itemIds{};
std::array<uint32_t, 10> spellIds{};
std::array<uint32_t, 10> thresholds{};
};
static std::unordered_map<uint32_t, SetEntry> s_setData;
static bool s_setDataLoaded = false;
if (!s_setDataLoaded && assetManager_) {
s_setDataLoaded = true;
auto dbc = assetManager_->loadDBC("ItemSet.dbc");
if (dbc && dbc->isLoaded()) {
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr;
auto lf = [&](const char* k, uint32_t def) -> uint32_t {
return layout ? (*layout)[k] : def;
};
uint32_t idF = lf("ID", 0), nameF = lf("Name", 1);
static const char* itemKeys[10] = {
"Item0","Item1","Item2","Item3","Item4",
"Item5","Item6","Item7","Item8","Item9"
};
static const char* spellKeys[10] = {
"Spell0","Spell1","Spell2","Spell3","Spell4",
"Spell5","Spell6","Spell7","Spell8","Spell9"
};
static const char* thrKeys[10] = {
"Threshold0","Threshold1","Threshold2","Threshold3","Threshold4",
"Threshold5","Threshold6","Threshold7","Threshold8","Threshold9"
};
uint32_t itemFallback[10], spellFallback[10], thrFallback[10];
for (int i = 0; i < 10; ++i) {
itemFallback[i] = 18 + i;
spellFallback[i] = 28 + i;
thrFallback[i] = 38 + i;
}
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t id = dbc->getUInt32(r, idF);
if (!id) continue;
SetEntry e;
e.name = dbc->getString(r, nameF);
for (int i = 0; i < 10; ++i) {
e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFallback[i]);
e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFallback[i]);
e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFallback[i]);
}
s_setData[id] = std::move(e);
}
}
}
auto setIt = s_setData.find(info.itemSetId);
ImGui::Spacing();
if (setIt != s_setData.end()) {
const SetEntry& se = setIt->second;
// Count equipped pieces
int equipped = 0, total = 0;
for (int i = 0; i < 10; ++i) {
if (se.itemIds[i] == 0) continue;
++total;
if (inventory) {
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
const auto& eSlot = inventory->getEquipSlot(static_cast<game::EquipSlot>(s));
if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; }
}
}
}
if (total > 0) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f),
"%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total);
} else {
if (!se.name.empty())
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str());
}
// Show set bonuses: gray if not reached, green if active
if (gameHandler_) {
for (int i = 0; i < 10; ++i) {
if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue;
const std::string& bname = gameHandler_->getSpellName(se.spellIds[i]);
bool active = (equipped >= static_cast<int>(se.thresholds[i]));
ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f)
: ImVec4(0.55f, 0.55f, 0.55f, 1.0f);
if (!bname.empty())
ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str());
else
ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]);
}
}
} else {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info.itemSetId);
}
}

View file

@ -485,12 +485,28 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv
auto reqIt = sel.requiredItemCounts.find(itemId);
if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second;
VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE;
const auto* objInfo = gameHandler.getItemInfo(itemId);
if (iconTex) {
ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14));
if (objInfo && objInfo->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
invScreen.renderItemTooltip(*objInfo);
ImGui::EndTooltip();
}
ImGui::SameLine();
ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required);
if (objInfo && objInfo->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
invScreen.renderItemTooltip(*objInfo);
ImGui::EndTooltip();
}
} else {
ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required);
if (objInfo && objInfo->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
invScreen.renderItemTooltip(*objInfo);
ImGui::EndTooltip();
}
}
}
}
@ -534,6 +550,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv
ImGui::Text("%s x%u", name.c_str(), ri.count);
else
ImGui::Text("%s", name.c_str());
if (info && info->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
invScreen.renderItemTooltip(*info);
ImGui::EndTooltip();
}
}
}
@ -560,6 +581,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv
ImGui::Text("%s x%u", name.c_str(), ri.count);
else
ImGui::Text("%s", name.c_str());
if (info && info->valid && ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
invScreen.renderItemTooltip(*info);
ImGui::EndTooltip();
}
}
}
}

View file

@ -76,6 +76,7 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
gameHandler.loadTalentDbc();
loadSpellDBC(assetManager);
loadSpellIconDBC(assetManager);
loadGlyphPropertiesDBC(assetManager);
}
uint8_t playerClass = gameHandler.getPlayerClass();
@ -161,6 +162,18 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
ImGui::EndTabItem();
}
}
// Glyphs tab (WotLK only — visible when any glyph slot is populated or DBC data loaded)
if (!glyphProperties_.empty() || [&]() {
const auto& g = gameHandler.getGlyphs();
for (auto id : g) if (id != 0) return true;
return false; }()) {
if (ImGui::BeginTabItem("Glyphs")) {
renderGlyphs(gameHandler);
ImGui::EndTabItem();
}
}
ImGui::EndTabBar();
}
}
@ -616,6 +629,99 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
}
}
void TalentScreen::loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager) {
if (glyphDbcLoaded) return;
glyphDbcLoaded = true;
if (!assetManager || !assetManager->isInitialized()) return;
auto dbc = assetManager->loadDBC("GlyphProperties.dbc");
if (!dbc || !dbc->isLoaded()) return;
// GlyphProperties.dbc: field 0=ID, field 1=SpellID, field 2=GlyphSlotFlags (1=minor), field 3=SpellIconID
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
uint32_t id = dbc->getUInt32(i, 0);
uint32_t spellId = dbc->getUInt32(i, 1);
uint32_t flags = dbc->getUInt32(i, 2);
if (id == 0) continue;
GlyphInfo info;
info.spellId = spellId;
info.isMajor = (flags == 0); // flag 0 = major, flag 1 = minor
glyphProperties_[id] = info;
}
}
void TalentScreen::renderGlyphs(game::GameHandler& gameHandler) {
auto* assetManager = core::Application::getInstance().getAssetManager();
const auto& glyphs = gameHandler.getGlyphs();
ImGui::Spacing();
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Major Glyphs");
ImGui::Separator();
// WotLK: 6 glyph slots total. Slots 0,2,4 are major by convention from the server,
// but we check GlyphProperties.dbc flags when available.
// Display all 6 slots grouped: show major (non-minor) first, then minor.
std::vector<std::pair<int, bool>> majorSlots, minorSlots;
for (int i = 0; i < game::GameHandler::MAX_GLYPH_SLOTS; i++) {
uint16_t glyphId = glyphs[i];
bool isMajor = true;
if (glyphId != 0) {
auto git = glyphProperties_.find(glyphId);
if (git != glyphProperties_.end()) isMajor = git->second.isMajor;
else isMajor = (i % 2 == 0); // fallback: even slots = major
} else {
isMajor = (i % 2 == 0); // empty slots follow same pattern
}
if (isMajor) majorSlots.push_back({i, true});
else minorSlots.push_back({i, false});
}
auto renderGlyphSlot = [&](int slotIdx) {
uint16_t glyphId = glyphs[slotIdx];
char label[64];
if (glyphId == 0) {
snprintf(label, sizeof(label), "Slot %d [Empty]", slotIdx + 1);
ImGui::TextDisabled("%s", label);
return;
}
uint32_t spellId = 0;
uint32_t iconId = 0;
auto git = glyphProperties_.find(glyphId);
if (git != glyphProperties_.end()) {
spellId = git->second.spellId;
auto iit = spellIconIds.find(spellId);
if (iit != spellIconIds.end()) iconId = iit->second;
}
// Icon (24x24)
VkDescriptorSet icon = getSpellIcon(iconId, assetManager);
if (icon != VK_NULL_HANDLE) {
ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(24, 24));
ImGui::SameLine(0, 6);
} else {
ImGui::Dummy(ImVec2(24, 24));
ImGui::SameLine(0, 6);
}
// Spell name
const std::string& name = spellId ? gameHandler.getSpellName(spellId) : "";
if (!name.empty()) {
ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", name.c_str());
} else {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Glyph #%u", (uint32_t)glyphId);
}
};
for (auto& [idx, major] : majorSlots) renderGlyphSlot(idx);
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "Minor Glyphs");
ImGui::Separator();
for (auto& [idx, major] : minorSlots) renderGlyphSlot(idx);
}
VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) {
if (iconId == 0 || !assetManager) return VK_NULL_HANDLE;