Skip to content

Commit ad13440

Browse files
committed
Item cache: hook response handler instead of racing the engine
`Item:CreateFromItemID(X):ContinueOnItemLoad(cb)` was leaving items permanently uncached when pfQuest called it at addon-load / PLAYER_ENTERING_WORLD against an empty WDB. Three intertwined bugs: 1. `GetItemInfoInstant` returned no values for uncached items, so `ItemMixin:GetItemID()` returned nil and `AddCallback(nil, cb)` crashed with "table index is nil". 2. `DoesItemExistByID` returned false for uncached items, which made `IsItemEmpty()` return true and erroneously gated `ContinueOnItemLoad` on cache state — modern semantic is "is this a valid ID", independent of cache. 3. Our `RequestLoadItemData` path eagerly called `CacheFetch` with our own callback at addon-load. That created a cache entry before the engine's natural inventory prefetch had a chance to run; the engine then saw the entry already existed and skipped its own SMSG, leaving the entry permanently pending. `GetItemInfo` retries didn't help — subsequent `_GetRecord` calls just appended callbacks to the dead list, no new query was sent. Fixes: - `GetItemInfoInstant` always pushes the itemID (echoed from input) and the remaining 6 fields when cached / nils when not. No cache touch. - `DoesItemExistByID` returns `itemID > 0`. No cache touch. - `RequestLoadItemData` tracks itemIDs in a pending set without touching the cache. We hook `FUN_ITEMSTATS_CACHE_RESPONSE` (the engine's `SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`) and sweep pending after the engine fills entries — observing the natural prefetch instead of racing it. `WorldTick` only handles escalation (after ~60 ticks, kick a query for items the engine doesn't prefetch itself, e.g. quest rewards) and the hard timeout. Also fix the truthiness bug in modern (id, success) events: `Event::Custom::Fire("%d%d", id, success)` was pushing `success` as a Lua number — both 0 and 1 are truthy, so `if success then` fired the success branch on failed loads. New `Event::Custom::FireIdSuccess` pushes `1` for success and `nil` for failure (via the engine's `lua_pushstring(NULL) → lua_pushnil` tail-jump). `ITEM_DATA_LOAD_RESULT`, `GET_ITEM_INFO_RECEIVED`, and `QUEST_DATA_LOAD_RESULT` now all use this.
1 parent 999a584 commit ad13440

6 files changed

Lines changed: 224 additions & 70 deletions

File tree

src/Offsets.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,26 @@ enum Offsets {
10681068
// With `requestIfMissing=false`, returns NULL if not in cache (no server
10691069
// round-trip) — exactly the "instant" semantics we want.
10701070
FUN_DBCACHE_ITEMSTATS_GET_RECORD = 0x0055BA30,
1071+
// Item-cache response handler — the function called from
1072+
// `0x00555140` (success path for `SMSG_ITEM_QUERY_SINGLE_RESPONSE`)
1073+
// and `0x00555160` (failure path). Parses one or more itemIDs out
1074+
// of the packet, looks up (or creates) the cache entry per ID,
1075+
// copies the item data block via `FUN_007c9640` to `entry+0x18`,
1076+
// sets `[entry+0x1F0]=1` (the loaded byte), then walks the entry's
1077+
// pending callback list at `[entry+0x1FC]` and invokes each with
1078+
// `(userData, success)`. High-bit-on itemID in the packet signals
1079+
// a per-item failure for the batch.
1080+
//
1081+
// We hook this to observe any item-cache fill the engine performs
1082+
// — covers the engine's natural inventory prefetch and any
1083+
// addon-triggered queries without having to register our own
1084+
// engine callbacks. Lets `RequestLoadItemData` be purely passive:
1085+
// we track itemIDs, the engine (or whoever) issues the query, we
1086+
// see the fill via this hook and fire `ITEM_DATA_LOAD_RESULT`.
1087+
//
1088+
// Signature: `__thiscall(void *cache, void *packetReader, int flag)`.
1089+
// `cache` is the same `VAR_ITEMDB_CACHE` we already use.
1090+
FUN_ITEMSTATS_CACHE_RESPONSE = 0x0055BDB0,
10711091
// ItemStats_C field offsets we read. Full struct layout in
10721092
// VanillaHelpers's `Game.h` (`struct ItemStats_C`); we only need these.
10731093
OFF_ITEMSTATS_CLASS = 0x00,

src/event/Custom.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ inline void Fire(int eventID, const char *format, Args... args) {
7474
fn(eventID, format, args...);
7575
}
7676

77+
// Convenience for the modern WoW `(id: number, success: bool)` event
78+
// shape (ITEM_DATA_LOAD_RESULT, GET_ITEM_INFO_RECEIVED,
79+
// QUEST_DATA_LOAD_RESULT, …). The engine's printf-style dispatcher has
80+
// no `%b`, so a naive `%d%d` with `0` for failure surfaces `arg2 = 0` —
81+
// truthy in Lua, breaking the canonical `if success then` branch. We
82+
// push `1` for success and `nil` for failure instead, leaning on the
83+
// engine's `lua_pushstring(L, NULL) → lua_pushnil` tail-jump so the
84+
// dispatcher itself emits the nil without any pre-staging. Lua handlers
85+
// can then use the natural idiom: nil is falsy, `1` is truthy.
86+
inline void FireIdSuccess(int eventID, int id, bool success) {
87+
if (success)
88+
Fire(eventID, "%d%d", id, 1);
89+
else
90+
Fire(eventID, "%d%s", id, static_cast<const char *>(nullptr));
91+
}
92+
7793
// Internal: try to claim a slot for every reservation that's still
7894
// unclaimed (`slot < 0`). Called from the `Frame::RegisterEvent` hook
7995
// in DllMain — every time Lua calls `frame:RegisterEvent(...)`, we

src/item/Data.cpp

Lines changed: 142 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include "item/Arg.h"
1818
#include "item/Data.h"
1919
#include "item/Location.h"
20+
#include "tick/WorldTick.h"
2021

2122
#include <cstdint>
2223

@@ -52,16 +53,14 @@ static constexpr const char *kGetItemInfoReceived = "GET_ITEM_INFO_RECEIVED";
5253
static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoadResult};
5354
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
5455

55-
static void FireGetItemInfoReceived(int itemID, int success) {
56-
const int slot = Event::Custom::Lookup(kGetItemInfoReceived);
57-
if (slot >= 0)
58-
Event::Custom::Fire(slot, "%d%d", itemID, success);
56+
static void FireGetItemInfoReceived(int itemID, bool success) {
57+
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
58+
itemID, success);
5959
}
6060

61-
static void FireItemDataLoadResult(int itemID, int success) {
62-
const int slot = Event::Custom::Lookup(kItemDataLoadResult);
63-
if (slot >= 0)
64-
Event::Custom::Fire(slot, "%d%d", itemID, success);
61+
static void FireItemDataLoadResult(int itemID, bool success) {
62+
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
63+
itemID, success);
6564
}
6665

6766
// Engine callback for **implicit** cache fills (transparent warmup
@@ -76,16 +75,7 @@ static void FireItemDataLoadResult(int itemID, int success) {
7675
// callback would crash the engine, so stdcall is verified by induction.
7776
static void __stdcall ItemLoadCallback_Implicit(void *userData, int success) {
7877
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
79-
FireGetItemInfoReceived(itemID, static_cast<int>(success != 0));
80-
}
81-
82-
// Engine callback for **explicit** cache fills (player-side
83-
// `C_Item.RequestLoadItemData(ByID)` calls). Fires ITEM_DATA_LOAD_RESULT
84-
// only — the implicit GET_ITEM_INFO_RECEIVED path is for ambient cache
85-
// fills, not for callers who explicitly asked.
86-
static void __stdcall ItemLoadCallback_Explicit(void *userData, int success) {
87-
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
88-
FireItemDataLoadResult(itemID, static_cast<int>(success != 0));
78+
FireGetItemInfoReceived(itemID, success != 0);
8979
}
9080

9181
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
@@ -148,18 +138,143 @@ static int __fastcall Script_IsItemDataCached(void *L) {
148138
return 1;
149139
}
150140

151-
// Explicit-request path: kicks off the cache fill via the explicit
152-
// callback (which fires ITEM_DATA_LOAD_RESULT). If the item is already
153-
// cached, the engine won't invoke our callback — synthesize the event
154-
// so addons get the same notification regardless of cache state,
155-
// matching modern `C_Item.RequestLoadItemData(ByID)` semantics.
141+
// Pending explicit-request tracking. We deliberately do NOT eagerly
142+
// create cache entries when an addon calls `C_Item.RequestLoadItemData`
143+
// at addon-load / PLAYER_ENTERING_WORLD time — empirically (pfQuest),
144+
// pre-creating the entry blocks the engine's natural prefetch path
145+
// from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The entry stays in a
146+
// permanently-pending state because nothing ever sends the query.
147+
//
148+
// Instead we hook the engine's item-cache response handler at
149+
// `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that fills cache
150+
// entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`). After the engine
151+
// finishes filling any entries and walking its own callback lists,
152+
// we sweep our pending set and fire `ITEM_DATA_LOAD_RESULT(itemID, 1)`
153+
// for any tracked items that now resolve. The engine's natural
154+
// prefetch (for player-inventory items) takes care of the actual
155+
// `SMSG_ITEM_QUERY_SINGLE` for us.
156+
//
157+
// For items the engine *doesn't* naturally prefetch (e.g. quest
158+
// reward IDs the player has never owned), we escalate after
159+
// `kEscalateTicks` by issuing our own `CacheFetch` with a no-op
160+
// callback — that creates the entry and queues the query, and the
161+
// response handler hook still fires our event when the data lands.
162+
// `kMaxWaitTicks` is the hard timeout that gives up and fires
163+
// `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
164+
struct Pending {
165+
uint32_t itemID;
166+
int ticksWaiting;
167+
bool requestIssued;
168+
};
169+
170+
constexpr int kMaxPending = 64;
171+
constexpr int kEscalateTicks = 60;
172+
constexpr int kMaxWaitTicks = 1200;
173+
174+
static Pending g_pending[kMaxPending];
175+
static int g_pendingCount = 0;
176+
177+
static int FindPending(uint32_t itemID) {
178+
for (int i = 0; i < g_pendingCount; ++i) {
179+
if (g_pending[i].itemID == itemID)
180+
return i;
181+
}
182+
return -1;
183+
}
184+
185+
static void RemovePendingAt(int idx) {
186+
--g_pendingCount;
187+
if (idx != g_pendingCount)
188+
g_pending[idx] = g_pending[g_pendingCount];
189+
}
190+
191+
// No-op callback for the escalation path — we just need a non-null
192+
// callback pointer so the engine's `_GetRecord` queues the query.
193+
// The response handler hook will detect the fill and fire the event;
194+
// this callback doesn't need to do anything.
195+
static void __stdcall ItemLoadCallback_NoOp(void * /*userData*/, int /*success*/) {}
196+
156197
static void RequestAndMaybeNotify(uint32_t itemID) {
157-
const bool wasCached = (CacheFetch(itemID, nullptr) != nullptr);
158-
CacheFetch(itemID, &ItemLoadCallback_Explicit);
159-
if (wasCached)
160-
FireItemDataLoadResult(static_cast<int>(itemID), 1);
198+
// Already loaded — fire success synchronously.
199+
if (CacheFetch(itemID, nullptr) != nullptr) {
200+
FireItemDataLoadResult(static_cast<int>(itemID), true);
201+
return;
202+
}
203+
// Already tracking — second request just dedups.
204+
if (FindPending(itemID) >= 0)
205+
return;
206+
if (g_pendingCount >= kMaxPending)
207+
return;
208+
g_pending[g_pendingCount].itemID = itemID;
209+
g_pending[g_pendingCount].ticksWaiting = 0;
210+
g_pending[g_pendingCount].requestIssued = false;
211+
++g_pendingCount;
212+
}
213+
214+
// Response handler hook. After the engine fills cache entries from
215+
// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and walks the per-entry callback
216+
// lists, we sweep `g_pending` for anything now-loaded and fire the
217+
// event. This is the engine-aligned alternative to per-tick polling:
218+
// we observe fills exactly when they happen, with zero added latency
219+
// and no risk of racing the engine's own callback walk.
220+
//
221+
// `__thiscall` is mimicked as `__fastcall(ecx, edx_unused, ...)` per
222+
// the convention used elsewhere in this codebase (see
223+
// `KeyringDescChange_h`, `FrameRegisterEvent_h`).
224+
using ItemStatsCacheResponse_t = void(__thiscall *)(void *cache, void *packetReader, int flag);
225+
static ItemStatsCacheResponse_t ItemStatsCacheResponse_o = nullptr;
226+
227+
static void __fastcall ItemStatsCacheResponse_h(void *cache, void * /*edx_unused*/,
228+
void *packetReader, int flag) {
229+
using Trampoline_t = void(__fastcall *)(void *cache, void *edx, void *packetReader, int flag);
230+
reinterpret_cast<Trampoline_t>(ItemStatsCacheResponse_o)(cache, nullptr, packetReader, flag);
231+
// Sweep pending — the engine just finished filling any matching
232+
// entries. Peek (no callback) returns non-null iff the entry's
233+
// loaded flag is set.
234+
for (int i = 0; i < g_pendingCount;) {
235+
const uint32_t itemID = g_pending[i].itemID;
236+
if (CacheFetch(itemID, nullptr) != nullptr) {
237+
RemovePendingAt(i);
238+
FireItemDataLoadResult(static_cast<int>(itemID), true);
239+
continue;
240+
}
241+
++i;
242+
}
243+
}
244+
245+
static const Game::HookAutoRegister _hookCacheResponse{
246+
Offsets::FUN_ITEMSTATS_CACHE_RESPONSE,
247+
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
248+
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
249+
250+
// Escalation + timeout. The hook covers the happy path (engine fills
251+
// cache → we fire). This tick is only responsible for:
252+
// 1. Issuing a query for items the engine doesn't naturally prefetch
253+
// (e.g. quest rewards) — wait `kEscalateTicks` so we don't race
254+
// the engine's startup-time state machine, then call CacheFetch
255+
// with a no-op callback. The engine sends the SMSG, the response
256+
// handler hook fires the event when the response lands.
257+
// 2. Giving up after `kMaxWaitTicks` for items that never resolve
258+
// (bad itemID, server doesn't respond, etc.) — fire failure.
259+
static void OnWorldTick() {
260+
for (int i = 0; i < g_pendingCount;) {
261+
if (!g_pending[i].requestIssued && g_pending[i].ticksWaiting >= kEscalateTicks) {
262+
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_NoOp);
263+
g_pending[i].requestIssued = true;
264+
}
265+
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
266+
const uint32_t itemID = g_pending[i].itemID;
267+
RemovePendingAt(i);
268+
FireItemDataLoadResult(static_cast<int>(itemID), false);
269+
continue;
270+
}
271+
++g_pending[i].ticksWaiting;
272+
++i;
273+
}
161274
}
162275

276+
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
277+
163278
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup
164279
// path used by the `Script_GetItemInfo` hook and `SetItemByID`. Fires
165280
// the cache request only if the item isn't already cached; uses the

src/item/Exists.cpp

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,12 @@
1414
#include "Game.h"
1515
#include "Offsets.h"
1616
#include "item/Arg.h"
17-
#include "item/Data.h"
1817
#include "item/Location.h"
1918

2019
#include <cstdint>
2120

2221
namespace Item::Exists {
2322

24-
namespace {
25-
26-
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
27-
const uint64_t *guid, void *callback,
28-
void *userData, int unused);
29-
30-
const uint8_t *PeekItemRecord(uint32_t itemID) {
31-
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
32-
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
33-
const uint64_t zeroGuid = 0;
34-
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
35-
}
36-
37-
} // namespace
38-
3923
// `C_Item.DoesItemExist(itemLocation)` — true iff the location resolves
4024
// to a populated inventory slot on the active player. Equipment-slot
4125
// locations check the character pane; bag-slot locations walk the
@@ -51,27 +35,32 @@ static int __fastcall Script_C_Item_DoesItemExist(void *L) {
5135
return 1;
5236
}
5337

54-
// `C_Item.DoesItemExistByID(itemInfo)` — true iff the cache currently
55-
// has data for this itemID (i.e. `GetItemInfo` would return non-nil
56-
// right now). Same semantics as the modern function and the existing
57-
// Lua shim: cache miss → false, but we kick off the network query
58-
// so a subsequent call after `GET_ITEM_INFO_RECEIVED` will succeed.
38+
// `C_Item.DoesItemExistByID(itemInfo)` — true if `itemID` looks like a
39+
// valid item identifier. Modern WoW answers from the client-side item
40+
// database (Item-sparse.db2) and is independent of whether the network
41+
// cache has been warmed yet. 1.12 has no client-side DB, so we
42+
// optimistically accept any positive itemID; callers needing the
43+
// "is the cache populated?" check should use `IsItemDataCachedByID`.
44+
//
45+
// This split matters for `ItemMixin:IsItemEmpty` (FrameXML ItemUtil),
46+
// which uses `DoesItemExistByID` to gate `ContinueOnItemLoad` — the
47+
// whole point of `ContinueOnItemLoad` is to wait for an uncached item
48+
// to load, so the existence check must succeed for items that aren't
49+
// in the cache yet.
50+
//
51+
// We deliberately do NOT auto-warm the cache here. Existence is a pure
52+
// query about the ID, not a load request — and during early-login
53+
// (cold WDB, pfQuest's PLAYER_ENTERING_WORLD path) a spurious cache
54+
// touch can race the engine's natural inventory prefetch and leave the
55+
// item stuck. Callers wanting to actually load data should use
56+
// `RequestLoadItemDataByID`; passive readers (`GetItemInfo`,
57+
// `GetItemNameByID`) already warm on their own miss path.
5958
//
6059
// Accepts numeric itemID or `"item:NNN..."` string (including full
61-
// chat links). Returns false for any input we can't resolve to an
62-
// itemID; never raises.
60+
// chat links); never raises.
6361
static int __fastcall Script_C_Item_DoesItemExistByID(void *L) {
6462
const int itemID = Item::Arg::ResolveItemID(L, 1);
65-
if (itemID <= 0) {
66-
Game::Lua::PushBoolean(L, 0);
67-
return 1;
68-
}
69-
if (PeekItemRecord(static_cast<uint32_t>(itemID)) == nullptr) {
70-
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
71-
Game::Lua::PushBoolean(L, 0);
72-
return 1;
73-
}
74-
Game::Lua::PushBoolean(L, 1);
63+
Game::Lua::PushBoolean(L, itemID > 0);
7564
return 1;
7665
}
7766

src/item/Info.cpp

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,31 @@ static bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
9393
return true;
9494
}
9595

