Compare commits

...

27 commits

Author SHA1 Message Date
Kelsi
075b4c1772 fix(gameplay): tighten TB elevator bounds and reset stale combat visuals
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
2026-03-14 09:19:16 -07:00
Kelsi
448560a0d2 fix(ui): correct minimap center compass arrow orientation 2026-03-14 09:06:55 -07:00
Kelsi
38210ec186 fix(gameplay): keep timeout animation stable on repeated presses and harden M2 elevator sync 2026-03-14 09:02:20 -07:00
Kelsi
f7a996ab26 fix(ui): avoid double-processing bag toggle hotkey 2026-03-14 08:44:45 -07:00
Kelsi
2c32b72f95 feat(ui): show keyring in inventory 2026-03-14 08:42:25 -07:00
Kelsi
800862c50a fix(ui): cache ghost opacity updates to state changes 2026-03-14 08:31:08 -07:00
Kelsi
cebca9a882 fix(gameplay): stabilize run animation and clean ghost/death visuals 2026-03-14 08:27:32 -07:00
Kelsi
1a4b21955c fix(transport): add Thunder Bluff lift-specific M2 attach bounds 2026-03-14 08:09:23 -07:00
Kelsi
422ff99b2a fix(ui): close trainer window immediately on close request 2026-03-14 07:43:52 -07:00
Kelsi
f235b8641f fix(animation): avoid forced stand reset after spline move 2026-03-14 07:41:50 -07:00
Kelsi
9cd52c4dd7 fix(loot): gate auto-loot sends per loot session 2026-03-14 07:31:15 -07:00
Kelsi
c1baffadf0 fix(input): release mouse on stalls and clean quest keybind duplication 2026-03-14 07:29:39 -07:00
Kelsi
013f6be162 fix(mail): correct WotLK mail list attachment parsing 2026-03-14 07:14:15 -07:00
Kelsi
5fa5020af5 fix(mail): use attachment item guid for WotLK take item 2026-03-14 07:11:18 -07:00
Kelsi
5a10ae9df0 fix(trade): allow accept after peer accepts first 2026-03-14 07:02:52 -07:00
Kelsi
aa9dc128d8 fix(chat): make /r resolve last whisper sender reliably 2026-03-14 06:56:16 -07:00
Kelsi
76f54bbd2c fix(minimap): keep a single player direction indicator 2026-03-14 06:48:14 -07:00
Kelsi
5b195781ad fix(death): restore corpse reclaim and enforce ghost grayscale 2026-03-14 06:43:49 -07:00
Kelsi
7b5ead8bd9 fix(terrain): drop chunks when material descriptor allocation fails 2026-03-14 06:34:09 -07:00
Kelsi
e7c1000fc2 fix(minimap): remove duplicate shader direction arrow 2026-03-14 06:22:32 -07:00
Kelsi
0b1ea464bb fix(render): premultiply glow sprite color by alpha 2026-03-14 06:10:20 -07:00
Kelsi
93cc092ee1 fix(render): narrow glow-card mesh suppression 2026-03-14 06:07:05 -07:00
Kelsi
e0d7cba330 fix(render): replace torch and lantern glow cards with sprites 2026-03-14 06:02:15 -07:00
Kelsi
d7a0a9f675 fix(wmo): disable portal traversal for outdoor camera groups 2026-03-14 05:57:27 -07:00
Kelsi
a2f9ccc9b9 fix(vendor): detect repair NPC flag correctly 2026-03-14 05:39:00 -07:00
Kelsi
7e7ad325dc fix(vendor): request list inventory for repair gossip 2026-03-14 05:31:17 -07:00
Kelsi
e0828cc35c fix(input): don't block hotkeys on WantCaptureKeyboard 2026-03-14 05:20:23 -07:00
24 changed files with 747 additions and 301 deletions

View file

@ -25,6 +25,9 @@ void main() {
if (lum < 0.05) discard;
}
float edge = smoothstep(0.5, 0.4, length(p - 0.5));
outColor = texColor * vColor * vec4(vec3(1.0), edge);
// Soft circular falloff for point-sprite edges.
float edge = 1.0 - smoothstep(0.4, 0.5, length(p - 0.5));
float alpha = texColor.a * vColor.a * edge;
vec3 rgb = texColor.rgb * vColor.rgb * alpha;
outColor = vec4(rgb, alpha);
}

View file

@ -44,19 +44,23 @@ void main() {
vec4 mapColor = texture(uComposite, mapUV);
// Player arrow
float acs = cos(push.arrowRotation);
float asn = sin(push.arrowRotation);
vec2 ac = center;
vec2 arrowPos = vec2(-(ac.x * acs - ac.y * asn), ac.x * asn + ac.y * acs);
vec2 tip = vec2(0.0, -0.04);
vec2 left = vec2(-0.02, 0.02);
vec2 right = vec2(0.02, 0.02);
if (pointInTriangle(arrowPos, tip, left, right)) {
mapColor = vec4(1.0, 0.8, 0.0, 1.0);
// Single player direction indicator (center arrow) rendered in-shader.
vec2 local = center; // [-0.5, 0.5] around minimap center
float ac = cos(push.arrowRotation);
float as = sin(push.arrowRotation);
// TexCoord Y grows downward on screen; use negative Y so 0-angle points North (up).
vec2 tip = vec2(0.0, -0.09);
vec2 left = vec2(-0.045, 0.02);
vec2 right = vec2( 0.045, 0.02);
mat2 rot = mat2(ac, -as, as, ac);
tip = rot * tip;
left = rot * left;
right = rot * right;
if (pointInTriangle(local, tip, left, right)) {
mapColor.rgb = vec3(1.0, 0.86, 0.05);
}
float centerDot = smoothstep(0.016, 0.0, length(local));
mapColor.rgb = mix(mapColor.rgb, vec3(1.0), centerDot * 0.95);
// Dark border ring
float border = smoothstep(0.48, 0.5, dist);

View file

@ -1978,7 +1978,7 @@ public:
const std::array<MailAttachSlot, 12>& getMailAttachments() const { return mailAttachments_; }
int getMailAttachmentCount() const;
void mailTakeMoney(uint32_t mailId);
void mailTakeItem(uint32_t mailId, uint32_t itemIndex);
void mailTakeItem(uint32_t mailId, uint32_t itemGuidLow);
void mailDelete(uint32_t mailId);
void mailMarkAsRead(uint32_t mailId);
void refreshMailList();
@ -2534,6 +2534,7 @@ private:
std::unordered_set<uint32_t> pendingItemQueries_;
std::array<uint64_t, 23> equipSlotGuids_{};
std::array<uint64_t, 16> backpackSlotGuids_{};
std::array<uint64_t, 32> keyringSlotGuids_{};
// Container (bag) contents: containerGuid -> array of item GUIDs per slot
struct ContainerInfo {
uint32_t numSlots = 0;
@ -2829,6 +2830,7 @@ private:
struct LocalLootState {
LootResponseData data;
bool moneyTaken = false;
bool itemAutoLootSent = false;
};
std::unordered_map<uint64_t, LocalLootState> localLootState_;
struct PendingLootRetry {
@ -3200,6 +3202,7 @@ private:
bool releasedSpirit_ = false;
uint32_t corpseMapId_ = 0;
float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f;
uint64_t corpseGuid_ = 0;
// Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially
std::array<RuneSlot, 6> playerRunes_ = [] {
std::array<RuneSlot, 6> r{};

View file

@ -69,6 +69,7 @@ struct ItemSlot {
class Inventory {
public:
static constexpr int BACKPACK_SLOTS = 16;
static constexpr int KEYRING_SLOTS = 32;
static constexpr int NUM_EQUIP_SLOTS = 23;
static constexpr int NUM_BAG_SLOTS = 4;
static constexpr int MAX_BAG_SIZE = 36;
@ -88,6 +89,12 @@ public:
bool setEquipSlot(EquipSlot slot, const ItemDef& item);
bool clearEquipSlot(EquipSlot slot);
// Keyring
const ItemSlot& getKeyringSlot(int index) const;
bool setKeyringSlot(int index, const ItemDef& item);
bool clearKeyringSlot(int index);
int getKeyringSize() const { return KEYRING_SLOTS; }
// Extra bags
int getBagSize(int bagIndex) const;
void setBagSize(int bagIndex, int size);
@ -123,6 +130,7 @@ public:
private:
std::array<ItemSlot, BACKPACK_SLOTS> backpack{};
std::array<ItemSlot, KEYRING_SLOTS> keyring_{};
std::array<ItemSlot, NUM_EQUIP_SLOTS> equipment{};
struct BagData {

View file

@ -266,8 +266,8 @@ public:
virtual bool parseMailList(network::Packet& packet, std::vector<MailMessage>& inbox);
/** Build CMSG_MAIL_TAKE_ITEM */
virtual network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) {
return MailTakeItemPacket::build(mailboxGuid, mailId, itemSlot);
virtual network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) {
return MailTakeItemPacket::build(mailboxGuid, mailId, itemGuidLow);
}
/** Build CMSG_MAIL_DELETE */
@ -404,7 +404,7 @@ public:
uint32_t money, uint32_t cod,
const std::vector<uint64_t>& itemGuids = {}) override;
bool parseMailList(network::Packet& packet, std::vector<MailMessage>& inbox) override;
network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) override;
network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) override;
network::Packet buildMailDelete(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) override;
network::Packet buildItemQuery(uint32_t entry, uint64_t guid) override;
bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override;

View file

@ -57,6 +57,7 @@ enum class UF : uint16_t {
PLAYER_QUEST_LOG_START,
PLAYER_FIELD_INV_SLOT_HEAD,
PLAYER_FIELD_PACK_SLOT_1,
PLAYER_FIELD_KEYRING_SLOT_1,
PLAYER_FIELD_BANK_SLOT_1,
PLAYER_FIELD_BANKBAG_SLOT_1,
PLAYER_SKILL_INFO_START,

View file

@ -2441,6 +2441,12 @@ public:
static network::Packet build();
};
/** CMSG_RECLAIM_CORPSE packet builder */
class ReclaimCorpsePacket {
public:
static network::Packet build(uint64_t guid);
};
/** CMSG_SPIRIT_HEALER_ACTIVATE packet builder */
class SpiritHealerActivatePacket {
public:
@ -2511,7 +2517,7 @@ public:
/** CMSG_MAIL_TAKE_ITEM packet builder */
class MailTakeItemPacket {
public:
static network::Packet build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemIndex);
static network::Packet build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow);
};
/** CMSG_MAIL_DELETE packet builder */

View file

@ -161,6 +161,7 @@ public:
// Targeting support
void setTargetPosition(const glm::vec3* pos);
void setInCombat(bool combat) { inCombat_ = combat; }
void resetCombatVisualState();
bool isMoving() const;
void triggerMeleeSwing();
void setEquippedWeaponType(uint32_t inventoryType) { equippedWeaponInvType_ = inventoryType; meleeAnimId = 0; }
@ -340,6 +341,10 @@ private:
// Character animation state
enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT, CHARGE, COMBAT_IDLE };
CharAnimState charAnimState = CharAnimState::IDLE;
float locomotionStopGraceTimer_ = 0.0f;
bool locomotionWasSprinting_ = false;
uint32_t lastPlayerAnimRequest_ = UINT32_MAX;
bool lastPlayerAnimLoopRequest_ = true;
void updateCharacterAnimation();
bool isFootstepAnimationState() const;
bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs);

View file

@ -621,6 +621,9 @@ private:
float resurrectFlashTimer_ = 0.0f;
static constexpr float kResurrectFlashDuration = 3.0f;
bool ghostStateCallbackSet_ = false;
bool ghostOpacityStateKnown_ = false;
bool ghostOpacityLastState_ = false;
uint32_t ghostOpacityLastInstanceId_ = 0;
void renderResurrectFlash();
// Zone discovery text ("Entering: <ZoneName>")

View file

@ -29,7 +29,6 @@ public:
TOGGLE_WORLD_MAP,
TOGGLE_NAMEPLATES,
TOGGLE_RAID_FRAMES,
TOGGLE_QUEST_LOG,
TOGGLE_ACHIEVEMENTS,
ACTION_COUNT
};

