From a8bedbf6cb0af361d369d1bad760aa72a9f80602 Mon Sep 17 00:00:00 2001 From: Y0oshi Date: Wed, 23 Sep 2026 05:13:17 +0300 Subject: [PATCH 1/5] Revalidate item before giving after movement completes --- Source/ACE.Server/WorldObjects/Player_Inventory.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Source/ACE.Server/WorldObjects/Player_Inventory.cs b/Source/ACE.Server/WorldObjects/Player_Inventory.cs index 4c079aff05..96ab5e81a3 100644 --- a/Source/ACE.Server/WorldObjects/Player_Inventory.cs +++ b/Source/ACE.Server/WorldObjects/Player_Inventory.cs @@ -3249,6 +3249,15 @@ public void HandleActionGiveObjectRequest(uint targetGuid, uint itemGuid, int am return; } + // We make sure the item is still valid. It could have changed during our movement + if (FindObject(item.Guid, SearchLocations.MyInventory | SearchLocations.MyEquippedItems, out itemFoundInContainer, out itemRootOwner, out itemWasEquipped) != item || item.StackSize < amount) + { + log.DebugFormat("Player 0x{0:X8}:{1} tried to give an item that's no longer valid 0x{2:X8}:{3}.", Guid.Full, Name, item.Guid.Full, item.Name); + Session.Network.EnqueueSend(new GameEventCommunicationTransientString(Session, "Give failed!")); // Custom error message + Session.Network.EnqueueSend(new GameEventInventoryServerSaveFailed(Session, itemGuid, WeenieError.ActionCancelled)); + return; + } + if (target is Player targetAsPlayer) GiveObjectToPlayer(targetAsPlayer, item, itemFoundInContainer, itemRootOwner, itemWasEquipped, amount); else From 391af92bc2be57796442fbde998aaac76e173364 Mon Sep 17 00:00:00 2001 From: Y0oshi Date: Wed, 23 Sep 2026 05:13:17 +0300 Subject: [PATCH 2/5] Revalidate stack before splitting to 3D after pickup animation --- Source/ACE.Server/WorldObjects/Player_Inventory.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Source/ACE.Server/WorldObjects/Player_Inventory.cs b/Source/ACE.Server/WorldObjects/Player_Inventory.cs index 96ab5e81a3..9c9ea303a7 100644 --- a/Source/ACE.Server/WorldObjects/Player_Inventory.cs +++ b/Source/ACE.Server/WorldObjects/Player_Inventory.cs @@ -2541,6 +2541,15 @@ public void HandleActionStackableSplitTo3D(uint stackId, int amount) return; } + // We make sure the stack is still valid. It could have changed during our pickup animation + if (FindObject(stack.Guid, SearchLocations.MyInventory | SearchLocations.MyEquippedItems, out stackFoundInContainer, out stackRootOwner, out _) != stack || stack.StackSize <= amount) + { + log.DebugFormat("Player 0x{0:X8}:{1} tried to split an item that's no longer valid 0x{2:X8}:{3}.", Guid.Full, Name, stack.Guid.Full, stack.Name); + Session.Network.EnqueueSend(new GameEventCommunicationTransientString(Session, "Split failed!")); // Custom error message + Session.Network.EnqueueSend(new GameEventInventoryServerSaveFailed(Session, stackId, WeenieError.ActionCancelled)); + return; + } + if (!AdjustStack(stack, -amount, stackFoundInContainer, stackRootOwner)) { Session.Network.EnqueueSend(new GameEventInventoryServerSaveFailed(Session, stackId, WeenieError.ActionCancelled)); From 99d94799b32bc7c2d430c8c77856af8f0e97afc7 Mon Sep 17 00:00:00 2001 From: Y0oshi Date: Wed, 23 Sep 2026 05:18:55 +0300 Subject: [PATCH 3/5] Return traded items to their owner if they can't be added to the recipient --- Source/ACE.Server/WorldObjects/Player_Trade.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Source/ACE.Server/WorldObjects/Player_Trade.cs b/Source/ACE.Server/WorldObjects/Player_Trade.cs index f1f71acf66..41da05ab27 100644 --- a/Source/ACE.Server/WorldObjects/Player_Trade.cs +++ b/Source/ACE.Server/WorldObjects/Player_Trade.cs @@ -259,10 +259,16 @@ private void FinalizeTrade(Player target) actionChain.AddAction(CurrentLandblock, () => { foreach (var wo in myEscrow) - TryCreateInInventoryWithNetworking(wo); + { + if (!TryCreateInInventoryWithNetworking(wo) && !target.TryCreateInInventoryWithNetworking(wo)) + log.WarnFormat("Item 0x{0:X8}:{1} for player {2} lost from FinalizeTrade failure.", wo.Guid.Full, wo.Name, target.Name); + } foreach (var wo in targetEscrow) - target.TryCreateInInventoryWithNetworking(wo); + { + if (!target.TryCreateInInventoryWithNetworking(wo) && !TryCreateInInventoryWithNetworking(wo)) + log.WarnFormat("Item 0x{0:X8}:{1} for player {2} lost from FinalizeTrade failure.", wo.Guid.Full, wo.Name, Name); + } Session.Network.EnqueueSend(new GameEventWeenieError(Session, WeenieError.TradeComplete)); target.Session.Network.EnqueueSend(new GameEventWeenieError(target.Session, WeenieError.TradeComplete)); From 486f19ea72ea8c105d82450f72ecd6cf7d03ecdf Mon Sep 17 00:00:00 2001 From: Y0oshi Date: Wed, 23 Sep 2026 05:18:55 +0300 Subject: [PATCH 4/5] Reject duplicate items and multiple vendor services in buy requests --- Source/ACE.Server/WorldObjects/Vendor.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Source/ACE.Server/WorldObjects/Vendor.cs b/Source/ACE.Server/WorldObjects/Vendor.cs index 0ae968610a..f9ebcd6fcc 100644 --- a/Source/ACE.Server/WorldObjects/Vendor.cs +++ b/Source/ACE.Server/WorldObjects/Vendor.cs @@ -440,6 +440,8 @@ public bool BuyItems_ValidateTransaction(List itemProfiles, Player var defaultItemProfiles = new List(); var uniqueItems = new List(); + var requestedGuids = new HashSet(); + // find item profiles in default and unique items foreach (var itemProfile in itemProfiles) { @@ -450,11 +452,25 @@ public bool BuyItems_ValidateTransaction(List itemProfiles, Player return false; } + if (!requestedGuids.Add(itemProfile.ObjectGuid)) + { + log.Warn($"[VENDOR] {player.Name} tried to buy duplicate item {itemProfile.ObjectGuid:X8} from {Name}"); + player.SendTransientError($"Invalid item"); + return false; + } + var itemGuid = new ObjectGuid(itemProfile.ObjectGuid); // check default items if (DefaultItemsForSale.TryGetValue(itemGuid, out var defaultItemForSale)) { + // services are not limited by pack space, so only allow one per request + if (itemProfile.Amount > 1 && (defaultItemForSale.GetProperty(PropertyBool.VendorService) ?? false)) + { + player.SendTransientError($"Invalid amount"); + return false; + } + itemProfile.WeenieClassId = defaultItemForSale.WeenieClassId; itemProfile.Palette = defaultItemForSale.PaletteTemplate; itemProfile.Shade = defaultItemForSale.Shade; From a436a02148b50a7a25eae2b2e7f588f384f2ba89 Mon Sep 17 00:00:00 2001 From: Y0oshi Date: Wed, 23 Sep 2026 05:18:55 +0300 Subject: [PATCH 5/5] Bind MySQL port to localhost in docker-compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 384e14e8e9..5cf5c225ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - ./db-data:/var/lib/mysql ports: - - "3306:3306/tcp" + - "127.0.0.1:3306:3306/tcp" restart: unless-stopped healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]