96+
// `GetItemInfoInstant` is the modern "synchronous, never blocks" item
97+
// info call. In modern WoW the trailing 6 fields come from client-side
98+
// `Item-sparse.db2` and so the call always returns the full 7-tuple.
99+
// 1.12 has no client-side item DB — those fields live only in the
100+
// network-fed `DBCache_ItemStats`. We mirror the contract by:
101+
// - always returning the itemID (echoed from input) so callers using
102+
// `GetItemInfoInstant(link_or_id)` as an ID extractor never see nil
103+
// - returning the remaining 6 fields from the cache when present,
104+
// nil when not (preserving the 7-tuple shape)
105+
// - not warming the cache; "Instant" implies no side effects. Callers
106+
// that need the full info should use `GetItemInfo`, which already
107+
// triggers the warmup via the `Script_GetItemInfo` hook.
96108
static int __fastcall Script_GetItemInfoInstant(void *L) {
97109
const int itemID = Item::Arg::ResolveItemID(L, 1);
98110
if (itemID <= 0)
99111
return 0;
112+
113+
Game::Lua::PushNumber(L, static_cast<double>(itemID));
114+
100115
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
101-
if (record == nullptr)
102-
return 0;
116+
if (record == nullptr) {
117+
for (int i = 0; i < 6; ++i)
118+
Game::Lua::PushNil(L);
119+
return 7;
120+
}
103121

104122
const uint32_t classID =
105123
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_CLASS);
@@ -114,7 +132,6 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
114132
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
115133
iconPath[0] = 0;
116134