View file

@ -391,143 +391,183 @@ void Application::run() {
}
auto lastTime = std::chrono::high_resolution_clock::now();
std::atomic<bool> watchdogRunning{true};
std::atomic<int64_t> watchdogHeartbeatMs{
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count()
};
std::thread watchdogThread([this, &watchdogRunning, &watchdogHeartbeatMs]() {
bool releasedForCurrentStall = false;
while (watchdogRunning.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(std::chrono::milliseconds(250));
const int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire);
const int64_t stallMs = nowMs - lastBeatMs;
while (running && !window->shouldClose()) {
// Calculate delta time
auto currentTime = std::chrono::high_resolution_clock::now();
std::chrono::duration<float> deltaTimeDuration = currentTime - lastTime;
float deltaTime = deltaTimeDuration.count();
lastTime = currentTime;
// Cap delta time to prevent large jumps
if (deltaTime > 0.1f) {
deltaTime = 0.1f;
// Failsafe: if the main loop stalls while relative mouse mode is active,
// forcibly release grab so the user can move the cursor and close the app.
if (stallMs > 1500) {
if (!releasedForCurrentStall) {
SDL_SetRelativeMouseMode(SDL_FALSE);
SDL_ShowCursor(SDL_ENABLE);
if (window && window->getSDLWindow()) {
SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE);
}
LOG_WARNING("Main-loop stall detected (", stallMs,
"ms) — force-released mouse capture failsafe");
releasedForCurrentStall = true;
}
} else {
releasedForCurrentStall = false;
}
}
});
// Poll events
SDL_Event event;
while (SDL_PollEvent(&event)) {
// Pass event to UI manager first
if (uiManager) {
uiManager->processEvent(event);
try {
while (running && !window->shouldClose()) {
watchdogHeartbeatMs.store(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count(),
std::memory_order_release);
// Calculate delta time
auto currentTime = std::chrono::high_resolution_clock::now();
std::chrono::duration<float> deltaTimeDuration = currentTime - lastTime;
float deltaTime = deltaTimeDuration.count();
lastTime = currentTime;
// Cap delta time to prevent large jumps
if (deltaTime > 0.1f) {
deltaTime = 0.1f;
}
// Pass mouse events to camera controller (skip when UI has mouse focus)
if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) {
if (event.type == SDL_MOUSEMOTION) {
renderer->getCameraController()->processMouseMotion(event.motion);
// Poll events
SDL_Event event;
while (SDL_PollEvent(&event)) {
// Pass event to UI manager first
if (uiManager) {
uiManager->processEvent(event);
}
else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) {
renderer->getCameraController()->processMouseButton(event.button);
// Pass mouse events to camera controller (skip when UI has mouse focus)
if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) {
if (event.type == SDL_MOUSEMOTION) {
renderer->getCameraController()->processMouseMotion(event.motion);
}
else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) {
renderer->getCameraController()->processMouseButton(event.button);
}
else if (event.type == SDL_MOUSEWHEEL) {
renderer->getCameraController()->processMouseWheel(static_cast<float>(event.wheel.y));
}
}
else if (event.type == SDL_MOUSEWHEEL) {
renderer->getCameraController()->processMouseWheel(static_cast<float>(event.wheel.y));
// Handle window events
if (event.type == SDL_QUIT) {
window->setShouldClose(true);
}
else if (event.type == SDL_WINDOWEVENT) {
if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
int newWidth = event.window.data1;
int newHeight = event.window.data2;
window->setSize(newWidth, newHeight);
// Vulkan viewport set in command buffer, not globally
if (renderer && renderer->getCamera()) {
renderer->getCamera()->setAspectRatio(static_cast<float>(newWidth) / newHeight);
}
}
}
// Debug controls
else if (event.type == SDL_KEYDOWN) {
// Skip non-function-key input when UI (chat) has keyboard focus
bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard;
auto sc = event.key.keysym.scancode;
bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12);
if (uiHasKeyboard && !isFKey) {
continue; // Let ImGui handle the keystroke
}
// F1: Toggle performance HUD
if (event.key.keysym.scancode == SDL_SCANCODE_F1) {
if (renderer && renderer->getPerformanceHUD()) {
renderer->getPerformanceHUD()->toggle();
bool enabled = renderer->getPerformanceHUD()->isEnabled();
LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF");
}
}
// F4: Toggle shadows
else if (event.key.keysym.scancode == SDL_SCANCODE_F4) {
if (renderer) {
bool enabled = !renderer->areShadowsEnabled();
renderer->setShadowsEnabled(enabled);
LOG_INFO("Shadows: ", enabled ? "ON" : "OFF");
}
}
// F8: Debug WMO floor at current position
else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) {
if (renderer && renderer->getWMORenderer()) {
glm::vec3 pos = renderer->getCharacterPosition();
LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")");
renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z);
}
}
}
}
// Handle window events
if (event.type == SDL_QUIT) {
// Update input
Input::getInstance().update();
// Update application state
try {
update(deltaTime);
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::update (state=", static_cast<int>(state),
", dt=", deltaTime, "): ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::update (state=", static_cast<int>(state),
", dt=", deltaTime, "): ", e.what());
throw;
}
// Render
try {
render();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::render (state=", static_cast<int>(state), "): ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::render (state=", static_cast<int>(state), "): ", e.what());
throw;
}
// Swap buffers
try {
window->swapBuffers();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during swapBuffers: ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during swapBuffers: ", e.what());
throw;
}
// Exit gracefully on GPU device lost (unrecoverable)
if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) {
LOG_ERROR("GPU device lost — exiting application");
window->setShouldClose(true);
}
else if (event.type == SDL_WINDOWEVENT) {
if (event.window.event == SDL_WINDOWEVENT_RESIZED) {
int newWidth = event.window.data1;
int newHeight = event.window.data2;
window->setSize(newWidth, newHeight);
// Vulkan viewport set in command buffer, not globally
if (renderer && renderer->getCamera()) {
renderer->getCamera()->setAspectRatio(static_cast<float>(newWidth) / newHeight);
}
}
}
// Debug controls
else if (event.type == SDL_KEYDOWN) {
// Skip non-function-key input when UI (chat) has keyboard focus
bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard;
auto sc = event.key.keysym.scancode;
bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12);
if (uiHasKeyboard && !isFKey) {
continue; // Let ImGui handle the keystroke
}
}
} catch (...) {
watchdogRunning.store(false, std::memory_order_release);
if (watchdogThread.joinable()) {
watchdogThread.join();
}
throw;
}
// F1: Toggle performance HUD
if (event.key.keysym.scancode == SDL_SCANCODE_F1) {
if (renderer && renderer->getPerformanceHUD()) {
renderer->getPerformanceHUD()->toggle();
bool enabled = renderer->getPerformanceHUD()->isEnabled();
LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF");
}
}
// F4: Toggle shadows
else if (event.key.keysym.scancode == SDL_SCANCODE_F4) {
if (renderer) {
bool enabled = !renderer->areShadowsEnabled();
renderer->setShadowsEnabled(enabled);
LOG_INFO("Shadows: ", enabled ? "ON" : "OFF");
}
}
// F7: Test level-up effect (ignore key repeat)
else if (event.key.keysym.scancode == SDL_SCANCODE_F7 && event.key.repeat == 0) {
if (renderer) {
renderer->triggerLevelUpEffect(renderer->getCharacterPosition());
LOG_INFO("Triggered test level-up effect");
}
if (uiManager) {
uiManager->getGameScreen().triggerDing(99);
}
}
// F8: Debug WMO floor at current position
else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) {
if (renderer && renderer->getWMORenderer()) {
glm::vec3 pos = renderer->getCharacterPosition();
LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")");
renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z);
}
}
}
}
// Update input
Input::getInstance().update();
// Update application state
try {
update(deltaTime);
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::update (state=", static_cast<int>(state),
", dt=", deltaTime, "): ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::update (state=", static_cast<int>(state),
", dt=", deltaTime, "): ", e.what());
throw;
}
// Render
try {
render();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during Application::render (state=", static_cast<int>(state), "): ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during Application::render (state=", static_cast<int>(state), "): ", e.what());
throw;
}
// Swap buffers
try {
window->swapBuffers();
} catch (const std::bad_alloc& e) {
LOG_ERROR("OOM during swapBuffers: ", e.what());
throw;
} catch (const std::exception& e) {
LOG_ERROR("Exception during swapBuffers: ", e.what());
throw;
}
// Exit gracefully on GPU device lost (unrecoverable)
if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) {
LOG_ERROR("GPU device lost — exiting application");
window->setShouldClose(true);
}
watchdogRunning.store(false, std::memory_order_release);
if (watchdogThread.joinable()) {
watchdogThread.join();
}
LOG_INFO("Main loop ended");
@ -807,6 +847,7 @@ void Application::logoutToLogin() {
world.reset();
if (renderer) {
renderer->resetCombatVisualState();
// Remove old player model so it doesn't persist into next session
if (auto* charRenderer = renderer->getCharacterRenderer()) {
charRenderer->removeInstance(1);
@ -1074,6 +1115,15 @@ void Application::update(float deltaTime) {
gameHandler->isTaxiMountActive() ||
gameHandler->isTaxiActivationPending());
bool onTransportNow = gameHandler && gameHandler->isOnTransport();
// Clear stale client-side transport state when the tracked transport no longer exists.
if (onTransportNow && gameHandler->getTransportManager()) {
auto* currentTracked = gameHandler->getTransportManager()->getTransport(
gameHandler->getPlayerTransportGuid());
if (!currentTracked) {
gameHandler->clearPlayerTransport();
onTransportNow = false;
}
}
// M2 transports (trams) use position-delta approach: player keeps normal
// movement and the transport's frame-to-frame delta is applied on top.
// Only WMO transports (ships) use full external-driven mode.
@ -1309,23 +1359,29 @@ void Application::update(float deltaTime) {
} else {
glm::vec3 renderPos = renderer->getCharacterPosition();
// M2 transport riding: apply transport's frame-to-frame position delta
// so the player moves with the tram while retaining normal movement input.
// M2 transport riding: resolve in canonical space and lock once per frame.
// This avoids visible jitter from mixed render/canonical delta application.
if (isM2Transport && gameHandler->getTransportManager()) {
auto* tr = gameHandler->getTransportManager()->getTransport(
gameHandler->getPlayerTransportGuid());
if (tr) {
static glm::vec3 lastTransportCanonical(0);
static uint64_t lastTransportGuid = 0;
if (lastTransportGuid == gameHandler->getPlayerTransportGuid()) {
glm::vec3 deltaCanonical = tr->position - lastTransportCanonical;
glm::vec3 deltaRender = core::coords::canonicalToRender(deltaCanonical)
- core::coords::canonicalToRender(glm::vec3(0));
renderPos += deltaRender;
renderer->getCharacterPosition() = renderPos;
// Keep passenger locked to elevator vertical motion while grounded.
// Without this, floor clamping can hold world-Z static unless the
// player is jumping, which makes lifts appear to not move vertically.
glm::vec3 tentativeCanonical = core::coords::renderToCanonical(renderPos);
glm::vec3 localOffset = gameHandler->getPlayerTransportOffset();
localOffset.x = tentativeCanonical.x - tr->position.x;
localOffset.y = tentativeCanonical.y - tr->position.y;
if (renderer->getCameraController() &&
!renderer->getCameraController()->isGrounded()) {
// While airborne (jump/fall), allow local Z offset to change.
localOffset.z = tentativeCanonical.z - tr->position.z;
}
lastTransportCanonical = tr->position;
lastTransportGuid = gameHandler->getPlayerTransportGuid();
gameHandler->setPlayerTransportOffset(localOffset);
glm::vec3 lockedCanonical = tr->position + localOffset;
renderPos = core::coords::canonicalToRender(lockedCanonical);
renderer->getCharacterPosition() = renderPos;
}
}
@ -1362,21 +1418,45 @@ void Application::update(float deltaTime) {
}
// Client-side transport boarding detection (for M2 transports like trams
// where the server doesn't send transport attachment data).
// Use a generous AABB around each transport's current position.
// and lifts where the server doesn't send transport attachment data).
// Thunder Bluff elevators use model origins that can be far from the deck
// the player stands on, so they need wider attachment bounds.
if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) {
auto* tm = gameHandler->getTransportManager();
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f;
constexpr float kM2BoardVertDist = 15.0f;
constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f;
constexpr float kTbLiftBoardVertDist = 14.0f;
uint64_t bestGuid = 0;
float bestScore = 1e30f;
for (auto& [guid, transport] : tm->getTransports()) {
if (!transport.isM2) continue;
const bool isThunderBluffLift =
(transport.entry >= 20649u && transport.entry <= 20657u);
const float maxHorizDistSq = isThunderBluffLift
? kTbLiftBoardHorizDistSq
: kM2BoardHorizDistSq;
const float maxVertDist = isThunderBluffLift
? kTbLiftBoardVertDist
: kM2BoardVertDist;
glm::vec3 diff = playerCanonical - transport.position;
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
float vertDist = std::abs(diff.z);
if (horizDistSq < 144.0f && vertDist < 15.0f) {
gameHandler->setPlayerOnTransport(guid, playerCanonical - transport.position);
LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, guid, std::dec);
break;
if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) {
float score = horizDistSq + vertDist * vertDist;
if (score < bestScore) {
bestScore = score;
bestGuid = guid;
}
}
}
if (bestGuid != 0) {
auto* tr = tm->getTransport(bestGuid);
if (tr) {
gameHandler->setPlayerOnTransport(bestGuid, playerCanonical - tr->position);
LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, bestGuid, std::dec);
}
}
}
@ -1389,7 +1469,19 @@ void Application::update(float deltaTime) {
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
glm::vec3 diff = playerCanonical - tr->position;
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
if (horizDistSq > 225.0f) {
const bool isThunderBluffLift =
(tr->entry >= 20649u && tr->entry <= 20657u);
constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f;
constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f;
constexpr float kM2DisembarkVertDist = 18.0f;
constexpr float kTbLiftDisembarkVertDist = 16.0f;
const float disembarkHorizDistSq = isThunderBluffLift
? kTbLiftDisembarkHorizDistSq
: kM2DisembarkHorizDistSq;
const float disembarkVertDist = isThunderBluffLift
? kTbLiftDisembarkVertDist
: kM2DisembarkVertDist;
if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) {
gameHandler->clearPlayerTransport();
LOG_DEBUG("M2 transport disembark");
}
@ -1822,6 +1914,9 @@ void Application::setupUICallbacks() {
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) {
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"
" initial=", isInitialEntry);
if (renderer) {
renderer->resetCombatVisualState();
}
// Reconnect to the same map: terrain stays loaded but all online entities are stale.
// Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world.
@ -2832,6 +2927,12 @@ void Application::setupUICallbacks() {
}
transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry);
// Keep type in sync with the spawned instance; needed for M2 lift boarding/motion.
if (!it->second.isWmo) {
if (auto* tr = transportManager->getTransport(guid)) {
tr->isM2 = true;
}
}
} else {
pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation};
LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec,
@ -3011,9 +3112,11 @@ void Application::setupUICallbacks() {
if (charInstId == 0) return;
// WoW stand state → M2 animation ID mapping
// 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72
// Do not force Stand(0) here: locomotion state machine already owns standing/running.
// Forcing Stand on packet timing causes visible run-cycle hitching while steering.
uint32_t animId = 0;
if (standState == 0) {
animId = 0; // Stand
return;
} else if (standState >= 1 && standState <= 6) {
animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height)
} else if (standState == 7) {

View file

@ -309,6 +309,16 @@ bool isPlaceholderQuestTitle(const std::string& s) {
return s.rfind("Quest #", 0) == 0;
}
float mergeCooldownSeconds(float current, float incoming) {
constexpr float kEpsilon = 0.05f;
if (incoming <= 0.0f) return 0.0f;
if (current <= 0.0f) return incoming;
// Cooldowns should normally tick down. If a duplicate/late packet reports a
// larger value, keep the local remaining time to avoid visible timer resets.
if (incoming > current + kEpsilon) return current;
return incoming;
}
bool looksLikeQuestDescriptionText(const std::string& s) {
int spaces = 0;
int commas = 0;
@ -3208,7 +3218,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t cdMs = packet.readUInt32();
float cdSec = cdMs / 1000.0f;
if (cdSec > 0.0f) {
if (spellId != 0) spellCooldowns[spellId] = cdSec;
if (spellId != 0) {
auto it = spellCooldowns.find(spellId);
if (it == spellCooldowns.end()) {
spellCooldowns[spellId] = cdSec;
} else {
it->second = mergeCooldownSeconds(it->second, cdSec);
}
}
// Resolve itemId from the GUID so item-type slots are also updated
uint32_t itemId = 0;
auto iit = onlineItems_.find(itemGuid);
@ -3217,8 +3234,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|| (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId);
if (match) {
slot.cooldownTotal = cdSec;
slot.cooldownRemaining = cdSec;
float prevRemaining = slot.cooldownRemaining;
float merged = mergeCooldownSeconds(slot.cooldownRemaining, cdSec);
slot.cooldownRemaining = merged;
if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) {
slot.cooldownTotal = cdSec;
} else {
slot.cooldownTotal = std::max(slot.cooldownTotal, merged);
}
}
}
LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec,
@ -8447,6 +8470,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
pendingItemQueries_.clear();
equipSlotGuids_ = {};
backpackSlotGuids_ = {};
keyringSlotGuids_ = {};
invSlotBase_ = -1;
packSlotBase_ = -1;
lastPlayerFields_.clear();
@ -8506,6 +8530,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
castTimeTotal = 0.0f;
playerDead_ = false;
releasedSpirit_ = false;
corpseGuid_ = 0;
targetGuid = 0;
focusGuid = 0;
lastTargetGuid = 0;
@ -9847,8 +9872,17 @@ void GameHandler::sendMovement(Opcode opcode) {
sanitizeMovementForTaxi();
}
// Add transport data if player is on a transport
if (isOnTransport()) {
bool includeTransportInWire = isOnTransport();
if (includeTransportInWire && transportManager_) {
if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr && tr->isM2) {
// Client-detected M2 elevators/trams are not always server-recognized transports.
// Sending ONTRANSPORT for these can trigger bad fall-state corrections server-side.
includeTransportInWire = false;
}
}
// Add transport data if player is on a server-recognized transport
if (includeTransportInWire) {
// Keep authoritative world position synchronized to parent transport transform
// so heartbeats/corrections don't drag the passenger through geometry.
if (transportManager_) {
@ -9890,7 +9924,7 @@ void GameHandler::sendMovement(Opcode opcode) {
LOG_DEBUG("Sending movement packet: opcode=0x", std::hex,
wireOpcode(opcode), std::dec,
(isOnTransport() ? " ONTRANSPORT" : ""));
(includeTransportInWire ? " ONTRANSPORT" : ""));
// Convert canonical → server coordinates for the wire
MovementInfo wireInfo = movementInfo;
@ -9903,7 +9937,7 @@ void GameHandler::sendMovement(Opcode opcode) {
wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation);
// Also convert transport local position to server coordinates if on transport
if (isOnTransport()) {
if (includeTransportInWire) {
glm::vec3 serverTransportPos = core::coords::canonicalToServer(
glm::vec3(wireInfo.transportX, wireInfo.transportY, wireInfo.transportZ));
wireInfo.transportX = serverTransportPos.x;
@ -9963,6 +9997,7 @@ void GameHandler::forceClearTaxiAndMovementState() {
resurrectRequestPending_ = false;
playerDead_ = false;
releasedSpirit_ = false;
corpseGuid_ = 0;
repopPending_ = false;
pendingSpiritHealerGuid_ = 0;
resurrectCasterGuid_ = 0;
@ -10580,11 +10615,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
uint64_t ownerGuid = (static_cast<uint64_t>(ownerHigh) << 32) | ownerLow;
if (ownerGuid == playerGuid || ownerLow == static_cast<uint32_t>(playerGuid)) {
// Server coords from movement block
corpseGuid_ = block.guid;
corpseX_ = block.x;
corpseY_ = block.y;
corpseZ_ = block.z;
corpseMapId_ = currentMapId_;
LOG_INFO("Corpse object detected: server=(", block.x, ", ", block.y, ", ", block.z,
LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec,
" server=(", block.x, ", ", block.y, ", ", block.z,
") map=", corpseMapId_);
}
}
@ -11136,6 +11173,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
repopPending_ = false;
resurrectPending_ = false;
corpseMapId_ = 0; // corpse reclaimed
corpseGuid_ = 0;
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
if (ghostStateCallback_) ghostStateCallback_(false);
}
@ -12908,9 +12946,12 @@ bool GameHandler::canReclaimCorpse() const {
void GameHandler::reclaimCorpse() {
if (!canReclaimCorpse() || !socket) return;
network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE));
// Reclaim expects the corpse object guid when known; fallback to player guid.
uint64_t reclaimGuid = (corpseGuid_ != 0) ? corpseGuid_ : playerGuid;
auto packet = ReclaimCorpsePacket::build(reclaimGuid);
socket->send(packet);
LOG_INFO("Sent CMSG_RECLAIM_CORPSE");
LOG_INFO("Sent CMSG_RECLAIM_CORPSE for guid=0x", std::hex, reclaimGuid, std::dec,
(corpseGuid_ == 0 ? " (fallback player guid)" : ""));
}
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
@ -13589,6 +13630,21 @@ bool GameHandler::applyInventoryFields(const std::map<uint16_t, uint32_t>& field
bool slotsChanged = false;
int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_PACK_SLOT_1));
int bankBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1));
int bankBagBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1));
// Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7).
if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) {
effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28);
effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7;
}
int keyringBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_KEYRING_SLOT_1));
if (keyringBase == 0xFFFF && bankBagBase != 0xFFFF) {
// Layout fallback for profiles that don't define PLAYER_FIELD_KEYRING_SLOT_1.
// Bank bag slots are followed by 12 vendor buyback slots (24 fields), then keyring.
keyringBase = bankBagBase + (effectiveBankBagSlots_ * 2) + 24;
}
for (const auto& [key, val] : fields) {
if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) {
@ -13609,15 +13665,17 @@ bool GameHandler::applyInventoryFields(const std::map<uint16_t, uint32_t>& field
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
// Bank slots starting at PLAYER_FIELD_BANK_SLOT_1
int bankBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1));
int bankBagBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1));
// Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7)
if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) {
effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28);
effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7;
} else if (keyringBase != 0xFFFF &&
key >= keyringBase &&
key <= keyringBase + (game::Inventory::KEYRING_SLOTS * 2 - 1)) {
int slotIndex = (key - keyringBase) / 2;
bool isLow = ((key - keyringBase) % 2 == 0);
if (slotIndex < static_cast<int>(keyringSlotGuids_.size())) {
uint64_t& guid = keyringSlotGuids_[slotIndex];
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
slotsChanged = true;
}
}
if (bankBase != 0xFFFF && key >= static_cast<uint16_t>(bankBase) &&
key <= static_cast<uint16_t>(bankBase) + (effectiveBankSlots_ * 2 - 1)) {
@ -13778,6 +13836,55 @@ void GameHandler::rebuildOnlineInventory() {
inventory.setBackpackSlot(i, def);
}
// Keyring slots
for (int i = 0; i < game::Inventory::KEYRING_SLOTS; i++) {
uint64_t guid = keyringSlotGuids_[i];
if (guid == 0) continue;
auto itemIt = onlineItems_.find(guid);
if (itemIt == onlineItems_.end()) continue;
ItemDef def;
def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount;
def.curDurability = itemIt->second.curDurability;
def.maxDurability = itemIt->second.maxDurability;
def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
if (infoIt != itemInfoCache_.end()) {
def.name = infoIt->second.name;
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
def.inventoryType = infoIt->second.inventoryType;
def.maxStack = std::max(1, infoIt->second.maxStack);
def.displayInfoId = infoIt->second.displayInfoId;
def.subclassName = infoIt->second.subclassName;
def.damageMin = infoIt->second.damageMin;
def.damageMax = infoIt->second.damageMax;
def.delayMs = infoIt->second.delayMs;
def.armor = infoIt->second.armor;
def.stamina = infoIt->second.stamina;
def.strength = infoIt->second.strength;
def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit;
def.sellPrice = infoIt->second.sellPrice;
def.itemLevel = infoIt->second.itemLevel;
def.requiredLevel = infoIt->second.requiredLevel;
def.bindType = infoIt->second.bindType;
def.description = infoIt->second.description;
def.startQuestId = infoIt->second.startQuestId;
def.extraStats.clear();
for (const auto& es : infoIt->second.extraStats)
def.extraStats.push_back({es.statType, es.statValue});
} else {
def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid);
}
inventory.setKeyringSlot(i, def);
}
// Bag contents (BAG1-BAG4 are equip slots 19-22)
for (int bagIdx = 0; bagIdx < 4; bagIdx++) {
uint64_t bagGuid = equipSlotGuids_[19 + bagIdx];
@ -14021,6 +14128,8 @@ void GameHandler::rebuildOnlineInventory() {
int c = 0; for (auto g : equipSlotGuids_) if (g) c++; return c;
}(), " backpack=", [&](){
int c = 0; for (auto g : backpackSlotGuids_) if (g) c++; return c;
}(), " keyring=", [&](){
int c = 0; for (auto g : keyringSlotGuids_) if (g) c++; return c;
}());
}
@ -16667,9 +16776,12 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
// Optimistically start GCD immediately on cast — server will confirm or override
gcdTotal_ = 1.5f;
gcdStartedAt_ = std::chrono::steady_clock::now();
// Optimistically start GCD immediately on cast, but do not restart it while
// already active (prevents timeout animation reset on repeated key presses).
if (!isGCDActive()) {
gcdTotal_ = 1.5f;
gcdStartedAt_ = std::chrono::steady_clock::now();
}
}
void GameHandler::cancelCast() {
@ -17184,13 +17296,24 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) {
continue;
}
spellCooldowns[spellId] = seconds;
auto it = spellCooldowns.find(spellId);
if (it == spellCooldowns.end()) {
spellCooldowns[spellId] = seconds;
} else {
it->second = mergeCooldownSeconds(it->second, seconds);
}
for (auto& slot : actionBar) {
bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
|| (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId);
if (match) {
slot.cooldownTotal = seconds;
slot.cooldownRemaining = seconds;
float prevRemaining = slot.cooldownRemaining;
float merged = mergeCooldownSeconds(slot.cooldownRemaining, seconds);
slot.cooldownRemaining = merged;
if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) {
slot.cooldownTotal = seconds;
} else {
slot.cooldownTotal = std::max(slot.cooldownTotal, merged);
}
}
}
}
@ -18445,6 +18568,21 @@ void GameHandler::selectGossipOption(uint32_t optionId) {
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
}
// Vendor / repair: some servers require an explicit CMSG_LIST_INVENTORY after gossip select.
const bool isVendor = (text == "GOSSIP_OPTION_VENDOR" ||
(textLower.find("browse") != std::string::npos &&
(textLower.find("goods") != std::string::npos || textLower.find("wares") != std::string::npos)));
const bool isArmorer = (text == "GOSSIP_OPTION_ARMORER" || textLower.find("repair") != std::string::npos);
if (isVendor || isArmorer) {
if (isArmorer) {
setVendorCanRepair(true);
}
auto pkt = ListInventoryPacket::build(currentGossip.npcGuid);
socket->send(pkt);
LOG_INFO("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip.npcGuid, std::dec,
" vendor=", (int)isVendor, " repair=", (int)isArmorer);
}
if (textLower.find("make this inn your home") != std::string::npos ||
textLower.find("set your home") != std::string::npos) {
auto bindPkt = BinderActivatePacket::build(currentGossip.npcGuid);
@ -19567,7 +19705,8 @@ void GameHandler::handleLootResponse(network::Packet& packet) {
std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(),
[&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }),
pendingGameObjectLootOpens_.end());
localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false};
auto& localLoot = localLootState_[currentLoot.lootGuid];
localLoot.data = currentLoot;
// Query item info so loot window can show names instead of IDs
for (const auto& item : currentLoot.items) {
@ -19592,11 +19731,12 @@ void GameHandler::handleLootResponse(network::Packet& packet) {
}
// Auto-loot items when enabled
if (autoLoot_ && state == WorldState::IN_WORLD && socket) {
if (autoLoot_ && state == WorldState::IN_WORLD && socket && !localLoot.itemAutoLootSent) {
for (const auto& item : currentLoot.items) {
auto pkt = AutostoreLootItemPacket::build(item.slotIndex);
socket->send(pkt);
}
localLoot.itemAutoLootSent = true;
}
}
@ -19805,13 +19945,14 @@ void GameHandler::handleListInventory(network::Packet& packet) {
bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path
if (!ListInventoryParser::parse(packet, currentVendorItems)) return;
// Check NPC_FLAG_REPAIR (0x40) on the vendor entity — this handles vendors that open
// Check NPC_FLAG_REPAIR (0x1000) on the vendor entity — this handles vendors that open
// directly without going through the gossip armorer option.
if (!savedCanRepair && currentVendorItems.vendorGuid != 0) {
auto entity = entityManager.getEntity(currentVendorItems.vendorGuid);
if (entity && entity->getType() == ObjectType::UNIT) {
auto unit = std::static_pointer_cast<Unit>(entity);
if (unit->getNpcFlags() & 0x40) { // NPC_FLAG_REPAIR
// MaNGOS/Trinity: UNIT_NPC_FLAG_REPAIR = 0x00001000.
if (unit->getNpcFlags() & 0x1000) {
savedCanRepair = true;
}
}
@ -20402,6 +20543,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
pendingSpiritHealerGuid_ = 0;
resurrectCasterGuid_ = 0;
corpseMapId_ = 0;
corpseGuid_ = 0;
hostileAttackers_.clear();
stopAutoAttack();
tabCycleStale = true;
@ -22120,9 +22262,9 @@ void GameHandler::mailTakeMoney(uint32_t mailId) {
socket->send(packet);
}
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemIndex) {
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemGuidLow) {
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemIndex);
auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemGuidLow);
socket->send(packet);
}
@ -22873,7 +23015,7 @@ void GameHandler::declineTradeRequest() {
}
void GameHandler::acceptTrade() {
if (tradeStatus_ != TradeStatus::Open || !socket) return;
if (!isTradeOpen() || !socket) return;
tradeStatus_ = TradeStatus::Accepted;
socket->send(AcceptTradePacket::build());
}

View file

@ -45,6 +45,23 @@ bool Inventory::clearEquipSlot(EquipSlot slot) {
return true;
}
const ItemSlot& Inventory::getKeyringSlot(int index) const {
if (index < 0 || index >= KEYRING_SLOTS) return EMPTY_SLOT;
return keyring_[index];
}
bool Inventory::setKeyringSlot(int index, const ItemDef& item) {
if (index < 0 || index >= KEYRING_SLOTS) return false;
keyring_[index].item = item;
return true;
}
bool Inventory::clearKeyringSlot(int index) {
if (index < 0 || index >= KEYRING_SLOTS) return false;
keyring_[index].item = ItemDef{};
return true;
}
int Inventory::getBagSize(int bagIndex) const {
if (bagIndex < 0 || bagIndex >= NUM_BAG_SLOTS) return 0;
return bags[bagIndex].size;

View file

@ -251,8 +251,10 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float
if (path.durationMs == 0) {
// Just update transform (position already set)
updateTransformMatrices(transport);
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
if (transport.isM2) {
if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
} else {
if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
}
return;
}
@ -287,8 +289,10 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float
} else {
// Strict server-authoritative mode: do not guess movement between server snapshots.
updateTransformMatrices(transport);
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
if (transport.isM2) {
if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
} else {
if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
}
return;
}
@ -777,8 +781,10 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos
}
updateTransformMatrices(*transport);
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform);
if (transport->isM2) {
if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform);
} else {
if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform);
}
return;
}

