Skip to content

Commit d977afc

Browse files
kamronbatmanclaude
andcommitted
fix: lift rejection only re-sends items the requester was sent
Mobile.Lift resolves the item from a client-supplied serial and, on rejection, re-sent it to the requester unconditionally. Item.IsSentTo(Mobile) answers whether a client is expected to hold an item, using the same recipient rule as the adds in ProcessDelta: for a private container child, the root mobile, the secure-trade parties, or the container's Openers (trade parties and openers re-checked for map and range), each gated on CanSee; otherwise CanSee and in update range. The private-child guard is shared with SendRemovePacket through TryGetPrivateParent. Lift's rejection path always sends the lift-reject packet, but resyncs the item only when it is undeleted and IsSentTo the requester. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a261d11 commit d977afc

3 files changed

Lines changed: 307 additions & 9 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
using System;
2+
using System.Buffers;
3+
using Server.Accounting;
4+
using Server.Items;
5+
using Server.Network;
6+
using Server.Tests.Network;
7+
using Xunit;
8+
9+
namespace Server.Tests;
10+
11+
[Collection("Sequential Server Tests")]
12+
public class LiftRejectResyncTests
13+
{
14+
private static readonly Point3D _baseLoc = new(2200, 2200, 0);
15+
16+
private static (NetState, Mobile) CreateClient(Point3D location)
17+
{
18+
var ns = PacketTestUtilities.CreateTestNetState();
19+
ns.Account = new MockAccount();
20+
21+
var mobile = new Mobile(World.NewMobile);
22+
mobile.DefaultMobileInit();
23+
ns.Mobile = mobile;
24+
mobile.NetState = ns;
25+
mobile.MoveToWorld(location, Map.Felucca);
26+
return (ns, mobile);
27+
}
28+
29+
private static void DisposeClient(NetState ns, Mobile mobile)
30+
{
31+
ns.Mobile = null;
32+
ns.Dispose();
33+
mobile.Delete();
34+
}
35+
36+
private static bool ReceivedContentUpdate(NetState ns, Serial serial)
37+
{
38+
Span<byte> expected = stackalloc byte[5];
39+
var writer = new SpanWriter(expected);
40+
writer.Write((byte)0x25); // ContainerContentUpdate packet ID
41+
writer.Write(serial);
42+
return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
43+
}
44+
45+
private static bool ReceivedWorldItem(NetState ns, Item item)
46+
{
47+
Span<byte> expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength];
48+
var length = OutgoingItemPackets.CreateWorldItem(expected, item);
49+
return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0;
50+
}
51+
52+
[Fact]
53+
public void PrivateNestedItem_RejectedLift_DoesNotResyncBystander()
54+
{
55+
var (ownerNs, owner) = CreateClient(_baseLoc);
56+
var (bystanderNs, bystander) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0));
57+
58+
var backpack = new Container(0xE75) { Layer = Layer.Backpack };
59+
owner.AddItem(backpack);
60+
var pouch = new Container(0xE75);
61+
backpack.DropItem(pouch);
62+
var item = new Item(0x1234);
63+
pouch.DropItem(item);
64+
65+
try
66+
{
67+
bystander.Lift(item, item.Amount, out var rejected, out _);
68+
69+
// Whichever check trips first (CheckNonlocalLift, accessibility, ...), the bystander was
70+
// never sent this private child, so the rejection must not resynchronize it to them.
71+
Assert.True(rejected);
72+
Assert.False(ReceivedContentUpdate(bystanderNs, item.Serial));
73+
}
74+
finally
75+
{
76+
DisposeClient(ownerNs, owner);
77+
DisposeClient(bystanderNs, bystander);
78+
}
79+
}
80+
81+
[Fact]
82+
public void OwnerAlreadyHolding_RejectedLift_ResyncsPrivateChildToOwner()
83+
{
84+
var (ownerNs, owner) = CreateClient(_baseLoc);
85+
86+
var backpack = new Container(0xE75) { Layer = Layer.Backpack };
87+
owner.AddItem(backpack);
88+
var item = new Item(0x1234);
89+
backpack.DropItem(item);
90+
91+
owner.Holding = new Item(0x1);
92+
93+
try
94+
{
95+
owner.Lift(item, item.Amount, out var rejected, out var reject);
96+
97+
Assert.True(rejected);
98+
Assert.Equal(LRReason.AreHolding, reject);
99+
Assert.True(ReceivedContentUpdate(ownerNs, item.Serial));
100+
}
101+
finally
102+
{
103+
owner.Holding?.Delete();
104+
DisposeClient(ownerNs, owner);
105+
}
106+
}
107+
108+
[Fact]
109+
public void GroundItemOutOfRange_RejectedLift_DoesNotResyncRequester()
110+
{
111+
var (requesterNs, requester) = CreateClient(_baseLoc);
112+
113+
var item = new Item(0x1234);
114+
item.MoveToWorld(new Point3D(_baseLoc.X, _baseLoc.Y + 100, 0), Map.Felucca);
115+
116+
try
117+
{
118+
requester.Lift(item, item.Amount, out var rejected, out var reject);
119+
120+
Assert.True(rejected);
121+
Assert.Equal(LRReason.OutOfRange, reject);
122+
Assert.False(ReceivedWorldItem(requesterNs, item));
123+
}
124+
finally
125+
{
126+
DisposeClient(requesterNs, requester);
127+
item.Delete();
128+
}
129+
}
130+
131+
[Fact]
132+
public void GroundContainerOpener_ForcedRejection_ResyncsPrivateChildToOpener()
133+
{
134+
var (requesterNs, requester) = CreateClient(_baseLoc);
135+
136+
var container = new Container(0xE75);
137+
container.MoveToWorld(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0), Map.Felucca);
138+
var item = new Item(0x1234);
139+
container.DropItem(item);
140+
container.Openers = [requester];
141+
142+
requester.Holding = new Item(0x1);
143+
144+
try
145+
{
146+
requester.Lift(item, item.Amount, out var rejected, out var reject);
147+
148+
Assert.True(rejected);
149+
Assert.Equal(LRReason.AreHolding, reject);
150+
Assert.True(ReceivedContentUpdate(requesterNs, item.Serial));
151+
}
152+
finally
153+
{
154+
requester.Holding?.Delete();
155+
DisposeClient(requesterNs, requester);
156+
container.Delete();
157+
}
158+
}
159+
160+
[Fact]
161+
public void TradePartner_RejectedLift_ResyncsNestedItemToTradePartner()
162+
{
163+
var (aNs, a) = CreateClient(_baseLoc);
164+
var (bNs, b) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0));
165+
166+
var trade = new SecureTrade(a, b);
167+
var pouch = new Container(0xE75);
168+
trade.From.Container.DropItem(pouch);
169+
var item = new Item(0x1234);
170+
pouch.DropItem(item);
171+
172+
b.Holding = new Item(0x1);
173+
174+
try
175+
{
176+
// b is a's trade partner: not the item's root and never an Opener of the pouch, but a
177+
// sanctioned recipient of this private child through the trade relationship.
178+
b.Lift(item, item.Amount, out var rejected, out var reject);
179+
180+
Assert.True(rejected);
181+
Assert.Equal(LRReason.AreHolding, reject);
182+
Assert.True(ReceivedContentUpdate(bNs, item.Serial));
183+
}
184+
finally
185+
{
186+
b.Holding?.Delete();
187+
trade.From.Container.Delete();
188+
trade.To.Container.Delete();
189+
DisposeClient(aNs, a);
190+
DisposeClient(bNs, b);
191+
}
192+
}
193+
194+
[Fact]
195+
public void InvisibleItem_RejectedLift_DoesNotResyncToNonStaffOwner()
196+
{
197+
var (ownerNs, owner) = CreateClient(_baseLoc);
198+
199+
var backpack = new Container(0xE75) { Layer = Layer.Backpack };
200+
owner.AddItem(backpack);
201+
var item = new Item(0x1234) { Visible = false };
202+
backpack.DropItem(item);
203+
204+
owner.Holding = new Item(0x1);
205+
206+
try
207+
{
208+
owner.Lift(item, item.Amount, out var rejected, out var reject);
209+
210+
// ProcessDelta never sends an invisible item to a non-staff owner (CanSee gates every
211+
// private recipient); the rejection resync must honor the same gate.
212+
Assert.True(rejected);
213+
Assert.Equal(LRReason.AreHolding, reject);
214+
Assert.False(ReceivedContentUpdate(ownerNs, item.Serial));
215+
}
216+
finally
217+
{
218+
owner.Holding?.Delete();
219+
DisposeClient(ownerNs, owner);
220+
}
221+
}
222+
223+
[Fact]
224+
public void InvisibleItem_RejectedLift_ResyncsToStaffOwner()
225+
{
226+
var (ownerNs, owner) = CreateClient(_baseLoc);
227+
owner.AccessLevel = AccessLevel.GameMaster;
228+
229+
var backpack = new Container(0xE75) { Layer = Layer.Backpack };
230+
owner.AddItem(backpack);
231+
var item = new Item(0x1234) { Visible = false };
232+
backpack.DropItem(item);
233+
234+
owner.Holding = new Item(0x1);
235+
236+
try
237+
{
238+
owner.Lift(item, item.Amount, out var rejected, out var reject);
239+
240+
Assert.True(rejected);
241+
Assert.Equal(LRReason.AreHolding, reject);
242+
Assert.True(ReceivedContentUpdate(ownerNs, item.Serial));
243+
}
244+
finally
245+
{
246+
owner.Holding?.Delete();
247+
DisposeClient(ownerNs, owner);
248+
}
249+
}
250+
}