117-
Game::Lua::PushNumber(L, static_cast<double>(itemID));
118135
Game::Lua::PushString(L, LookupItemClassName(classID));
119136
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID));
120137
Game::Lua::PushString(L, LookupInvType(invType));

src/quest/Data.cpp

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
3131
// `ret 8` cleans both args. We encode the questID into `userData` at
3232
// request time so the callback knows which quest completed.
3333
static void __stdcall QuestLoadCallback(void *userData, int success) {
34-
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
35-
if (slot < 0)
36-
return;
3734
const auto questID =
3835
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
39-
Event::Custom::Fire(slot, "%d%d", questID, static_cast<int>(success != 0));
36+
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
37+
questID, success != 0);
4038
}
4139

4240
static void RequestAndMaybeNotify(uint32_t questID) {
@@ -47,9 +45,8 @@ static void RequestAndMaybeNotify(uint32_t questID) {
4745
// invoking our callback. Fire the event ourselves so addons get the
4846
// notification regardless of cache state, matching modern semantics.
4947
if (wasCached) {
50-
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
51-
if (slot >= 0)
52-
Event::Custom::Fire(slot, "%d%d", static_cast<int>(questID), 1);
48+
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
49+
static_cast<int>(questID), true);
5350
}
5451
}
5552

0 commit comments

Comments
 (0)