View file

@ -54,6 +54,7 @@ static const UFNameEntry kUFNames[] = {
{"PLAYER_QUEST_LOG_START", UF::PLAYER_QUEST_LOG_START},
{"PLAYER_FIELD_INV_SLOT_HEAD", UF::PLAYER_FIELD_INV_SLOT_HEAD},
{"PLAYER_FIELD_PACK_SLOT_1", UF::PLAYER_FIELD_PACK_SLOT_1},
{"PLAYER_FIELD_KEYRING_SLOT_1", UF::PLAYER_FIELD_KEYRING_SLOT_1},
{"PLAYER_FIELD_BANK_SLOT_1", UF::PLAYER_FIELD_BANK_SLOT_1},
{"PLAYER_FIELD_BANKBAG_SLOT_1", UF::PLAYER_FIELD_BANKBAG_SLOT_1},
{"PLAYER_SKILL_INFO_START", UF::PLAYER_SKILL_INFO_START},

View file

@ -1482,6 +1482,47 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) {
// Read unknown field
packet.readUInt32();
auto tryReadSizedCString = [&](std::string& out, uint32_t maxLen, size_t minTrailingBytes) -> bool {
size_t start = packet.getReadPos();
size_t remaining = packet.getSize() - start;
if (remaining < 4 + minTrailingBytes) return false;
uint32_t len = packet.readUInt32();
if (len < 2 || len > maxLen) {
packet.setReadPos(start);
return false;
}
if ((packet.getSize() - packet.getReadPos()) < (static_cast<size_t>(len) + minTrailingBytes)) {
packet.setReadPos(start);
return false;
}
std::string tmp;
tmp.resize(len);
for (uint32_t i = 0; i < len; ++i) {
tmp[i] = static_cast<char>(packet.readUInt8());
}
if (tmp.empty() || tmp.back() != '\0') {
packet.setReadPos(start);
return false;
}
tmp.pop_back();
if (tmp.empty()) {
packet.setReadPos(start);
return false;
}
for (char c : tmp) {
unsigned char uc = static_cast<unsigned char>(c);
if (uc < 32 || uc > 126) {
packet.setReadPos(start);
return false;
}
}
out = std::move(tmp);
return true;
};
// Type-specific data
// WoW 3.3.5 SMSG_MESSAGECHAT format: after senderGuid+unk, most types
// have a receiverGuid (uint64). Some types have extra fields before it.
@ -1537,6 +1578,27 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) {
break;
}
case ChatType::WHISPER:
case ChatType::WHISPER_INFORM: {
// Some cores include an explicit sized sender/receiver name for whisper chat.
// Consume it when present so /r has a reliable last whisper sender.
if (data.type == ChatType::WHISPER) {
tryReadSizedCString(data.senderName, 128, 8 + 4 + 1);
} else {
tryReadSizedCString(data.receiverName, 128, 8 + 4 + 1);
}
data.receiverGuid = packet.readUInt64();
// Optional trailing whisper target/source name on some formats.
if (data.type == ChatType::WHISPER && data.receiverName.empty()) {
tryReadSizedCString(data.receiverName, 128, 4 + 1);
} else if (data.type == ChatType::WHISPER_INFORM && data.senderName.empty()) {
tryReadSizedCString(data.senderName, 128, 4 + 1);
}
break;
}
case ChatType::BG_SYSTEM_NEUTRAL:
case ChatType::BG_SYSTEM_ALLIANCE:
case ChatType::BG_SYSTEM_HORDE:
@ -4881,6 +4943,12 @@ network::Packet RepopRequestPacket::build() {
return packet;
}
network::Packet ReclaimCorpsePacket::build(uint64_t guid) {
network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE));
packet.writeUInt64(guid);
return packet;
}
network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) {
network::Packet packet(wireOpcode(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE));
packet.writeUInt64(npcGuid);
@ -5001,11 +5069,12 @@ network::Packet MailTakeMoneyPacket::build(uint64_t mailboxGuid, uint32_t mailId
return packet;
}
network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemIndex) {
network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) {
network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_ITEM));
packet.writeUInt64(mailboxGuid);
packet.writeUInt32(mailId);
packet.writeUInt32(itemIndex);
// WotLK expects attachment item GUID low, not attachment slot index.
packet.writeUInt32(itemGuidLow);
return packet;
}
@ -5072,10 +5141,9 @@ bool PacketParsers::parseMailList(network::Packet& packet, std::vector<MailMessa
msg.expirationTime = packet.readFloat();
msg.mailTemplateId = packet.readUInt32();
msg.subject = packet.readString();
if (msg.mailTemplateId == 0) {
msg.body = packet.readString();
}
// WotLK 3.3.5a always includes body text in SMSG_MAIL_LIST_RESULT.
// mailTemplateId != 0 still carries a (possibly empty) body string.
msg.body = packet.readString();
uint8_t attachCount = packet.readUInt8();
msg.attachments.reserve(attachCount);
@ -5095,6 +5163,8 @@ bool PacketParsers::parseMailList(network::Packet& packet, std::vector<MailMessa
att.stackCount = packet.readUInt32();
att.chargesOrDurability = packet.readUInt32();
att.maxDurability = packet.readUInt32();
packet.readUInt32(); // durability/current durability
packet.readUInt8(); // unknown WotLK trailing byte per attachment
msg.attachments.push_back(att);
}

View file

@ -1623,10 +1623,6 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
if (t >= 1.0f) {
inst.position = inst.moveEnd;
inst.isMoving = false;
// Return to idle when movement completes
if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) {
playAnimation(pair.first, 0, true);
}
} else {
inst.position = glm::mix(inst.moveStart, inst.moveEnd, t);
}

