fix: animation stutter, resolution crash, memory cap, spell tooltip hints, GO collision

- Animation stutter: skip playAnimation(Run) for the local player in the
  server movement callback — the player renderer state machine already manages
  it; resetting animTime on every movement packet caused visible stutter
- Resolution crash: reorder swapchain recreation so old swapchain is only
  destroyed after confirming the new build succeeded; add null-swapchain
  guard in beginFrame to survive the retry window
- Memory cap: reduce cache budget from 80% uncapped to 50% hard-capped at
  16 GB to prevent excessive RAM use on high-memory systems
- Spell tooltip: suppress "Drag to action bar / Double-click to cast" hints
  when the tooltip is shown from the action bar (showUsageHints=false)
- M2 collision: add watermelon/melon/squash/gourd to foliage (no-collision);
  exclude chair/bench/stool/seat/throne from smallSolidProp so invisible chair
  bounding boxes no longer trap the player
This commit is contained in:
Kelsi 2026-03-10 22:26:50 -07:00
parent 8f2974b17c
commit 19eb7a1fb7
7 changed files with 60 additions and 30 deletions

View file

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

View file

@ -2553,13 +2553,19 @@ void Application::setupUICallbacks() {
// Don't override Death animation (1). The per-frame sync loop will return to // Don't override Death animation (1). The per-frame sync loop will return to
// Stand when movement stops. // Stand when movement stops.
if (durationMs > 0) { if (durationMs > 0) {
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; // Player animation is managed by the local renderer state machine —
auto* cr = renderer->getCharacterRenderer(); // don't reset it here or every server movement packet restarts the
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); // run cycle from frame 0, causing visible stutter.
if (!gotState || curAnimId != 1 /*Death*/) { if (!isPlayer) {
cr->playAnimation(instanceId, 5u, /*loop=*/true); uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
auto* cr = renderer->getCharacterRenderer();
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur);
// Only start Run if not already running and not in Death animation.
if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) {
cr->playAnimation(instanceId, 5u, /*loop=*/true);
}
creatureWasMoving_[guid] = true;
} }
if (!isPlayer) creatureWasMoving_[guid] = true;
} }
} }
}); });
@ -8701,17 +8707,21 @@ void Application::updateQuestMarkers() {
int markerType = -1; // -1 = no marker int markerType = -1; // -1 = no marker
using game::QuestGiverStatus; using game::QuestGiverStatus;
float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests)
switch (status) { switch (status) {
case QuestGiverStatus::AVAILABLE: case QuestGiverStatus::AVAILABLE:
markerType = 0; // Yellow !
break;
case QuestGiverStatus::AVAILABLE_LOW: case QuestGiverStatus::AVAILABLE_LOW:
markerType = 0; // Available (yellow !) markerType = 0; // Grey ! (same texture, desaturated in shader)
markerGrayscale = 1.0f;
break; break;
case QuestGiverStatus::REWARD: case QuestGiverStatus::REWARD:
case QuestGiverStatus::REWARD_REP: case QuestGiverStatus::REWARD_REP:
markerType = 1; // Turn-in (yellow ?) markerType = 1; // Yellow ?
break; break;
case QuestGiverStatus::INCOMPLETE: case QuestGiverStatus::INCOMPLETE:
markerType = 2; // Incomplete (grey ?) markerType = 2; // Grey ?
break; break;
default: default:
break; break;
@ -8745,7 +8755,7 @@ void Application::updateQuestMarkers() {
} }
// Set the marker (renderer will handle positioning, bob, glow, etc.) // Set the marker (renderer will handle positioning, bob, glow, etc.)
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight); questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale);
markersAdded++; markersAdded++;
} }

View file

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

View file

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

View file

@ -979,8 +979,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(lowerName.find("monument") != std::string::npos) || (lowerName.find("monument") != std::string::npos) ||
(lowerName.find("sculpture") != std::string::npos); (lowerName.find("sculpture") != std::string::npos);
gpuModel.collisionStatue = statueName; gpuModel.collisionStatue = statueName;
// Sittable furniture: chairs/benches/stools cause players to get stuck against
// invisible bounding boxes; WMOs already handle room collision.
bool sittableFurnitureName =
(lowerName.find("chair") != std::string::npos) ||
(lowerName.find("bench") != std::string::npos) ||
(lowerName.find("stool") != std::string::npos) ||
(lowerName.find("seat") != std::string::npos) ||
(lowerName.find("throne") != std::string::npos);
bool smallSolidPropName = bool smallSolidPropName =
statueName || (statueName && !sittableFurnitureName) ||
(lowerName.find("crate") != std::string::npos) || (lowerName.find("crate") != std::string::npos) ||
(lowerName.find("box") != std::string::npos) || (lowerName.find("box") != std::string::npos) ||
(lowerName.find("chest") != std::string::npos) || (lowerName.find("chest") != std::string::npos) ||
@ -1023,6 +1031,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(lowerName.find("bamboo") != std::string::npos) || (lowerName.find("bamboo") != std::string::npos) ||
(lowerName.find("banana") != std::string::npos) || (lowerName.find("banana") != std::string::npos) ||
(lowerName.find("coconut") != std::string::npos) || (lowerName.find("coconut") != std::string::npos) ||
(lowerName.find("watermelon") != std::string::npos) ||
(lowerName.find("melon") != std::string::npos) ||
(lowerName.find("squash") != std::string::npos) ||
(lowerName.find("gourd") != std::string::npos) ||
(lowerName.find("canopy") != std::string::npos) || (lowerName.find("canopy") != std::string::npos) ||
(lowerName.find("hedge") != std::string::npos) || (lowerName.find("hedge") != std::string::npos) ||
(lowerName.find("cactus") != std::string::npos) || (lowerName.find("cactus") != std::string::npos) ||

View file

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

View file

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