mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-14 23:20:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555ea02447 | ||
|
|
e3f5318747 | ||
|
|
cd674aa115 | ||
|
|
f91ffd3189 | ||
|
|
8361ae01eb | ||
|
|
5d785ea0d3 | ||
|
|
e84673dde5 | ||
|
|
a2c5db7b76 |
@@ -45,7 +45,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
|
||||
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
|
||||
@@ -66,7 +66,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
|
||||
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
|
||||
+84
-3
@@ -294,6 +294,7 @@ build instructions.
|
||||
- [`C_Loot.LootUnit(guid)`](#c_lootlootunitguid)
|
||||
- [`C_Loot.LootUnitItem(guid, itemID)`](#c_lootlootunititemguid-itemid)
|
||||
- [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
|
||||
- [`C_Loot.LootAllCorpses([max])`](#c_lootlootallcorpsesmax)
|
||||
- [`C_Loot.IsScanInProgress()`](#c_lootisscaninprogress)
|
||||
- [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults)
|
||||
|
||||
@@ -494,6 +495,7 @@ build instructions.
|
||||
|
||||
- [Time](#time)
|
||||
- [`GetServerTime()`](#getservertime)
|
||||
- [`GetTimeCached()`](#gettimecached)
|
||||
- [`C_Timer.After(seconds, callback)`](#c_timeraftersseconds-callback)
|
||||
- [`C_Timer.NewTimer(seconds, callback)`](#c_timernewtimerseconds-callback)
|
||||
- [`C_Timer.NewTicker(seconds, callback, [iterations])`](#c_timernewtickerseconds-callback-iterations)
|
||||
@@ -2538,9 +2540,15 @@ it: vanilla renders durability UI for equipped gear only.
|
||||
### `HEARTHSTONE_BOUND` event
|
||||
|
||||
Fires (with no payload) every time the player binds their
|
||||
hearthstone at an innkeeper. Polyfills modern WoW's event of the
|
||||
same name — addons listen to `HEARTHSTONE_BOUND` and re-read
|
||||
`GetBindLocation()` to refresh whatever bind-location UI they show.
|
||||
hearthstone at an innkeeper — including a rebind at the same inn.
|
||||
Polyfills modern WoW's event of the same name — addons listen to
|
||||
`HEARTHSTONE_BOUND` and re-read `GetBindLocation()` to refresh
|
||||
whatever bind-location UI they show.
|
||||
|
||||
> Note: the server re-sends the bind packet on map/zone transitions
|
||||
> (behind the loading screen). Those resyncs are suppressed by gating
|
||||
> on the engine's in-world flag, so the event fires only for an actual
|
||||
> innkeeper bind, never on a map change.
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
@@ -7163,6 +7171,45 @@ Per-corpse step timeout is ~3-6 seconds (180 WorldTick frames) so a
|
||||
single dropped server response doesn't hang the whole scan. Failed
|
||||
corpses simply don't appear in the results.
|
||||
|
||||
### `C_Loot.LootAllCorpses([max])`
|
||||
|
||||
Loots **every** nearby lootable corpse in sequence — the same corpse
|
||||
walk as [`ScanNearbyLoot`](#c_lootscannearbyloot), but it actually takes
|
||||
the loot: coin (`CMSG_LOOT_MONEY`) plus every item slot (`CMSG_LOOT_ITEM`)
|
||||
from each window before releasing and advancing to the next corpse. The
|
||||
optional `max` caps how many corpses are visited (default: all in range).
|
||||
|
||||
Returns `true` if the loot walk was queued, `false` if a walk (this or
|
||||
`ScanNearbyLoot`) is already in progress or the world isn't loaded — the
|
||||
two share one state machine, so [`IsScanInProgress`](#c_lootisscaninprogress)
|
||||
reports either, and only one runs at a time.
|
||||
|
||||
Like the scan, the walk is **silent**: `LOOT_OPENED` / `LOOT_CLOSED` are
|
||||
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.
|
||||
|
||||
```lua
|
||||
-- Loot everything within interact range
|
||||
C_Loot.LootAllCorpses()
|
||||
|
||||
-- Loot at most 5 corpses
|
||||
C_Loot.LootAllCorpses(5)
|
||||
```
|
||||
|
||||
Completion is signaled by [`LOOT_SCAN_COMPLETED`](#loot_scan_completed-event);
|
||||
afterward [`GetLastScanResults()`](#c_lootgetlastscanresults) reports what
|
||||
each corpse held — i.e. what was looted.
|
||||
|
||||
> **Range and permissions.** Only corpses inside the engine's own
|
||||
> right-click-loot range are queued (same distance test as
|
||||
> `GetNearbyLootableUnits`), and only those the server flagged lootable
|
||||
> for you. The item sends bypass the client-side BoP/unique **confirm
|
||||
> dialog** (appropriate for a programmatic loot-all), but the server still
|
||||
> enforces every real rule — loot rights, distance, master-loot. Unlike a
|
||||
> right-click, this does **not** change your target or auto-walk you toward
|
||||
> out-of-range corpses.
|
||||
|
||||
### `C_Loot.IsScanInProgress()`
|
||||
|
||||
Returns `true` while a `ScanNearbyLoot` call is mid-flight, `false`
|
||||
@@ -11860,6 +11907,40 @@ right call for calendar / log-timestamp / cooldown-sync use cases.
|
||||
> boundary and the timestamp is accurate to within a second of the
|
||||
> engine's clock for as long as the session continues.
|
||||
|
||||
### `GetTimeCached()`
|
||||
|
||||
Returns the same value as [`GetTime()`](https://warcraft.wiki.gg/wiki/API_GetTime)
|
||||
— seconds on the engine's uptime clock — but **frame-stable**: every call
|
||||
within a single frame returns the identical value, refreshed once per
|
||||
frame. Same epoch as `GetTime()`, so the two are directly comparable.
|
||||
|
||||
Backports the retail 4.x `GetTime()` behavior (retail samples the clock
|
||||
once per frame in the main loop) under a distinct name, leaving vanilla's
|
||||
`GetTime()` untouched — 1.12's `GetTime()` is *live*, recomputing the OS
|
||||
tick on every call, so two same-frame `GetTime()` calls can differ.
|
||||
|
||||
```lua
|
||||
-- Frame-stable: consistent timestamps across a frame's work
|
||||
local now = GetTimeCached()
|
||||
if now - lastFire >= interval then
|
||||
lastFire = now
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
> **Not a performance feature.** The underlying tick source is cheap in
|
||||
> 1.12 (`GetTickCount`, a user-mode shared-page read, or `rdtsc`), and
|
||||
> both functions pay the same Lua→C call overhead, which dominates. So
|
||||
> `GetTimeCached()` is not meaningfully faster than `GetTime()` — its
|
||||
> only advantage is the frame-stable semantics. To actually cut cost in a
|
||||
> hot `OnUpdate`, cache the value in a Lua local
|
||||
> (`local now = GetTime()`) and reuse it; that removes the per-call Lua
|
||||
> dispatch, which a cached C function cannot.
|
||||
|
||||
The cache is refreshed from the shared once-per-frame `Tick::WorldTick`
|
||||
hook; before the first world tick (pre-login / glue) it falls back to a
|
||||
live sample, so it never returns 0.
|
||||
|
||||
### `C_Timer.After(seconds, callback)`
|
||||
|
||||
Schedules `callback` to fire once after `seconds`. Returns nothing.
|
||||
|
||||
+35
-7
@@ -3825,6 +3825,19 @@ enum Offsets {
|
||||
// `Script_IsInInstance`).
|
||||
VAR_CURRENT_MAP_ID = 0x00B4E378,
|
||||
|
||||
// "World is live" flag — 1 while the player is fully in the world, 0 during
|
||||
// any loading screen (initial login, zone/instance transition, logout to
|
||||
// glue). Set to 1 by the enter-world init `FUN_004908C0` (guarded
|
||||
// `if (==0){=1;...}`) and back to 0 by the world-teardown `FUN_00490A80`
|
||||
// (`if(!=0){=0;...}`) that runs at the start of every transition. Byte flag.
|
||||
// The clean pollable gate for "can this happen in the world right now" —
|
||||
// e.g. suppressing server resync packets that arrive behind a loading
|
||||
// screen. Verified via the enter/leave pair in Ghidra.
|
||||
//
|
||||
// (NOTE: 0x00B4E37C is NOT this flag — an earlier note wrongly claimed it
|
||||
// was. That address is instance grace-period state, read only by the
|
||||
// "recently left an instance" 90-second check in `FUN_00495c90`.)
|
||||
VAR_IN_WORLD = 0x00B4B424,
|
||||
|
||||
// Local player's current AreaTable.dbc area ID (u32 storage; only
|
||||
// the low 16 bits are used — the value is broadcast over the wire
|
||||
@@ -4875,18 +4888,19 @@ enum Offsets {
|
||||
//
|
||||
// Single caller (the packet dispatcher's jump table for opcode
|
||||
// 0x155) — quiet hook target, no contention with other DLLs.
|
||||
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`
|
||||
// every time the user re-binds at an innkeeper. Gating off the
|
||||
// bind-valid flag's transition distinguishes "initial sync" (the
|
||||
// one we suppress) from "user rebound at an inn" (the one we
|
||||
// fire on — even when the area string stays the same, since the
|
||||
// bind ACTION is what fires the event).
|
||||
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`.
|
||||
// Two packets must be suppressed so the event reflects only a real
|
||||
// innkeeper bind: (1) the initial post-login/char-switch sync
|
||||
// (gated on VAR_BIND_POINT_VALID being 0 before the handler runs),
|
||||
// and (2) Turtle's per-map-transition resync, which re-sends the
|
||||
// packet behind a loading screen — gated on VAR_IN_WORLD (the
|
||||
// player can't bind mid-load). See `Player::HearthstoneBound`.
|
||||
FUN_BINDPOINT_UPDATE_HANDLER = 0x005ED3C0,
|
||||
// "Bind point is valid" flag. Set to 1 by the BINDPOINTUPDATE
|
||||
// handler after the packet has been fully parsed. Zeroed by
|
||||
// `FUN_005E2510` (the per-character-entry init routine).
|
||||
// Reading this BEFORE the handler runs distinguishes initial
|
||||
// sync (`== 0`) from a real re-bind (`== 1`).
|
||||
// sync (`== 0`) from a later packet (`== 1`).
|
||||
VAR_BIND_POINT_VALID = 0x00C4D4E0,
|
||||
|
||||
// SMSG_QUESTGIVER_QUEST_COMPLETE handler (opcode 0x191). Server
|
||||
@@ -5100,6 +5114,20 @@ enum Offsets {
|
||||
// delayed crashes on unrelated vtable reads.
|
||||
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
|
||||
|
||||
// `__fastcall(player)` — sends `CMSG_LOOT_MONEY` (the coin/money
|
||||
// request) for the player's currently-open loot window. Vanilla has
|
||||
// no `LootMoney` Lua global; coin is virtual loot slot 0 and the Lua
|
||||
// `LootSlot` handler `FUN_004C2790` routes the coin slot here (its
|
||||
// `param_1 == 0 && VAR_LOOT_LOOTABLE != 0` branch calls this with the
|
||||
// resolved local player). Guards internally on the player's active
|
||||
// loot GUID at `[player + 0x1D28]` — which `FUN_CMSG_LOOT_UNIT` writes
|
||||
// when it sends the loot request — so it silently no-ops if no window
|
||||
// is open. Pass the SAME player pointer used for `FUN_CMSG_LOOT_UNIT`
|
||||
// so the `+0x1D28` guard it just wrote is the one read here.
|
||||
// `Loot::Scan`'s `LootAllCorpses` path uses it to grab coin alongside
|
||||
// the per-slot `CMSG_LOOT_ITEM` sends.
|
||||
FUN_CMSG_LOOT_MONEY = 0x005EA8C0,
|
||||
|
||||
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
|
||||
// server-driven dynamic-state bitmask. Empirically verified by
|
||||
// cross-referencing three `Script_*` accessors that all read
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Bag::UpdateDelayed {
|
||||
|
||||
namespace {
|
||||
@@ -127,6 +129,19 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
|
||||
void OnWorldTick() {
|
||||
if (!g_pending)
|
||||
return;
|
||||
// Bag changes aren't meaningful until the player is actually in the world.
|
||||
// During any loading screen (initial login, zone/instance transition) the
|
||||
// engine re-populates bag slots as objects stream in (setting g_pending)
|
||||
// while still behind the screen; the per-frame world tick runs there too,
|
||||
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
|
||||
// mid-load for no real change. Hold the pending flag — do NOT clear it —
|
||||
// until the engine's in-world flag is set; the first in-world frame then
|
||||
// emits a single DELAYED covering the settled inventory (matching retail's
|
||||
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
|
||||
// so this re-suppresses on later zone changes too, not just initial login.
|
||||
if (*reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
g_pending = false;
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
if (slot >= 0)
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
<Binding name="AOELOOT">
|
||||
if not C_Loot.IsScanInProgress() then
|
||||
C_Loot.ScanNearbyLoot()
|
||||
end
|
||||
C_Loot.LootAllCorpses()
|
||||
</Binding>
|
||||
</Bindings>
|
||||
|
||||
@@ -110,6 +110,8 @@ constexpr uint32_t kSettleMs = 1500;
|
||||
// ~currently-owned items rather than everything ever owned.
|
||||
constexpr int kMaxMissStreak = 4;
|
||||
|
||||
constexpr int kMaxContainerSlots = 64;
|
||||
|
||||
// One seen-set entry: an owned item's GUID and how many consecutive scans it
|
||||
// has been absent from the read (0 = present this scan).
|
||||
struct SeenEntry {
|
||||
@@ -313,7 +315,9 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
|
||||
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
|
||||
if (bagInvMgr == nullptr)
|
||||
continue;
|
||||
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
|
||||
int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
|
||||
if (bagSlots > kMaxContainerSlots)
|
||||
bagSlots = kMaxContainerSlots; // guard against a garbage count at login
|
||||
if (bagSlots > 0)
|
||||
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
|
||||
}
|
||||
|
||||
+123
-37
@@ -8,16 +8,27 @@
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
|
||||
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
|
||||
// the engine's loot-window response* before any visual updates happen.
|
||||
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
|
||||
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
|
||||
// Hook-driven loot corpse-walker. Walks the nearby-lootable-units set,
|
||||
// opens each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then
|
||||
// *intercepts the engine's loot-window response* before any visual updates
|
||||
// happen. The intercept (`FUN_LOOT_CONTROLLER`) lets the original function
|
||||
// run so `VAR_LOOT_SLOTS` populates and the link builder works — but a
|
||||
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
|
||||
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
|
||||
// the engine's state machine cycles silently for each corpse and
|
||||
// `LootFrame` never reacts. Other Lua addons listening to those events
|
||||
// also see nothing during the scan; that's deliberate.
|
||||
// `LOOT_CLOSED` event fires while the walk is in progress. Net result: the
|
||||
// engine's state machine cycles silently for each corpse and `LootFrame`
|
||||
// never reacts. Other Lua addons listening to those events also see nothing
|
||||
// during the walk; that's deliberate.
|
||||
//
|
||||
// Two Lua entry points share this one walker (and, importantly, this one
|
||||
// `FUN_LOOT_CONTROLLER` hook — MinHook allows a single hook per target, so
|
||||
// they can't be separate modules):
|
||||
// - `C_Loot.ScanNearbyLoot()` — scrape-only: read each corpse's contents
|
||||
// into `GetLastScanResults()` and release without looting.
|
||||
// - `C_Loot.LootAllCorpses([max])` — the same walk but actually LOOTS
|
||||
// each window (coin via `CMSG_LOOT_MONEY`, every item slot via
|
||||
// `CMSG_LOOT_ITEM`) before releasing. Silent (no LootFrame flicker);
|
||||
// the usual item-received / money chat + inventory events still fire,
|
||||
// since only the LootFrame open/close events are swallowed.
|
||||
//
|
||||
// Compared to the older polling-driven version this also collapses the
|
||||
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
|
||||
@@ -79,6 +90,7 @@ struct ScanCtx {
|
||||
uint64_t currentGuid = 0;
|
||||
int ticksSinceTransition = 0;
|
||||
bool suppressEvents = false; // read by the FireEvent hook
|
||||
bool lootMode = false; // LootAllCorpses (true) vs ScanNearbyLoot (false)
|
||||
std::vector<uint64_t> queue;
|
||||
std::vector<LootEntry> results;
|
||||
};
|
||||
@@ -106,6 +118,12 @@ using CloseLootInner_t = void(__fastcall *)(int sendRelease,
|
||||
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
|
||||
char *outBuf,
|
||||
int bufSize);
|
||||
// `FUN_CMSG_LOOT_ITEM` — `__stdcall(uint8_t wireSlot)`, sends CMSG_LOOT_ITEM
|
||||
// for one slot of the open window (see the offset comment for the
|
||||
// __stdcall-vs-__fastcall stack-corruption hazard).
|
||||
using SendLootItem_t = void(__stdcall *)(uint8_t wireSlot);
|
||||
// `FUN_CMSG_LOOT_MONEY` — `__fastcall(player)`, sends the coin request.
|
||||
using LootMoney_t = void(__fastcall *)(void *player);
|
||||
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
|
||||
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
|
||||
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
|
||||
@@ -207,6 +225,42 @@ void ScrapeCurrentLoot(LootEntry *out) {
|
||||
}
|
||||
}
|
||||
|
||||
// Loots everything in the currently-open window: coin (if any) plus every
|
||||
// populated item slot. Called from the controller hook (loot mode) after the
|
||||
// original populated `VAR_LOOT_SLOTS`, so the slot table is fully filled in.
|
||||
//
|
||||
// Items go through `FUN_CMSG_LOOT_ITEM` directly — the same silent path
|
||||
// `C_Loot.LootUnitItem` uses, which bypasses the BoP/unique confirm dialog
|
||||
// the Lua `LootSlot` wrapper would raise (appropriate for a programmatic
|
||||
// loot-all; the server still enforces real permissions). Coin goes through
|
||||
// `FUN_CMSG_LOOT_MONEY` with `s_scan.player` — the SAME pointer we passed to
|
||||
// `FUN_CMSG_LOOT_UNIT`, whose send wrote this window's target GUID to the
|
||||
// player's `+0x1D28` guard slot, so the money send's guard passes.
|
||||
//
|
||||
// Packet order to the server is items, then coin, then the release that
|
||||
// follows (`SendCloseLoot`); the server processes loot-item / loot-money
|
||||
// before the release, so nothing is dropped.
|
||||
void LootCurrentWindow() {
|
||||
// Coin present when `VAR_LOOT_LOOTABLE` holds a real amount (0 = none;
|
||||
// 0xFFFFFFFF = already-looted sentinel, never seen on a fresh open).
|
||||
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_LOOT_LOOTABLE);
|
||||
if (coinRaw != 0 && coinRaw != 0xFFFFFFFFu) {
|
||||
auto LootMoney = reinterpret_cast<LootMoney_t>(Offsets::FUN_CMSG_LOOT_MONEY);
|
||||
LootMoney(s_scan.player);
|
||||
}
|
||||
|
||||
auto SendItem = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
|
||||
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
|
||||
auto *entry = reinterpret_cast<const uint8_t *>(
|
||||
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
|
||||
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
|
||||
if (itemID == 0)
|
||||
continue;
|
||||
SendItem(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
|
||||
}
|
||||
}
|
||||
|
||||
// === State-machine transitions ===
|
||||
|
||||
void StartLoot(uint64_t guid);
|
||||
@@ -221,6 +275,7 @@ void Complete() {
|
||||
s_scan.state = State::Idle;
|
||||
s_scan.currentGuid = 0;
|
||||
s_scan.suppressEvents = false;
|
||||
s_scan.lootMode = false;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
|
||||
}
|
||||
|
||||
@@ -315,6 +370,12 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
|
||||
ScrapeCurrentLoot(&entry);
|
||||
s_scan.results.push_back(std::move(entry));
|
||||
|
||||
// Loot mode: actually take everything (coin + all slots) before the
|
||||
// release below. Scrape ran first, so `GetLastScanResults()` still
|
||||
// reports what each corpse held.
|
||||
if (s_scan.lootMode)
|
||||
LootCurrentWindow();
|
||||
|
||||
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
|
||||
// with `arg1 == 0`. That recursive call falls into the
|
||||
// passthrough branch but still sees `suppressEvents == true`
|
||||
@@ -377,46 +438,69 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
void BuildQueue(void *player) {
|
||||
void BuildQueue(void *player, size_t maxCount) {
|
||||
s_scan.queue.clear();
|
||||
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
|
||||
EnumCtx ctx{player, &s_scan.queue};
|
||||
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
|
||||
&ctx);
|
||||
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
|
||||
// enumeration order, not distance-sorted, so the truncation is
|
||||
// arbitrary among in-range corpses.
|
||||
if (maxCount != 0 && s_scan.queue.size() > maxCount)
|
||||
s_scan.queue.resize(maxCount);
|
||||
}
|
||||
|
||||
// Shared starter for both entry points: resolve the player, build the
|
||||
// corpse queue, kick off the walk. Returns whether it started — `false` if a
|
||||
// walk is already in progress or there's no local player.
|
||||
bool BeginWalk(bool lootMode, size_t maxCount) {
|
||||
if (s_scan.state != State::Idle)
|
||||
return false;
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
|
||||
return false;
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
s_scan.results.clear();
|
||||
s_scan.player = player;
|
||||
s_scan.lootMode = lootMode;
|
||||
BuildQueue(player, maxCount);
|
||||
|
||||
if (s_scan.queue.empty()) {
|
||||
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately
|
||||
return true;
|
||||
}
|
||||
TryStartNext();
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Lua bindings ===
|
||||
|
||||
// `C_Loot.ScanNearbyLoot()` — scrape-only walk. Returns `true` if it
|
||||
// started (a subsequent `GetLastScanResults()` holds the contents once
|
||||
// `LOOT_SCAN_COMPLETED` fires), `false` if a walk is already running or
|
||||
// there's no local player.
|
||||
int __fastcall Script_ScanNearbyLoot(void *L) {
|
||||
if (s_scan.state != State::Idle) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/false, /*maxCount=*/0));
|
||||
return 1;
|
||||
}
|
||||
|
||||
s_scan.results.clear();
|
||||
s_scan.player = player;
|
||||
BuildQueue(player);
|
||||
|
||||
if (s_scan.queue.empty()) {
|
||||
Complete();
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
TryStartNext();
|
||||
Game::Lua::PushBool(L, true);
|
||||
// `C_Loot.LootAllCorpses([max])` — loots every nearby lootable corpse
|
||||
// (coin + all items) in sequence. Optional `max` caps how many corpses are
|
||||
// visited. Returns `true` if it started, `false` if a walk (scan or loot)
|
||||
// is already in progress or there's no local player. `LOOT_SCAN_COMPLETED`
|
||||
// fires when the walk finishes; `GetLastScanResults()` then reports what
|
||||
// each corpse held (i.e. what was looted).
|
||||
int __fastcall Script_LootAllCorpses(void *L) {
|
||||
const size_t maxCount = Game::Lua::IsNumber(L, 1)
|
||||
? static_cast<size_t>(Game::Lua::ToNumber(L, 1))
|
||||
: 0;
|
||||
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/true, maxCount));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -462,6 +546,8 @@ int __fastcall Script_GetLastScanResults(void *L) {
|
||||
void Register() {
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
|
||||
&Script_ScanNearbyLoot);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "LootAllCorpses",
|
||||
&Script_LootAllCorpses);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
|
||||
&Script_IsScanInProgress);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
// mirroring the engine's registration shape exactly (bank 4, size 8). The
|
||||
// dispatcher invokes us only when a slot's item GUID actually changed
|
||||
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
|
||||
// on our side, and the nodes share the engine observers' lifetime (they
|
||||
// die with the player object; the setup re-runs each enter-world).
|
||||
// on our side. The setup re-runs each enter-world; on logout/login the
|
||||
// player object is recreated so old nodes die with it, but /reload REUSES
|
||||
// the object, so we unregister-then-register per slot to avoid stacking a
|
||||
// duplicate observer each reload (see ObserverSetup_h).
|
||||
//
|
||||
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
|
||||
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
|
||||
@@ -126,6 +128,10 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
using RegisterObserver_t = void(__fastcall *)(
|
||||
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
|
||||
const void *callback, void *userArg1, void *userArg2);
|
||||
using UnregisterObserver_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const void *callback,
|
||||
void *userArg1);
|
||||
using ObserverSetup_t = void(__fastcall *)();
|
||||
ObserverSetup_t g_origSetup = nullptr;
|
||||
|
||||
@@ -147,12 +153,26 @@ void __fastcall ObserverSetup_h() {
|
||||
const uint64_t guid = Unit::Identity::PlayerGuid();
|
||||
if (guid == 0)
|
||||
return;
|
||||
const uint32_t lo = static_cast<uint32_t>(guid);
|
||||
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
|
||||
auto reg = reinterpret_cast<RegisterObserver_t>(
|
||||
Offsets::FUN_DESC_OBSERVER_REGISTER);
|
||||
auto unreg = reinterpret_cast<UnregisterObserver_t>(
|
||||
Offsets::FUN_DESC_OBSERVER_UNREGISTER);
|
||||
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
|
||||
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
|
||||
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
const uint32_t field = Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8;
|
||||
// Drop any observer we already registered for this field before adding
|
||||
// a fresh one. The setup re-runs on every enter-world, including
|
||||
// /reload — but /reload does NOT destroy the CGPlayer object, so our
|
||||
// previous nodes are still attached and the registrar always APPENDS
|
||||
// (no dedup by callback). Without this, each /reload stacks another
|
||||
// observer per slot → PLAYER_EQUIPMENT_CHANGED fires N times after N
|
||||
// reloads. On logout/login the object is recreated and the old nodes
|
||||
// died with it, so the unregister no-ops there. Same objects-survive-
|
||||
// /reload hazard `Unit::TokenObserver` guards against for nameplates.
|
||||
unreg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
|
||||
reinterpret_cast<const void *>(&EquipSlotChanged_cb), nullptr);
|
||||
reg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
|
||||
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
|
||||
nullptr, nullptr);
|
||||
}
|
||||
|
||||
@@ -10,19 +10,27 @@
|
||||
|
||||
// `HEARTHSTONE_BOUND` event — polyfills modern WoW's event of the
|
||||
// same name. Fires every time the player binds their hearthstone at
|
||||
// an innkeeper, even when they re-bind at the SAME inn — matches
|
||||
// an innkeeper, even when they re-bind at the SAME inn — matching
|
||||
// modern semantics where the bind ACTION fires the event regardless
|
||||
// of whether the location string changes.
|
||||
// of whether the location changes.
|
||||
//
|
||||
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0` and
|
||||
// gates on `VAR_BIND_POINT_VALID` to suppress only the initial
|
||||
// post-login sync. The engine zeroes that flag during per-character
|
||||
// entry init (`FUN_005E2510`); the handler sets it to 1 after
|
||||
// parsing. If the flag is 0 before the original runs, this packet
|
||||
// is the initial sync — don't fire. Any subsequent invocation is
|
||||
// a real innkeeper rebind. Character switch is handled automatically
|
||||
// because the entry init re-zeroes the flag, so each character's
|
||||
// first update is also treated as sync.
|
||||
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0`. Two
|
||||
// non-bind packets must be suppressed:
|
||||
//
|
||||
// 1. Initial post-login / character-switch sync — gated on
|
||||
// `VAR_BIND_POINT_VALID`. The engine zeroes that flag during
|
||||
// per-character entry init (`FUN_005E2510`); the handler sets it
|
||||
// to 1 after parsing. Flag 0 before the original runs ⇒ initial
|
||||
// sync, don't fire.
|
||||
// 2. Map/zone-transition resync — Turtle's server re-sends this
|
||||
// packet on every map change with the (unchanged) bind location.
|
||||
// The valid flag is already 1 by then, so (1) doesn't catch it.
|
||||
// But those packets arrive behind a LOADING SCREEN, and the
|
||||
// player can't bind mid-load — so gate on `VAR_IN_WORLD` (the
|
||||
// engine's "world is live" flag, 0 during any loading screen).
|
||||
// A genuine rebind always arrives with the world live (flag 1),
|
||||
// so this suppresses the resync while still firing for a rebind
|
||||
// at the same innkeeper.
|
||||
//
|
||||
// Event has no payload — addons call `GetBindLocation()` to read
|
||||
// the new location, matching modern semantics.
|
||||
@@ -51,7 +59,14 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
|
||||
BindPointUpdateHandler_o(packetBuffer);
|
||||
|
||||
if (wasValid == 0)
|
||||
return; // initial post-login sync — not a user rebind
|
||||
return; // initial post-login / char-switch sync — not a user rebind
|
||||
|
||||
// Resync packets ride the enter-world burst behind a loading screen;
|
||||
// a real innkeeper bind only happens with the world live. The player
|
||||
// can't bind mid-load, so a not-in-world packet is always a resync.
|
||||
if (*reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
Event::Custom::Fire(slot, "");
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `GetTimeCached()` — a frame-stable companion to vanilla `GetTime()`.
|
||||
//
|
||||
// 1.12's `GetTime()` is *live*: `Script_GetTime` (`0x00515ea0`) calls the
|
||||
// OS tick source (`FUN_OS_TICKCOUNT_MS` → `GetTickCount`, or QPC in
|
||||
// timing-mode 1) on every invocation and scales by 0.001, so two calls in
|
||||
// the same frame can differ. Retail 4.x changed `GetTime()` to read a
|
||||
// value sampled once per frame in the main loop, so all same-frame calls
|
||||
// return the identical timestamp. Rather than change vanilla `GetTime`'s
|
||||
// long-standing live behavior, we expose the frame-stable value under a
|
||||
// new name and leave `GetTime()` alone.
|
||||
//
|
||||
// `GetTimeCached()` returns seconds on the SAME epoch as `GetTime()` (the
|
||||
// OS millisecond counter × 0.001), so the two are directly comparable —
|
||||
// the only difference is that `GetTimeCached()` holds constant for the
|
||||
// duration of a frame. We refresh the cache from the shared once-per-frame
|
||||
// `Tick::WorldTick` hook (which fires at the tail of each world frame).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Time::Cached {
|
||||
|
||||
namespace {
|
||||
|
||||
// OS millisecond counter sampled at the last frame boundary. 0 until the
|
||||
// first world tick fires (pre-world). Read as uint32 — `GetTickCount`
|
||||
// wraps at ~24.86 days, matching how the rest of the codebase treats this
|
||||
// counter (see aura/Data.cpp), and how `Script_GetTime` masks it to 32
|
||||
// bits before scaling.
|
||||
uint32_t g_frameMs = 0;
|
||||
|
||||
uint32_t SampleTickMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
|
||||
// Tail-of-frame refresh. Tail-of-frame-N ≈ start-of-frame-(N+1) minus the
|
||||
// inter-frame gap, which is all a frame-stable timestamp needs: every read
|
||||
// during a frame returns the value stamped at the previous boundary.
|
||||
void OnWorldTick() {
|
||||
g_frameMs = SampleTickMs();
|
||||
}
|
||||
|
||||
// `GetTimeCached()` → number (seconds). Frame-stable analog of
|
||||
// `GetTime()`, same epoch. Falls back to a live sample when no frame has
|
||||
// ticked yet (pre-world / glue), so it never returns 0.
|
||||
int __fastcall Script_GetTimeCached(void *L) {
|
||||
uint32_t ms = g_frameMs;
|
||||
if (ms == 0)
|
||||
ms = SampleTickMs();
|
||||
Game::Lua::PushNumber(L, static_cast<double>(ms) * 0.001);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetTimeCached", &Script_GetTimeCached);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Time::Cached
|
||||
Reference in New Issue
Block a user