View file

@ -2654,8 +2654,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
(batch.blendMode >= 3) ||
batch.colorKeyBlack ||
((batch.materialFlags & 0x01) != 0);
if ((batch.glowCardLike && lanternLikeModel) ||
(cardLikeSkipMesh && !lanternLikeModel)) {
const bool lanternGlowCardSkip =
lanternLikeModel &&
batch.lanternGlowHint &&
smallCardLikeBatch &&
cardLikeSkipMesh;
if (lanternGlowCardSkip || (cardLikeSkipMesh && !lanternLikeModel)) {
continue;
}
}
@ -2851,16 +2855,25 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
// Skip glow sprites (handled after loop)
const bool batchUnlit = (batch.materialFlags & 0x01) != 0;
const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame;
const bool smallCardLikeBatch =
(batch.glowSize <= 1.35f) ||
(batch.lanternGlowHint && batch.glowSize <= 6.0f);
const bool shouldUseGlowSprite =
!batch.colorKeyBlack &&
!koboldFlameCard &&
(model.isElvenLike || model.isLanternLike) &&
!model.isSpellEffect &&
(batch.glowSize <= 1.35f || (batch.lanternGlowHint && batch.glowSize <= 6.0f)) &&
smallCardLikeBatch &&
(batch.lanternGlowHint || (batch.blendMode >= 3) ||
(batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1));
if (shouldUseGlowSprite) {
const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit;
if ((batch.glowCardLike && model.isLanternLike) || (cardLikeSkipMesh && !model.isLanternLike))
const bool lanternGlowCardSkip =
model.isLanternLike &&
batch.lanternGlowHint &&
smallCardLikeBatch &&
cardLikeSkipMesh;
if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike))
continue;
}

