mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-10 20:07:37 +00:00
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:
+14
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user