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);
const SpellInfo* getSpellInfo(uint32_t spellId) const;
// Tooltip rendering helper
void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler);
// Tooltip rendering helper (showUsageHints=false when called from action bar)
void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints = true);
};
} // namespace ui

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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