View file

@ -1031,13 +1031,14 @@ 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) {
// Do not force this pass for ghost mode; keep AA quality strictly user-controlled.
const bool useFXAAPostPass = fxaa_.enabled;
if ((fxaa_.needsRecreate || !useFXAAPostPass) && fxaa_.sceneFramebuffer) {
destroyFXAAResources();
fxaa_.needsRecreate = false;
if (!fxaa_.enabled) LOG_INFO("FXAA: disabled");
if (!useFXAAPostPass) LOG_INFO("FXAA: disabled");
}
if (fxaa_.enabled && !fxaa_.sceneFramebuffer) {
if (useFXAAPostPass && !fxaa_.sceneFramebuffer) {
if (!initFXAAResources()) {
LOG_ERROR("FXAA: initialization failed, disabling");
fxaa_.enabled = false;
@ -1060,9 +1061,8 @@ void Renderer::beginFrame() {
destroyFSR2Resources();
initFSR2Resources();
}
// Recreate FXAA resources for new swapchain dimensions
// FXAA can coexist with FSR1 and FSR3 simultaneously.
if (fxaa_.enabled) {
// Recreate FXAA resources for new swapchain dimensions.
if (useFXAAPostPass) {
destroyFXAAResources();
initFXAAResources();
}
@ -1152,7 +1152,7 @@ void Renderer::beginFrame() {
if (fsr2_.enabled && fsr2_.sceneFramebuffer) {
rpInfo.framebuffer = fsr2_.sceneFramebuffer;
renderExtent = { fsr2_.internalWidth, fsr2_.internalHeight };
} else if (fxaa_.enabled && fxaa_.sceneFramebuffer) {
} else if (useFXAAPostPass && 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;
@ -1856,7 +1856,18 @@ void Renderer::updateCharacterAnimation() {
CharAnimState newState = charAnimState;
bool moving = cameraController->isMoving();
const bool rawMoving = cameraController->isMoving();
const bool rawSprinting = cameraController->isSprinting();
constexpr float kLocomotionStopGraceSec = 0.12f;
if (rawMoving) {
locomotionStopGraceTimer_ = kLocomotionStopGraceSec;
locomotionWasSprinting_ = rawSprinting;
} else {
locomotionStopGraceTimer_ = std::max(0.0f, locomotionStopGraceTimer_ - lastDeltaTime_);
}
// Debounce brief input/state dropouts (notably during both-mouse steering) so
// locomotion clips do not restart every few frames.
bool moving = rawMoving || locomotionStopGraceTimer_ > 0.0f;
bool movingForward = cameraController->isMovingForward();
bool movingBackward = cameraController->isMovingBackward();
bool autoRunning = cameraController->isAutoRunning();
@ -1869,7 +1880,7 @@ void Renderer::updateCharacterAnimation() {
bool anyStrafeRight = strafeRight && !strafeLeft && pureStrafe;
bool grounded = cameraController->isGrounded();
bool jumping = cameraController->isJumping();
bool sprinting = cameraController->isSprinting();
bool sprinting = rawSprinting || (!rawMoving && moving && locomotionWasSprinting_);
bool sitting = cameraController->isSitting();
bool swim = cameraController->isSwimming();
bool forceMelee = meleeSwingTimer > 0.0f && grounded && !swim;
@ -2529,8 +2540,14 @@ void Renderer::updateCharacterAnimation() {
float currentAnimTimeMs = 0.0f;
float currentAnimDurationMs = 0.0f;
bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs);
if (!haveState || currentAnimId != animId) {
// Some frames may transiently fail getAnimationState() while resources/instance state churn.
// Avoid reissuing the same clip on those frames, which restarts locomotion and causes hitches.
const bool requestChanged = (lastPlayerAnimRequest_ != animId) || (lastPlayerAnimLoopRequest_ != loop);
const bool shouldPlay = (haveState && currentAnimId != animId) || (!haveState && requestChanged);
if (shouldPlay) {
characterRenderer->playAnimation(characterInstanceId, animId, loop);
lastPlayerAnimRequest_ = animId;
lastPlayerAnimLoopRequest_ = loop;
}
}
@ -2701,6 +2718,13 @@ void Renderer::setTargetPosition(const glm::vec3* pos) {
targetPosition = pos;
}
void Renderer::resetCombatVisualState() {
inCombat_ = false;
targetPosition = nullptr;
meleeSwingTimer = 0.0f;
meleeSwingCooldown = 0.0f;
}
bool Renderer::isMoving() const {
return cameraController && cameraController->isMoving();
}
@ -5074,7 +5098,7 @@ void Renderer::renderFXAAPass() {
vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr);
// Pass rcpFrame + sharpness + desaturate (vec4, 16 bytes).
// Pass rcpFrame + sharpness + effect flag (vec4, 16 bytes).
// When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the
// post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes.
float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f;
@ -5082,7 +5106,7 @@ void Renderer::renderFXAAPass() {
1.0f / static_cast<float>(ext.width),
1.0f / static_cast<float>(ext.height),
sharpness,
ghostMode_ ? 1.0f : 0.0f // desaturate: 1=ghost grayscale, 0=normal
0.0f
};
vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout,
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc);
@ -5272,12 +5296,6 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
renderOverlay(tint, cmd);
}
}
// Ghost mode desaturation overlay (non-FXAA path approximation).
// When FXAA is active the FXAA shader applies true per-pixel desaturation;
// otherwise a high-opacity gray overlay gives a similar washed-out effect.
if (ghostMode_ && overlayPipeline && !fxaa_.enabled) {
renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f), cmd);
}
if (minimap && minimap->isEnabled() && camera && window) {
glm::vec3 minimapCenter = camera->getPosition();
if (cameraController && cameraController->isThirdPerson())
@ -5412,10 +5430,6 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
renderOverlay(tint);
}
}
// Ghost mode desaturation overlay (non-FXAA path approximation).
if (ghostMode_ && overlayPipeline && !fxaa_.enabled) {
renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f));
}
if (minimap && minimap->isEnabled() && camera && window) {
glm::vec3 minimapCenter = camera->getPosition();
if (cameraController && cameraController->isThirdPerson())

View file

@ -396,9 +396,13 @@ bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh,
// Allocate and write material descriptor set
gpuChunk.materialSet = allocateMaterialSet();
if (gpuChunk.materialSet) {
writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk);
if (!gpuChunk.materialSet) {
// Pool exhaustion can happen transiently while tile churn is high.
// Drop this chunk instead of retaining non-renderable GPU resources.
destroyChunkGPU(gpuChunk);
continue;
}
writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk);
chunks.push_back(std::move(gpuChunk));
}
@ -487,9 +491,12 @@ bool TerrainRenderer::loadTerrainIncremental(const pipeline::TerrainMesh& mesh,
}
gpuChunk.materialSet = allocateMaterialSet();
if (gpuChunk.materialSet) {
writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk);
if (!gpuChunk.materialSet) {
// Keep memory/work bounded under descriptor pool pressure.
destroyChunkGPU(gpuChunk);
continue;
}
writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk);
chunks.push_back(std::move(gpuChunk));
uploaded++;
@ -653,7 +660,11 @@ VkDescriptorSet TerrainRenderer::allocateMaterialSet() {
VkDescriptorSet set = VK_NULL_HANDLE;
if (vkAllocateDescriptorSets(vkCtx->getDevice(), &allocInfo, &set) != VK_SUCCESS) {
LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set");
static uint64_t failCount = 0;
++failCount;
if (failCount <= 8 || (failCount % 256) == 0) {
LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set (count=", failCount, ")");
}
return VK_NULL_HANDLE;
}
return set;

View file

@ -2054,6 +2054,9 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,
const Frustum& frustum,
const glm::mat4& modelMatrix,
std::unordered_set<uint32_t>& outVisibleGroups) const {
constexpr uint32_t WMO_GROUP_FLAG_OUTDOOR = 0x8;
constexpr uint32_t WMO_GROUP_FLAG_INDOOR = 0x2000;
// Find camera's containing group
int cameraGroup = findContainingGroup(model, cameraLocalPos);
@ -2067,6 +2070,21 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,
return;
}
// Outdoor city WMOs (e.g. Stormwind) often have portal graphs that are valid for
// indoor visibility but too aggressive outdoors, causing direction-dependent popout.
// Only trust portal traversal when the camera is in an interior-only group.
if (cameraGroup < static_cast<int>(model.groups.size())) {
const uint32_t gFlags = model.groups[cameraGroup].groupFlags;
const bool isIndoor = (gFlags & WMO_GROUP_FLAG_INDOOR) != 0;
const bool isOutdoor = (gFlags & WMO_GROUP_FLAG_OUTDOOR) != 0;
if (!isIndoor || isOutdoor) {
for (size_t gi = 0; gi < model.groups.size(); gi++) {
outVisibleGroups.insert(static_cast<uint32_t>(gi));
}
return;
}
}
// If the camera group has no portal refs, it's a dead-end group (utility/transition group).
// Fall back to showing all groups to avoid the rest of the WMO going invisible.
if (cameraGroup < static_cast<int>(model.groupPortalRefs.size())) {

View file

@ -845,7 +845,23 @@ void GameScreen::render(game::GameHandler& gameHandler) {
// Update renderer face-target position and selection circle
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
renderer->setInCombat(gameHandler.isInCombat());
renderer->setInCombat(gameHandler.isInCombat() &&
!gameHandler.isPlayerDead() &&
!gameHandler.isPlayerGhost());
if (auto* cr = renderer->getCharacterRenderer()) {
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId != 0) {
const bool isGhost = gameHandler.isPlayerGhost();
if (!ghostOpacityStateKnown_ ||
ghostOpacityLastState_ != isGhost ||
ghostOpacityLastInstanceId_ != charInstId) {
cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f);
ghostOpacityStateKnown_ = true;
ghostOpacityLastState_ = isGhost;
ghostOpacityLastInstanceId_ = charInstId;
}
}
}
static glm::vec3 targetGLPos;
if (gameHandler.hasTarget()) {
auto target = gameHandler.getTarget();
@ -2288,7 +2304,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
refocusChatInput = true;
}
const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput || io.WantCaptureKeyboard;
const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput;
// Tab targeting (when keyboard not captured by UI)
if (!io.WantCaptureKeyboard) {
@ -2319,21 +2335,17 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
if (!textFocus) {
// Toggle character screen (C) and inventory/bags (I)
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) {
const bool wasOpen = inventoryScreen.isCharacterOpen();
inventoryScreen.toggleCharacter();
if (!wasOpen && gameHandler.isConnected()) {
gameHandler.requestPlayedTime();
}
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) {
inventoryScreen.toggle();
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_BAGS)) {
if (inventoryScreen.isSeparateBags()) {
inventoryScreen.openAllBags();
} else {
inventoryScreen.toggle();
}
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) {
showNameplates_ = !showNameplates_;
}
@ -2350,10 +2362,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
showRaidFrames_ = !showRaidFrames_;
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_QUEST_LOG)) {
questLogScreen.toggle();
}
if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) {
showAchievementWindow_ = !showAchievementWindow_;
}
@ -13602,6 +13610,13 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) {
bool open = true;
if (ImGui::Begin("Trainer", &open)) {
// If user clicked window close, short-circuit before rendering large trainer tables.
if (!open) {
ImGui::End();
gameHandler.closeTrainer();
return;
}
const auto& trainer = gameHandler.getTrainerSpells();
// NPC name
@ -15881,27 +15896,6 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
return true;
};
// Player position marker — always drawn at minimap center with a directional arrow.
{
// The player is always at centerX, centerY on the minimap.
// Draw a yellow arrow pointing in the player's facing direction.
glm::vec3 fwd = camera->getForward();
float facing = std::atan2(fwd.y, -fwd.x); // clockwise bearing from North
float cosF = std::cos(facing - bearing);
float sinF = std::sin(facing - bearing);
float arrowLen = 8.0f;
float arrowW = 4.0f;
ImVec2 tip(centerX + sinF * arrowLen, centerY - cosF * arrowLen);
ImVec2 left(centerX - cosF * arrowW - sinF * arrowLen * 0.3f,
centerY - sinF * arrowW + cosF * arrowLen * 0.3f);
ImVec2 right(centerX + cosF * arrowW - sinF * arrowLen * 0.3f,
centerY + sinF * arrowW + cosF * arrowLen * 0.3f);
drawList->AddTriangleFilled(tip, left, right, IM_COL32(255, 220, 0, 255));
drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f);
// White dot at player center
drawList->AddCircleFilled(ImVec2(centerX, centerY), 2.5f, IM_COL32(255, 255, 255, 220));
}
// Build sets of entries that are incomplete objectives for tracked quests.
// minimapQuestEntries: NPC creature entries (npcOrGoId > 0)
// minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value)
@ -17737,7 +17731,7 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) {
}
ImGui::SameLine();
if (ImGui::SmallButton("Take")) {
gameHandler.mailTakeItem(mail.messageId, att.slot);
gameHandler.mailTakeItem(mail.messageId, att.itemGuidLow);
}
ImGui::PopID();
@ -17746,7 +17740,7 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) {
if (mail.attachments.size() > 1) {
if (ImGui::SmallButton("Take All")) {
for (const auto& att2 : mail.attachments) {
gameHandler.mailTakeItem(mail.messageId, att2.slot);
gameHandler.mailTakeItem(mail.messageId, att2.itemGuidLow);
}
}
}

View file

@ -743,17 +743,6 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
bool bToggled = bagsDown && !bKeyWasDown;
bKeyWasDown = bagsDown;
// Character screen toggle (C key, edge-triggered)
bool characterDown = KeybindingManager::getInstance().isActionPressed(
KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN, false);
if (characterDown && !cKeyWasDown) {
characterOpen = !characterOpen;
if (characterOpen && gameHandler_) {
gameHandler_->requestPlayedTime();
}
}
cKeyWasDown = characterDown;
bool wantsTextInput = ImGui::GetIO().WantTextInput;
if (separateBags_) {
@ -1028,7 +1017,11 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
int rows = (numSlots + columns - 1) / columns;
float contentH = rows * (slotSize + 4.0f) + 10.0f;
if (bagIndex < 0) contentH += 25.0f; // money display for backpack
if (bagIndex < 0) {
int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns;
contentH += 25.0f; // money display for backpack
contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots
}
float gridW = columns * (slotSize + 4.0f) + 30.0f;
// Ensure window is wide enough for the title + close button
const char* displayTitle = title;
@ -1076,6 +1069,23 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
ImGui::PopID();
}
if (bagIndex < 0) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring");
for (int i = 0; i < inventory.getKeyringSize(); ++i) {
if (i % columns != 0) ImGui::SameLine();
const auto& slot = inventory.getKeyringSlot(i);
char id[32];
snprintf(id, sizeof(id), "##skr_%d", i);
ImGui::PushID(id);
// Keyring is display-only for now.
renderItemSlot(inventory, slot, slotSize, nullptr,
SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS);
ImGui::PopID();
}
}
// Money display at bottom of backpack
if (bagIndex < 0 && moneyCopper > 0) {
ImGui::Spacing();
@ -2031,6 +2041,30 @@ void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool colla
ImGui::PopID();
}
}
bool keyringHasAnyItems = false;
for (int i = 0; i < inventory.getKeyringSize(); ++i) {
if (!inventory.getKeyringSlot(i).empty()) {
keyringHasAnyItems = true;
break;
}
}
if (!collapseEmptySections || keyringHasAnyItems) {
ImGui::Spacing();
ImGui::Separator();
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring");
for (int i = 0; i < inventory.getKeyringSize(); ++i) {
if (i % columns != 0) ImGui::SameLine();
const auto& slot = inventory.getKeyringSlot(i);
char sid[32];
snprintf(sid, sizeof(sid), "##keyring_%d", i);
ImGui::PushID(sid);
// Keyring is display-only for now.
renderItemSlot(inventory, slot, slotSize, nullptr,
SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS);
ImGui::PopID();
}
}
}
void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot,

