perf: eliminate per-frame heap allocs in M2 renderer; UI polish and report
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

M2 renderer: move 3 per-frame local containers to member variables:
- particleGroups_ (unordered_map): reuse bucket structure across frames
- ribbonDraws_ (vector): reuse draw call buffer
- shadowTexSetCache_ (unordered_map): reuse descriptor cache
Eliminates ~3 heap allocations per frame in particle/ribbon/shadow passes.

UI polish:
- Nameplate hover tooltip showing level, class (players), guild name
- Bag window titles show slot counts: "Backpack (12/16)"

Player report: CMSG_COMPLAIN packet builder and reportPlayer() method.
"Report Player" option in target frame right-click menu for other players.
Server response handler (SMSG_COMPLAIN_RESULT) was already implemented.
This commit is contained in:
Kelsi 2026-03-27 18:21:47 -07:00
parent dee90d2951
commit 2af3594ce8
8 changed files with 117 additions and 44 deletions

View file

@ -718,6 +718,7 @@ public:
// Combat and Trade // Combat and Trade
void proposeDuel(uint64_t targetGuid); void proposeDuel(uint64_t targetGuid);
void initiateTrade(uint64_t targetGuid); void initiateTrade(uint64_t targetGuid);
void reportPlayer(uint64_t targetGuid, const std::string& reason);
void stopCasting(); void stopCasting();
// ---- Phase 1: Name queries ---- // ---- Phase 1: Name queries ----

View file

@ -904,6 +904,12 @@ public:
static network::Packet build(uint64_t ignoreGuid); static network::Packet build(uint64_t ignoreGuid);
}; };
/** CMSG_COMPLAIN packet builder (player report) */
class ComplainPacket {
public:
static network::Packet build(uint64_t targetGuid, const std::string& reason);
};
// ============================================================ // ============================================================
// Logout Commands // Logout Commands
// ============================================================ // ============================================================

View file

@ -550,6 +550,49 @@ private:
}; };
std::vector<GlowSprite> glowSprites_; // Reused each frame std::vector<GlowSprite> glowSprites_; // Reused each frame
// Shadow-pass texture descriptor cache (reused each frame, cleared via pool reset)
std::unordered_map<VkImageView, VkDescriptorSet> shadowTexSetCache_;
// Ribbon draw-call list (reused each frame)
struct RibbonDrawCall {
VkDescriptorSet texSet;
VkPipeline pipeline;
uint32_t firstVertex;
uint32_t vertexCount;
};
std::vector<RibbonDrawCall> ribbonDraws_;
// Particle group structures (reused each frame)
struct ParticleGroupKey {
VkTexture* texture;
uint8_t blendType;
uint16_t tilesX;
uint16_t tilesY;
bool operator==(const ParticleGroupKey& other) const {
return texture == other.texture &&
blendType == other.blendType &&
tilesX == other.tilesX &&
tilesY == other.tilesY;
}
};
struct ParticleGroupKeyHash {
size_t operator()(const ParticleGroupKey& key) const {
size_t h1 = std::hash<uintptr_t>{}(reinterpret_cast<uintptr_t>(key.texture));
size_t h2 = std::hash<uint32_t>{}((static_cast<uint32_t>(key.tilesX) << 16) | key.tilesY);
size_t h3 = std::hash<uint8_t>{}(key.blendType);
return h1 ^ (h2 * 0x9e3779b9u) ^ (h3 * 0x85ebca6bu);
}
};
struct ParticleGroup {
VkTexture* texture;
uint8_t blendType;
uint16_t tilesX;
uint16_t tilesY;
VkDescriptorSet preAllocSet = VK_NULL_HANDLE;
std::vector<float> vertexData;
};
std::unordered_map<ParticleGroupKey, ParticleGroup, ParticleGroupKeyHash> particleGroups_;
// Animation update buffers (avoid per-frame allocation) // Animation update buffers (avoid per-frame allocation)
std::vector<size_t> boneWorkIndices_; // Reused each frame std::vector<size_t> boneWorkIndices_; // Reused each frame
std::vector<std::future<void>> animFutures_; // Reused each frame std::vector<std::future<void>> animFutures_; // Reused each frame

View file

