fix(loot): skip corpses that stay lootable after LootAllCorpses (#133)

* fix(loot): skip corpses that stay lootable after LootAllCorpses

A corpse whose remaining loot this player can never take keeps
UNIT_DYNFLAG_LOOTABLE set: quest items for quests they don't have,
condition-gated loot, and the like. The per-corpse re-check still
passes, so a caller that runs C_Loot.LootAllCorpses from a timer
re-opens the same corpse on every walk and re-sends the refused
CMSG_LOOT_ITEM, which never clears.

Loot mode now remembers every corpse it opened and released. Later
walks skip a remembered corpse while it is still visible and
lootable; the entry is dropped once the corpse stops being lootable or
leaves the object manager. A corpse that merely left render range is
dropped too, so it gets one retry when the player comes back.
ScanNearbyLoot stays read-only and keeps reporting the corpse.

1.12 has no per-slot "cannot loot" flag (GetLootSlotInfo has no
`locked` return), so one refused request per corpse is unavoidable;
this only stops it from repeating on every walk.

* docs(loot): describe the LootAllCorpses corpse skip

Explain why a corpse that stays lootable after the walk is skipped by
later walks, and when the skip entry is dropped.

* fix(loot): skip only corpses that open empty in LootAllCorpses

The previous change remembered every corpse the loot walk opened. The
most common reason a corpse stays UNIT_DYNFLAG_LOOTABLE is full bags,
so those corpses were never looted again by the walk, even after the
player freed bag space. Rolls and master-loot assignments in progress
had the same problem.

The server never sends a player a loot slot that player cannot take:
another member's quest item and condition-gated loot are left out of
SMSG_LOOT_RESPONSE, while the corpse stays lootable for as long as any
loot remains. So the stuck corpse opens to an EMPTY window, with no
coin and no populated VAR_LOOT_SLOTS entry. Loot mode now remembers a
corpse only when the scraped window is empty. A window with anything
in it (full bags, a roll, a master-loot slot) is retried.

An entry is forgotten:
- on the first world tick where its unit is gone or no longer
  lootable. This replaces the prune at walk start, which missed a
  corpse that left view and came back, and a creature that respawned
  with the same GUID and died again, between two walks;
- all at once on QUEST_LOG_UPDATE (event 0x134, observed through the
  shared FUN_FIRE_EVENT_NO_ARGS interceptor and never swallowed),
  because quest items are per-player loot and a quest change can put
  an item into a window that was empty.

The memory moves out of the per-walk ScanCtx into its own vector. The
API reference now describes the skip as a contract only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
This commit is contained in:
Mrbcat
2026-11-06 23:25:23 -06:00
committed by GitHub
co-authored by Brues
parent 20069f3815
commit dc8d4caf12
2 changed files with 75 additions and 4 deletions
+14
View File
@@ -15702,6 +15702,20 @@ suppressed so `LootFrame` never flickers open per corpse. The normal
item-received and money events still fire (`CHAT_MSG_LOOT`,
`CHAT_MSG_MONEY`, inventory updates), so the player sees what they got.
Later walks skip a corpse that opened with nothing for you but stays
lootable. This occurs when the remaining loot is for other players, for
example a quest item for a different group member. The walk opens a
skipped corpse again when one of these occurs:
- The corpse leaves view and comes back.
- The creature respawns and dies again.
- Your quest log changes.
Later walks try again a corpse that still shows loot, for example when
your bags are full, or when a roll or a master-loot assignment is in
progress. Skipped corpses are not in
[`GetLastScanResults()`](#c_lootgetlastscanresults).
```lua
-- Loot everything within interact range
C_Loot.LootAllCorpses()
+61 -4
View File
@@ -34,6 +34,15 @@
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
// per-tick `VAR_LOOT_GUID` reads, because the controller hook *is* the
// state transition.
//
// Loot mode remembers corpses whose window opened EMPTY but which stay
// LOOTABLE: the server keeps the flag while any loot is left (another
// member's quest item, condition-gated loot) yet never sends this player a
// slot it can't take, so an empty window is the server's own "nothing here
// for you". Later walks skip such a corpse. A corpse whose window held
// anything (bags full, a roll or master-loot assignment in progress) is not
// remembered and is retried, because those refusals clear on their own.
// See `s_emptyCorpses` for when an entry is forgotten.
#include "../Game.h"
#include "../Offsets.h"
@@ -45,6 +54,7 @@
#include "../time/Clock.h"
#include "Request.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -68,6 +78,8 @@ constexpr uint32_t kStepTimeoutMs = 3000;
// fire unmodified.
constexpr int EVENT_LOOT_OPENED = 0x10B;
constexpr int EVENT_LOOT_CLOSED = 0x10D;
// Observed, never swallowed (see `s_emptyCorpses`).
constexpr int EVENT_QUEST_LOG_UPDATE = 0x134;
enum class State {
Idle,
@@ -100,6 +112,21 @@ struct ScanCtx {
ScanCtx s_scan;
// Loot-mode corpses whose window opened empty while the unit stayed
// LOOTABLE (see the file comment). Outlives a walk. An entry is forgotten:
// - on the first world tick where its unit is gone (despawned, out of
// view) or no longer LOOTABLE (alive again after a respawn
// with the same GUID, or emptied by someone else), so a corpse back in
// view or a fresh corpse is opened again;
// - all at once on QUEST_LOG_UPDATE: quest items are per-player loot, so
// a quest change can turn an empty window into one with an item in it.
std::vector<uint64_t> s_emptyCorpses;
bool IsKnownEmpty(uint64_t guid) {
return std::find(s_emptyCorpses.begin(), s_emptyCorpses.end(), guid) !=
s_emptyCorpses.end();
}
// === Typedefs for engine helpers ===
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
@@ -130,6 +157,19 @@ using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
// Run every world tick, so no gone-and-back or respawn-and-die-again
// can happen between two checks.
void PruneEmptyCorpses() {
s_emptyCorpses.erase(
std::remove_if(s_emptyCorpses.begin(), s_emptyCorpses.end(),
[](uint64_t guid) {
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT,
guid, nullptr, 0);
return unit == nullptr || !Request::IsLootable(unit);
}),
s_emptyCorpses.end());
}
// === Scrape ===
// Reads the post-population `VAR_LOOT_SLOTS` table into a LootEntry.
@@ -325,6 +365,10 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
LootEntry entry;
entry.guid = s_scan.currentGuid;
ScrapeCurrentLoot(&entry);
// Each corpse is queued once per walk and known-empty ones are not
// queued in loot mode, so this never adds a duplicate.
if (s_scan.lootMode && entry.coin == 0 && entry.items.empty())
s_emptyCorpses.push_back(entry.guid);
s_scan.results.push_back(std::move(entry));
// Loot mode: actually take everything (coin + all slots) before the
@@ -355,9 +399,19 @@ const Game::HookAutoRegister _hookController{
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (refusals and timeouts) ===
// Observes only: the event still fires.
bool ForgetEmptyCorpsesOnQuestChange(int eventID) {
if (eventID == EVENT_QUEST_LOG_UPDATE)
s_emptyCorpses.clear();
return false;
}
const Event::SignalHook::AutoSubscribe _questIntercept{&ForgetEmptyCorpsesOnQuestChange};
// === Tick (memory upkeep, refusals and timeouts) ===
void OnTick() {
PruneEmptyCorpses();
if (s_scan.state == State::Idle)
return;
const bool timedOut = Time::Clock::Elapsed(s_scan.stepStartMs) >= kStepTimeoutMs;
@@ -396,6 +450,7 @@ const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
struct EnumCtx {
void *player;
std::vector<uint64_t> *out;
bool skipKnownEmpty;
};
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
@@ -404,17 +459,19 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
return 1;
if (!Request::IsLootable(obj))
return 1;
if (ctx->skipKnownEmpty && IsKnownEmpty(guid))
return 1;
if (!Request::InReach(ctx->player, obj))
return 1;
ctx->out->push_back(guid);
return 1;
}
void BuildQueue(void *player, size_t maxCount) {
void BuildQueue(void *player, size_t maxCount, bool skipKnownEmpty) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
EnumCtx ctx{player, &s_scan.queue, skipKnownEmpty};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
@@ -440,7 +497,7 @@ bool BeginWalk(bool lootMode, size_t maxCount) {
s_scan.results.clear();
s_scan.player = player;
s_scan.lootMode = lootMode;
BuildQueue(player, maxCount);
BuildQueue(player, maxCount, /*skipKnownEmpty=*/lootMode);
if (s_scan.queue.empty()) {
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately