Instant restack and bag swapping with C_Container.MoveItem

MoveItems() uses ClassicAPI's C_Container.MoveItem() when available (and
no move equips a bag): each round sends every move whose slots are free
and unlocked, then waits for BAG_UPDATE_DELAYED (1 s fallback) before the
next round. Restack() queues exact amounts; EmptyBag()/FillBag() move
whole stacks. Locked slots are retried up to 5 times, stock-state
handling matches ProcessMoveQueue(), and the cursor-based queue remains
the fallback. Approach taken from brues-code/pfUI libs/libbagsort.lua.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwt5PZSpEDkXYzRuRhH
This commit is contained in:
2026-11-02 03:49:40 +00:00
co-authored by Claude Opus 5.5
parent fe447d45a7
commit 0742732e41
3 changed files with 196 additions and 6 deletions
+6
View File
@@ -1,5 +1,11 @@
# Bagshui Changelog
## Unreleased
### Performance
- Restack and bag swapping move items with ClassicAPI's `C_Container.MoveItem()` instead of picking each item up and putting it down, the same way pfUI's instant bag sort does. Moves are sent in batches and each batch waits for the server's confirmation, so restacking many stacks or emptying a bag takes about a second instead of several. Without ClassicAPI, the old one-at-a-time method is used.
## 1.6.8 - 2026-11-01
### Performance
+183 -6
View File
@@ -161,14 +161,14 @@ Bagshui:AddComponent(function()
--self:PrintDebug(">> source count: " .. source.count)
if source ~= target and source.count > 0 and target.count < target.maxStackCount and target.count > 0 then
-- Queue move of source stacks onto target.
self:QueueMove(source, target)
--self:PrintDebug(string.format(">>> queued %s:%s (%s) to %s:%s (%s)", source.bagNum, source.slotNum, source.count, target.bagNum, target.slotNum, target.count))
-- Calculate the changes.
oldTargetCount = target.count
target.count = math.min(target.count + source.count, target.maxStackCount)
source.count = source.count - (target.count - oldTargetCount)
-- Queue move of source stacks onto target (with the exact amount, for instant moves).
self:QueueMove(source, target, target.count - oldTargetCount)
--self:PrintDebug(string.format(">>> queued %s:%s (%s) to %s:%s (%s)", source.bagNum, source.slotNum, source.count, target.bagNum, target.slotNum, target.count))
--self:PrintDebug(">> NEW source count: " .. source.count)
--self:PrintDebug(">> NEW target count: " .. target.count)
@@ -203,11 +203,16 @@ Bagshui:AddComponent(function()
function Inventory:ClearMoveQueue()
BsUtil.TableClear(self.queuedMoveSources)
BsUtil.TableClear(self.queuedMoveTargets)
BsUtil.TableClear(self.queuedMoveCounts)
end
function Inventory:QueueMove(source, target)
---@param source table Inventory cache entry to move from.
---@param target table Inventory cache entry to move to.
---@param count number? How many to move (whole stack when omitted).
function Inventory:QueueMove(source, target, count)
table.insert(self.queuedMoveSources, source)
table.insert(self.queuedMoveTargets, target)
table.insert(self.queuedMoveCounts, count or 0)
end
--- Perform queued item move operations.
@@ -233,7 +238,177 @@ Bagshui:AddComponent(function()
self.moveQueue_resort = resortAfterEachMove
-- Reset tracking.
self.moveQueue_LastSource = nil
self:ProcessMoveQueue()
if self:CanBatchMoves() then
self:StartMoveQueueBatched()
else
self:ProcessMoveQueue()
end
end
--- Can the queued moves be done with ClassicAPI's C_Container.MoveItem() (no cursor, all at once)?
--- Equipping a bag (a numeric target) still needs the cursor.
---@return boolean
function Inventory:CanBatchMoves()
if type(_G.C_Container) ~= "table" or type(_G.C_Container.MoveItem) ~= "function" then
return false
end
for _, target in ipairs(self.queuedMoveTargets) do
if type(target) ~= "table" then
return false
end
end
return true
end
--- Copy the queued moves into the batch lists and start the first round.
function Inventory:StartMoveQueueBatched()
self.moveBatch_sources = self.moveBatch_sources or {}
self.moveBatch_targets = self.moveBatch_targets or {}
self.moveBatch_counts = self.moveBatch_counts or {}
self.moveBatch_usedSlots = self.moveBatch_usedSlots or {}
BsUtil.TableClear(self.moveBatch_sources)
BsUtil.TableClear(self.moveBatch_targets)
BsUtil.TableClear(self.moveBatch_counts)
-- Amounts are only valid when they line up with the queue (FillBag() doesn't set any).
local haveCounts = table.getn(self.queuedMoveCounts) == table.getn(self.queuedMoveSources)
for i = 1, table.getn(self.queuedMoveSources) do
table.insert(self.moveBatch_sources, self.queuedMoveSources[i])
table.insert(self.moveBatch_targets, self.queuedMoveTargets[i])
table.insert(self.moveBatch_counts, haveCounts and self.queuedMoveCounts[i] or 0)
end
self:ClearMoveQueue()
self.moveBatch_retries = 0
self.moveBatch_waiting = false
self.moveBatch_done = 0
self:ProcessMoveQueueBatched()
end
--- # 🛑 Do not call directly; use `Inventory:MoveItems()` 🛑.
--- Instant item mover using ClassicAPI's C_Container.MoveItem() (same approach as pfUI's bag sort).
--- Each round sends every move whose slots aren't already part of this round and aren't locked,
--- then waits for BAG_UPDATE_DELAYED (server confirmation) before the next round. A stack that
--- is split onto several targets, or a locked slot, simply waits for a later round.
function Inventory:ProcessMoveQueueBatched()
self.moveBatch_waiting = false
local sources = self.moveBatch_sources
local targets = self.moveBatch_targets
local counts = self.moveBatch_counts
-- Are we done?
if table.getn(sources) <= 0 then
self:QueueUpdate(0.1)
if type(self.moveQueue_onComplete) == "function" then
self.moveQueue_onComplete(self.moveQueue_success)
end
return
end
-- Set resorting for this inventory and any supplemental storage.
if self.moveQueue_resort then
self.forceResort = true
if type(self.bagSwappingSupplementalStorage) == "table" then
for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
if Bagshui.components[inventoryClassName] and Bagshui.components[inventoryClassName].online then
Bagshui.components[inventoryClassName].forceResort = true
end
end
end
end
local usedSlots = self.moveBatch_usedSlots
BsUtil.TableClear(usedSlots)
local fired = 0
local i = 1
while i <= table.getn(sources) do
local source = sources[i]
local target = targets[i]
local sourceKey = source.bagNum .. ":" .. source.slotNum
local targetKey = target.bagNum .. ":" .. target.slotNum
local handled = false
if not usedSlots[sourceKey] and not usedSlots[targetKey] then
local _, sourceCount, sourceLocked = _G.GetContainerItemInfo(source.bagNum, source.slotNum)
local _, _, targetLocked = _G.GetContainerItemInfo(target.bagNum, target.slotNum)
if not sourceLocked and not targetLocked then
sourceCount = sourceCount or 0
if sourceCount > 0 then
-- Amount to move: the queued amount, but never more than is actually there.
local count = counts[i]
if not count or count <= 0 or count > sourceCount then
count = sourceCount
end
-- Try to make stock states and dates make sense (same rules as ProcessMoveQueue()).
local oldSourceStockState = source.bagshuiStockState
local oldTargetStockState = target.bagshuiStockState
if
oldSourceStockState
and oldSourceStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE
and (
oldSourceStockState ~= BS_ITEM_STOCK_STATE.DOWN
or not oldTargetStockState
or oldTargetStockState == BS_ITEM_STOCK_STATE.NO_CHANGE
)
then
target.bagshuiStockState = oldSourceStockState
end
target.bagshuiDate = math.max(source.bagshuiDate or 0, target.bagshuiDate or 0)
_G.C_Container.MoveItem(source.bagNum, source.slotNum, target.bagNum, target.slotNum, count)
fired = fired + 1
end
-- Done with this move (an empty source means there's nothing left to move).
usedSlots[sourceKey] = true
usedSlots[targetKey] = true
handled = true
end
end
if handled then
table.remove(sources, i)
table.remove(targets, i)
table.remove(counts, i)
self.moveBatch_done = self.moveBatch_done + 1
if type(self.moveQueue_onProgress) == "function" then
self.moveQueue_onProgress(self.moveBatch_done)
end
else
i = i + 1
end
end
if fired > 0 then
self.moveBatch_retries = 0
-- Wait for the server to confirm (BAG_UPDATE_DELAYED, see Inventory:OnEvent()),
-- with a fallback in case that event never arrives.
self.moveBatch_waiting = true
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 1.0)
return
end
if table.getn(sources) <= 0 then
-- Everything left was already empty; finish up.
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 0.05)
return
end
-- Nothing could be moved this round (slots still locked). Retry a few times, then give up.
self.moveBatch_retries = self.moveBatch_retries + 1
if self.moveBatch_retries > 5 then
for j = 1, table.getn(sources) do
Bagshui:ShowAndLogErrorMessage(string.format(L.Error_RestackFailed, sources[j].name or "?"))
end
self.moveQueue_success = false
BsUtil.TableClear(sources)
BsUtil.TableClear(targets)
BsUtil.TableClear(counts)
end
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 0.5)
end
--- # 🛑 Do not call directly; use `Inventory:MoveItems()` 🛑.
@@ -657,6 +832,8 @@ Bagshui:AddComponent(function()
BsUtil.TableCopyFlat(items, self.queuedMoveSources)
BsUtil.TableCopyFlat(self.inventory[bagNum], self.queuedMoveTargets)
-- Whole stacks (no per-move amounts).
BsUtil.TableClear(self.queuedMoveCounts)
-- Prune source table if there aren't enough slots in the destination.
if table.getn(self.queuedMoveSources) > table.getn(self.queuedMoveTargets) then
+7
View File
@@ -330,6 +330,8 @@ Bagshui:AddComponent(function()
-- Item move queue -- used for restacking and bag swapping.
queuedMoveSources = {},
queuedMoveTargets = {},
-- How many to move for each queued move (0 = the whole stack). Only Restack() sets amounts.
queuedMoveCounts = {},
tempMoveTargets = {},
-- Available empty slots (excluding profession bags -- see `Inventory:SwapBag()` comments).
@@ -663,6 +665,11 @@ Bagshui:AddComponent(function()
-- Handled before `self.lastEvent` is set so the ITEM_LOCK_CHANGED check in UpdateCache()
-- still sees the event that actually caused the update.
if event == "BAG_UPDATE_DELAYED" then
-- A batch of instant moves (see ProcessMoveQueueBatched()) has been confirmed by the server.
if self.moveBatch_waiting then
self.moveBatch_waiting = false
Bagshui:QueueClassCallback(self, self.ProcessMoveQueueBatched, 0.05)
end
if self.bagUpdatePending and not Bagshui.pickedUpBagSlotNum and not Bagshui.putDownBagSlotNum then
self.bagUpdatePending = false
self:QueueUpdate(0)