‎Projects/Server/Items/Item.cs‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4051,19 +4051,14 @@ public void SendRemovePacket(Point3D worldLoc)
40514051

40524052
var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket();
40534053

4054-
if (m_Parent is Container cont && !cont.IsPublicContainer && !cont.IsChildPublic(this))
4054+
if (TryGetPrivateParent(out var cont))
40554055
{
40564056
// Private children are only ever sent to these recipients (see ProcessDelta); nobody else knows them.
40574057
OutgoingEntityPackets.CreateRemoveEntity(removeEntity, Serial);
40584058

4059-
var root = cont.RootParent as Mobile;
4060-
SendRemoveTo(root, worldLoc, removeEntity);
4059+
GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
40614060

4062-
var trade = GetSecureTradeCont()?.Trade;
4063-
// Trade.From/To are unassigned while SecureTrade's own constructor is still adding the
4064-
// VirtualCheck to each side's container, so both must stay null-conditional.
4065-
var tradeFrom = trade?.From?.Mobile;
4066-
var tradeTo = trade?.To?.Mobile;
4061+
SendRemoveTo(root, worldLoc, removeEntity);
40674062

40684063
if (tradeFrom != root)
40694064
{
@@ -4113,6 +4108,58 @@ private void SendRemoveTo(Mobile m, Point3D worldLoc, ReadOnlySpan<byte> removeE
41134108
}
41144109
}
41154110

4111+
// Shared with IsSentTo so the private-child guard and recipient set (root/trade/openers) each live
4112+
// in one place.
4113+
private bool TryGetPrivateParent(out Container cont)
4114+
{
4115+
cont = m_Parent as Container;
4116+
return cont != null && !cont.IsPublicContainer && !cont.IsChildPublic(this);
4117+
}
4118+
4119+
private void GetPrivateChildRecipients(Container cont, out Mobile root, out Mobile tradeFrom, out Mobile tradeTo)
4120+
{
4121+
root = cont.RootParent as Mobile;
4122+
4123+
var trade = GetSecureTradeCont()?.Trade;
4124+
// Trade.From/To are unassigned while SecureTrade's own constructor is still adding the
4125+
// VirtualCheck to each side's container, so both must stay null-conditional.
4126+
tradeFrom = trade?.From?.Mobile;
4127+
tradeTo = trade?.To?.Mobile;
4128+
}
4129+
4130+
/// <summary>
4131+
/// Whether <paramref name="m"/>'s client is expected to hold this item; gates resends keyed by a
4132+
/// client-supplied serial.
4133+
/// </summary>
4134+
public bool IsSentTo(Mobile m)
4135+
{
4136+
if (m == null || Deleted)
4137+
{
4138+
return false;
4139+
}
4140+
4141+
if (TryGetPrivateParent(out var cont))
4142+
{
4143+
GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
4144+
4145+
if (m == root)
4146+
{
4147+
// Mirrors ProcessDelta's own root gate: CanSee and in range.
4148+
return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m));
4149+
}
4150+
4151+
var isTradeOrOpener = m == tradeFrom || m == tradeTo || cont.Openers?.Contains(m) == true;
4152+
4153+
// Trade parties and openers are re-validated against ProcessDelta's own gate (CanSee,
4154+
// current map, update range), so an invisible item or a stale Openers entry that hasn't
4155+
// been pruned yet never resyncs to them.
4156+
return isTradeOrOpener && m.CanSee(this) && m.Map == m_Map &&
4157+
m.InRange(GetWorldLocation(), GetUpdateRange(m));
4158+
}
4159+
4160+
return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m));
4161+
}
4162+
41164163
public virtual int GetDropSound() => -1;
41174164

41184165
public Point3D GetWorldLocation()

‎Projects/Server/Mobiles/Mobile.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5203,7 +5203,8 @@ public virtual void Lift(Item item, int amount, out bool rejected, out LRReason
52035203
{
52045204
state.SendLiftReject(reject);
52055205

5206-
if (item.Deleted)
5206+
// A rejection must not resynchronize state this client was never sent (client-supplied serial).
5207+
if (item.Deleted || !item.IsSentTo(this))
52075208
{
52085209
return;
52095210
}

0 commit comments

Comments
 (0)