View file

@ -35,7 +35,6 @@ void KeybindingManager::initializeDefaults() {
bindings_[static_cast<int>(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map
bindings_[static_cast<int>(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V;
bindings_[static_cast<int>(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset)
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_None; // Q conflicts with strafe-left; quest log accessible via TOGGLE_QUESTS (L)
bindings_[static_cast<int>(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail)
}
@ -47,9 +46,7 @@ bool KeybindingManager::isActionPressed(Action action, bool repeat) {
// When typing in a text field (e.g. chat input), never treat A-Z or 0-9 as shortcuts.
const ImGuiIO& io = ImGui::GetIO();
// Note: WantTextInput may not be set until the text widget is processed later in the
// frame, but WantCaptureKeyboard remains true while an ImGui widget is active.
if (io.WantTextInput || io.WantCaptureKeyboard) {
if (io.WantTextInput) {
if ((key >= ImGuiKey_A && key <= ImGuiKey_Z) ||
(key >= ImGuiKey_0 && key <= ImGuiKey_9)) {
return false;
@ -95,7 +92,6 @@ const char* KeybindingManager::getActionName(Action action) {
case Action::TOGGLE_WORLD_MAP: return "World Map";
case Action::TOGGLE_NAMEPLATES: return "Nameplates";
case Action::TOGGLE_RAID_FRAMES: return "Raid Frames";
case Action::TOGGLE_QUEST_LOG: return "Quest Log";
case Action::TOGGLE_ACHIEVEMENTS: return "Achievements";
case Action::ACTION_COUNT: break;
}
@ -160,7 +156,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) {
else if (action == "toggle_world_map") actionIdx = static_cast<int>(Action::TOGGLE_WORLD_MAP);
else if (action == "toggle_nameplates") actionIdx = static_cast<int>(Action::TOGGLE_NAMEPLATES);
else if (action == "toggle_raid_frames") actionIdx = static_cast<int>(Action::TOGGLE_RAID_FRAMES);
else if (action == "toggle_quest_log") actionIdx = static_cast<int>(Action::TOGGLE_QUEST_LOG);
else if (action == "toggle_quest_log") actionIdx = static_cast<int>(Action::TOGGLE_QUESTS); // legacy alias
else if (action == "toggle_achievements") actionIdx = static_cast<int>(Action::TOGGLE_ACHIEVEMENTS);
if (actionIdx < 0) continue;
@ -257,7 +253,6 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const {
{Action::TOGGLE_WORLD_MAP, "toggle_world_map"},
{Action::TOGGLE_NAMEPLATES, "toggle_nameplates"},
{Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"},
{Action::TOGGLE_QUEST_LOG, "toggle_quest_log"},
{Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"},
};