@ -13784,6 +13784,23 @@ void GameHandler::initiateTrade(uint64_t targetGuid) {
LOG_INFO("Initiated trade with target: 0x", std::hex, targetGuid, std::dec); LOG_INFO("Initiated trade with target: 0x", std::hex, targetGuid, std::dec);
} }
void GameHandler::reportPlayer(uint64_t targetGuid, const std::string& reason) {
if (!isInWorld()) {
LOG_WARNING("Cannot report player: not in world or not connected");
return;
}
if (targetGuid == 0) {
addSystemChatMessage("You must target a player to report.");
return;
}
auto packet = ComplainPacket::build(targetGuid, reason);
socket->send(packet);
addSystemChatMessage("Player report submitted.");
LOG_INFO("Reported player: 0x", std::hex, targetGuid, std::dec, " reason=", reason);
}
void GameHandler::stopCasting() { void GameHandler::stopCasting() {
if (!isInWorld()) { if (!isInWorld()) {
LOG_WARNING("Cannot stop casting: not in world or not connected"); LOG_WARNING("Cannot stop casting: not in world or not connected");

View file

@ -1908,6 +1908,19 @@ network::Packet DelIgnorePacket::build(uint64_t ignoreGuid) {
return packet; return packet;
} }
network::Packet ComplainPacket::build(uint64_t targetGuid, const std::string& reason) {
network::Packet packet(wireOpcode(Opcode::CMSG_COMPLAIN));
packet.writeUInt8(1); // complaintType: 1 = spam
packet.writeUInt64(targetGuid);
packet.writeUInt32(0); // unk
packet.writeUInt32(0); // messageType
packet.writeUInt32(0); // channelId
packet.writeUInt32(static_cast<uint32_t>(time(nullptr))); // timestamp
packet.writeString(reason);
LOG_DEBUG("Built CMSG_COMPLAIN: target=0x", std::hex, targetGuid, std::dec, " reason=", reason);
return packet;
}
// ============================================================ // ============================================================
// Logout Commands // Logout Commands
// ============================================================ // ============================================================

View file

@ -2998,7 +2998,9 @@ void M2Renderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMa
vkResetDescriptorPool(vkCtx_->getDevice(), shadowTexPool_, 0); vkResetDescriptorPool(vkCtx_->getDevice(), shadowTexPool_, 0);
} }
// Cache: texture imageView -> allocated descriptor set (avoids duplicates within frame) // Cache: texture imageView -> allocated descriptor set (avoids duplicates within frame)
std::unordered_map<VkImageView, VkDescriptorSet> texSetCache; // Reuse persistent map — pool reset already invalidated the sets.
shadowTexSetCache_.clear();
auto& texSetCache = shadowTexSetCache_;
auto getTexDescSet = [&](VkTexture* tex) -> VkDescriptorSet { auto getTexDescSet = [&](VkTexture* tex) -> VkDescriptorSet {
VkImageView iv = tex->getImageView(); VkImageView iv = tex->getImageView();
@ -3425,13 +3427,8 @@ void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
float* dst = static_cast<float*>(ribbonVBMapped_); float* dst = static_cast<float*>(ribbonVBMapped_);
size_t written = 0; size_t written = 0;
struct DrawCall { ribbonDraws_.clear();
VkDescriptorSet texSet; auto& draws = ribbonDraws_;
VkPipeline pipeline;
uint32_t firstVertex;
uint32_t vertexCount;
};
std::vector<DrawCall> draws;
for (const auto& inst : instances) { for (const auto& inst : instances) {
if (!inst.cachedModel) continue; if (!inst.cachedModel) continue;
@ -3539,36 +3536,12 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame
if (!particlePipeline_ || !m2ParticleVB_) return; if (!particlePipeline_ || !m2ParticleVB_) return;
// Collect all particles from all instances, grouped by texture+blend // Collect all particles from all instances, grouped by texture+blend
struct ParticleGroupKey { // Reuse persistent map — clear each group's vertex data but keep bucket structure.
VkTexture* texture; for (auto& [k, g] : particleGroups_) {
uint8_t blendType; g.vertexData.clear();
uint16_t tilesX; g.preAllocSet = VK_NULL_HANDLE;
uint16_t tilesY;
bool operator==(const ParticleGroupKey& other) const {
return texture == other.texture &&
blendType == other.blendType &&
tilesX == other.tilesX &&
tilesY == other.tilesY;
} }
}; auto& groups = particleGroups_;
struct ParticleGroupKeyHash {
size_t operator()(const ParticleGroupKey& key) const {
size_t h1 = std::hash<uintptr_t>{}(reinterpret_cast<uintptr_t>(key.texture));
size_t h2 = std::hash<uint32_t>{}((static_cast<uint32_t>(key.tilesX) << 16) | key.tilesY);
size_t h3 = std::hash<uint8_t>{}(key.blendType);
return h1 ^ (h2 * 0x9e3779b9u) ^ (h3 * 0x85ebca6bu);
}
};
struct ParticleGroup {
VkTexture* texture;
uint8_t blendType;
uint16_t tilesX;
uint16_t tilesY;
VkDescriptorSet preAllocSet = VK_NULL_HANDLE; // Pre-allocated stable set, avoids per-frame alloc
std::vector<float> vertexData; // 9 floats per particle
};
std::unordered_map<ParticleGroupKey, ParticleGroup, ParticleGroupKeyHash> groups;
size_t totalParticles = 0; size_t totalParticles = 0;

View file

@ -4271,6 +4271,8 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
gameHandler.addFriend(name); gameHandler.addFriend(name);
if (ImGui::MenuItem("Ignore")) if (ImGui::MenuItem("Ignore"))
gameHandler.addIgnore(name); gameHandler.addIgnore(name);
if (ImGui::MenuItem("Report Player"))
gameHandler.reportPlayer(tGuid, "Reported via UI");
} }
ImGui::Separator(); ImGui::Separator();
if (ImGui::BeginMenu("Set Raid Mark")) { if (ImGui::BeginMenu("Set Raid Mark")) {
@ -12181,6 +12183,17 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) {
if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) {
// Track mouseover for [target=mouseover] macro conditionals // Track mouseover for [target=mouseover] macro conditionals
gameHandler.setMouseoverGuid(guid); gameHandler.setMouseoverGuid(guid);
// Hover tooltip: name, level/class, guild
ImGui::BeginTooltip();
ImGui::TextUnformatted(unitName.c_str());
if (isPlayer) {
uint8_t cid = entityClassId(unit);
ImGui::Text("Level %u %s", level, classNameStr(cid));
} else if (level > 0) {
ImGui::Text("Level %u", level);
}
if (!subLabel.empty()) ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", subLabel.c_str());
ImGui::EndTooltip();
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
gameHandler.setTarget(guid); gameHandler.setTarget(guid);
} else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {

View file

@ -985,10 +985,15 @@ void InventoryScreen::renderSeparateBags(game::Inventory& inventory, uint64_t mo
// Backpack window (bottom of stack) // Backpack window (bottom of stack)
if (backpackOpen_) { if (backpackOpen_) {
int bpRows = (inventory.getBackpackSize() + columns - 1) / columns; int bpTotal = inventory.getBackpackSize();
int bpUsed = 0;
for (int i = 0; i < bpTotal; ++i) if (!inventory.getBackpackSlot(i).empty()) ++bpUsed;
char bpTitle[64];
snprintf(bpTitle, sizeof(bpTitle), "Backpack (%d/%d)", bpUsed, bpTotal);
int bpRows = (bpTotal + columns - 1) / columns;
float bpH = bpRows * (slotSize + 4.0f) + 80.0f; // header + money + padding float bpH = bpRows * (slotSize + 4.0f) + 80.0f; // header + money + padding
float defaultY = stackBottom - bpH; float defaultY = stackBottom - bpH;
renderBagWindow("Backpack", backpackOpen_, inventory, -1, stackX, defaultY, moneyCopper); renderBagWindow(bpTitle, backpackOpen_, inventory, -1, stackX, defaultY, moneyCopper);
stackBottom = defaultY - stackGap; stackBottom = defaultY - stackGap;
} }
@ -1010,14 +1015,16 @@ void InventoryScreen::renderSeparateBags(game::Inventory& inventory, uint64_t mo
float defaultY = stackBottom - bagH; float defaultY = stackBottom - bagH;
stackBottom = defaultY - stackGap; stackBottom = defaultY - stackGap;
// Build title from equipped bag item name // Build title from equipped bag item name, with used/total slot counts
char title[64]; int bagUsed = 0;
for (int si = 0; si < bagSize; ++si) if (!inventory.getBagSlot(bag, si).empty()) ++bagUsed;
char title[96];
game::EquipSlot bagSlot = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + bag); game::EquipSlot bagSlot = static_cast<game::EquipSlot>(static_cast<int>(game::EquipSlot::BAG1) + bag);
const auto& bagItem = inventory.getEquipSlot(bagSlot); const auto& bagItem = inventory.getEquipSlot(bagSlot);
if (!bagItem.empty() && !bagItem.item.name.empty()) { if (!bagItem.empty() && !bagItem.item.name.empty()) {
snprintf(title, sizeof(title), "%s##bag%d", bagItem.item.name.c_str(), bag); snprintf(title, sizeof(title), "%s (%d/%d)##bag%d", bagItem.item.name.c_str(), bagUsed, bagSize, bag);
} else { } else {
snprintf(title, sizeof(title), "Bag Slot %d##bag%d", bag + 1, bag); snprintf(title, sizeof(title), "Bag Slot %d (%d/%d)##bag%d", bag + 1, bagUsed, bagSize, bag);
} }
renderBagWindow(title, bagOpen_[bag], inventory, bag, stackX, defaultY, 0); renderBagWindow(title, bagOpen_[bag], inventory, bag, stackX, defaultY, 0);