diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index f9f5fbe15..3e22e2e66 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"modernuoschemagenerator": {
- "version": "4.0.0",
+ "version": "4.1.0",
"commands": [
"ModernUOSchemaGenerator"
]
diff --git a/CLAUDE.md b/CLAUDE.md
index c77d39e19..ba7b3f5ca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,6 +44,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
| Object property lists (tooltips) | `dev-docs/property-lists.md` |
| Gump (UI dialog) system | `dev-docs/gump-system.md` |
| Commands & targeting | `dev-docs/commands-targeting.md` |
+| Generic commands (`where`/`order by`/`distinct`, dot notation, `@""` literals, `[batch`, `[interface`) | `dev-docs/generic-commands.md` |
| Event system | `dev-docs/events.md` |
| Threading model | `dev-docs/threading-model.md` |
| Server hardware requirements | `dev-docs/server-requirements.md` |
diff --git a/Directory.Build.props b/Directory.Build.props
index c832f1006..ad8c7515a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -75,7 +75,7 @@
- 3.10.91
+ 3.10.94
all
diff --git a/Projects/Server.Tests/Helpers/MockAccount.cs b/Projects/Server.Tests/Helpers/MockAccount.cs
new file mode 100644
index 000000000..8d8973d86
--- /dev/null
+++ b/Projects/Server.Tests/Helpers/MockAccount.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using Server.Accounting;
+
+namespace Server.Tests.Network;
+
+///
+/// Minimal IAccount so a test NetState looks authenticated.
+///
+public class MockAccount : IAccount
+{
+ public int TotalGold { get; }
+ public int TotalPlat { get; }
+ public bool DepositGold(int amount) => throw new NotImplementedException();
+ public bool DepositPlat(int amount) => throw new NotImplementedException();
+ public bool WithdrawGold(int amount) => throw new NotImplementedException();
+ public bool WithdrawPlat(int amount) => throw new NotImplementedException();
+ public long GetTotalGold() => throw new NotImplementedException();
+ public int CompareTo(IAccount other) => throw new NotImplementedException();
+ public string Username { get; }
+ public string Email { get; set; }
+ public AccessLevel AccessLevel { get; set; }
+ public int Length { get; }
+ public int Limit { get; set; } = 6; // Default to 6 character slots
+ public int Count { get; }
+
+ private readonly Dictionary _mobiles = new();
+ public Mobile this[int index]
+ {
+ get => _mobiles.GetValueOrDefault(index);
+ set => _mobiles[index] = value;
+ }
+
+ public DateTime Created { get; set; }
+ public Serial Serial { get; }
+ public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
+ public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
+ public bool Deleted { get; }
+ public void Delete() => throw new NotImplementedException();
+ public bool TrySetUsername(string username) => throw new NotImplementedException();
+ public void SetPassword(string password) => throw new NotImplementedException();
+ public bool CheckPassword(string password) => throw new NotImplementedException();
+}
diff --git a/Projects/Server.Tests/Helpers/PacketTestUtilities.cs b/Projects/Server.Tests/Helpers/PacketTestUtilities.cs
index 5c6c793d4..48d344d39 100644
--- a/Projects/Server.Tests/Helpers/PacketTestUtilities.cs
+++ b/Projects/Server.Tests/Helpers/PacketTestUtilities.cs
@@ -20,7 +20,13 @@ public static class PacketTestUtilities
/// Uses a real Socket and RingSocket with actual buffers.
/// Must be disposed after use (use 'using' statement).
///
- public static NetState CreateTestNetState()
+ public static NetState CreateTestNetState() => CreateTestNetState(out _);
+
+ ///
+ /// As , also handing back the peer socket so a test can read what the
+ /// server delivered.
+ ///
+ public static NetState CreateTestNetState(out Socket client)
{
NetState.Slice(); // Process disconnects/disposes
@@ -65,6 +71,7 @@ public static class PacketTestUtilities
var testSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
testSocket.Connect(IPAddress.Loopback, _testPort);
_testSocketClients.Add(testSocket);
+ client = testSocket;
// Slice until we have a new NetState instance added
// AcceptEx is asynchronous, so we may need to wait/retry
diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj
index 4bae245cb..b1462ba37 100644
--- a/Projects/Server.Tests/Server.Tests.csproj
+++ b/Projects/Server.Tests/Server.Tests.csproj
@@ -5,9 +5,9 @@
Server.Tests
-
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs
new file mode 100644
index 000000000..b8b4fd419
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs
@@ -0,0 +1,173 @@
+using System;
+using Server.Items;
+using Xunit;
+
+namespace Server.Tests;
+
+[Collection("Sequential Server Tests")]
+public class LockedDownContainerDecayTests
+{
+ public LockedDownContainerDecayTests()
+ {
+ DecayScheduler.ResetForTests();
+
+ // Content assigns these at startup (BaseHouse); the engine tests have no content.
+ if (Item.LockedDownFlag == 0)
+ {
+ Item.LockedDownFlag = 1;
+ Item.SecureFlag = 2;
+ }
+ }
+
+ private class TestChest : Container
+ {
+ public TestChest() : base(0xE43)
+ {
+ }
+ }
+
+ private class KeepsContentsChest : Container
+ {
+ public KeepsContentsChest() : base(0xE43)
+ {
+ }
+
+ public override bool ContentsDecay => false;
+ }
+
+ private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true };
+
+ private static TestChest PlaceLockedDownChest(int x)
+ {
+ var chest = new TestChest { Movable = false };
+ chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca);
+ chest.IsLockedDown = true;
+ return chest;
+ }
+
+ [Fact]
+ public void ItemDroppedIntoLockedDownContainer_IsTracked()
+ {
+ var chest = PlaceLockedDownChest(120);
+ var loot = NewLoot();
+
+ chest.AddItem(loot);
+
+ Assert.True(loot.CanDecay());
+ Assert.True(DecayScheduler.IsRegistered(loot));
+
+ chest.Delete();
+ }
+
+ [Fact]
+ public void ItemInOrdinaryContainer_IsNotTracked()
+ {
+ var chest = new TestChest();
+ chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca);
+ var loot = NewLoot();
+
+ chest.AddItem(loot);
+
+ Assert.False(loot.CanDecay());
+ Assert.False(DecayScheduler.IsRegistered(loot));
+
+ chest.Delete();
+ }
+
+ [Fact]
+ public void LockingAndReleasingContainer_RegistersAndUnregistersContents()
+ {
+ var chest = new TestChest { Movable = false };
+ chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca);
+ var loot = NewLoot();
+ chest.AddItem(loot);
+
+ Assert.False(DecayScheduler.IsRegistered(loot));
+
+ chest.IsLockedDown = true;
+ Assert.True(DecayScheduler.IsRegistered(loot));
+
+ chest.IsSecure = true; // secure containers keep their contents
+ Assert.False(DecayScheduler.IsRegistered(loot));
+
+ chest.IsSecure = false;
+ Assert.True(DecayScheduler.IsRegistered(loot));
+
+ chest.IsLockedDown = false;
+ Assert.False(DecayScheduler.IsRegistered(loot));
+
+ chest.Delete();
+ }
+
+ [Fact]
+ public void ContainerThatKeepsContents_DoesNotTrackThem()
+ {
+ var board = new KeepsContentsChest { Movable = false };
+ board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca);
+ board.IsLockedDown = true;
+ var piece = NewLoot();
+
+ board.AddItem(piece);
+
+ Assert.False(DecayScheduler.IsRegistered(piece));
+
+ board.Delete();
+ }
+
+ [Fact]
+ public void NestedContainerContents_AreNotTracked()
+ {
+ var chest = PlaceLockedDownChest(124);
+ var bag = new TestChest();
+ chest.AddItem(bag);
+ var loot = NewLoot();
+ bag.AddItem(loot);
+
+ Assert.True(DecayScheduler.IsRegistered(bag));
+ Assert.False(DecayScheduler.IsRegistered(loot));
+
+ chest.Delete();
+ }
+
+ [Fact]
+ public void LockedDownItemInsideLockedDownContainer_IsNotTracked()
+ {
+ var chest = PlaceLockedDownChest(125);
+ var loot = NewLoot();
+ chest.AddItem(loot);
+ loot.IsLockedDown = true;
+
+ Assert.False(DecayScheduler.IsRegistered(loot));
+
+ chest.Delete();
+ }
+
+ [Fact]
+ public void TrackedContents_DecayOnSchedule()
+ {
+ var start = Core._now;
+
+ try
+ {
+ var chest = PlaceLockedDownChest(126);
+ var loot = NewLoot();
+ chest.AddItem(loot);
+
+ var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2);
+ for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256))
+ {
+ Core._now = now;
+ DecayScheduler.ProcessTick(now);
+ }
+
+ Assert.True(loot.Deleted, "Contents of a locked-down container must decay.");
+ Assert.False(chest.Deleted, "The locked-down container itself must not decay.");
+
+ chest.Delete();
+ }
+ finally
+ {
+ Core._now = start;
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs
index bfbc18e94..629ec3961 100644
--- a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs
+++ b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs
@@ -416,41 +416,6 @@ public class ClientEnumeratorTests
}
}
- private class MockAccount : IAccount
- {
- public int TotalGold { get; }
- public int TotalPlat { get; }
- public bool DepositGold(int amount) => throw new NotImplementedException();
- public bool DepositPlat(int amount) => throw new NotImplementedException();
- public bool WithdrawGold(int amount) => throw new NotImplementedException();
- public bool WithdrawPlat(int amount) => throw new NotImplementedException();
- public long GetTotalGold() => throw new NotImplementedException();
- public int CompareTo(IAccount other) => throw new NotImplementedException();
- public string Username { get; }
- public string Email { get; set; }
- public AccessLevel AccessLevel { get; set; }
- public int Length { get; }
- public int Limit { get; set; } = 6; // Default to 6 character slots
- public int Count { get; }
-
- private readonly Dictionary _mobiles = new();
- public Mobile this[int index]
- {
- get => _mobiles.GetValueOrDefault(index);
- set => _mobiles[index] = value;
- }
-
- public DateTime Created { get; set; }
- public Serial Serial { get; }
- public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
- public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
- public bool Deleted { get; }
- public void Delete() => throw new NotImplementedException();
- public bool TrySetUsername(string username) => throw new NotImplementedException();
- public void SetPassword(string password) => throw new NotImplementedException();
- public bool CheckPassword(string password) => throw new NotImplementedException();
- }
-
private static (NetState, Mobile) CreateClientWithMobile(Map map, Point3D location)
{
// Create test NetState with real socket and buffers
diff --git a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs
new file mode 100644
index 000000000..adc513e2c
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Diagnostics;
+using System.Net.Sockets;
+using Server.Network;
+using Xunit;
+
+namespace Server.Tests.Network;
+
+///
+/// Send-side behaviour of a NetState after its disconnect is handed to the socket.
+///
+[Collection("Sequential Server Tests")]
+public class NetStateDisconnectTests
+{
+ private static NetState CreateAuthenticatedNetState()
+ {
+ var ns = PacketTestUtilities.CreateTestNetState();
+ ns.Account = new MockAccount(); // skips the unattached-socket sweep
+ return ns;
+ }
+
+ private static (NetState ns, Mobile m) CreateClient(string name)
+ {
+ var ns = CreateAuthenticatedNetState();
+ var m = new Mobile(World.NewMobile) { Name = name };
+ m.DefaultMobileInit();
+ ns.Mobile = m;
+ m.NetState = ns;
+ return (ns, m);
+ }
+
+ [Fact]
+ public void Send_AfterDisconnectHandedToSocket_DropsPacket()
+ {
+ var ns = CreateAuthenticatedNetState();
+
+ try
+ {
+ ns.Disconnect("test");
+ NetState.Slice(); // handoff; the in-flight recv keeps it pending
+
+ Assert.True(ns.Running);
+ Assert.True(ns._socket.DisconnectPending);
+ Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes);
+
+ ns.Send([0x73, 0x00]);
+
+ Assert.True(ns.CannotSendPackets());
+ Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes);
+ }
+ finally
+ {
+ ns.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Send_AfterImmediateDisconnect_DropsPacket()
+ {
+ var ns = CreateAuthenticatedNetState();
+
+ // Immediate branch of RingSocket.Disconnect(): Connected drops, DisconnectPending never set,
+ // Disconnected event lands next Slice()
+ try
+ {
+ NetState.SocketManager.DisconnectImmediate(ns._socket);
+
+ Assert.True(ns.Running);
+ Assert.False(ns._socket.Connected);
+ Assert.False(ns._socket.DisconnectPending);
+
+ ns.Send([0x73, 0x00]);
+
+ Assert.True(ns.CannotSendPackets());
+ Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes);
+ }
+ finally
+ {
+ ns.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Send_BeforeDisconnect_IsDeliveredThenPeerSeesEof()
+ {
+ var ns = PacketTestUtilities.CreateTestNetState(out var client);
+ ns.Account = new MockAccount();
+
+ try
+ {
+ byte[] payload = [0x8C, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+ ns.Send(payload);
+ ns.Disconnect("redirect");
+ NetState.Slice(); // flush, then handoff
+
+ Assert.True(ns._socket.DisconnectPending);
+
+ var received = new byte[payload.Length];
+ var total = 0;
+ var deadline = Stopwatch.StartNew();
+
+ while (total < payload.Length && deadline.ElapsedMilliseconds < 5000)
+ {
+ NetState.Slice();
+ if (client.Poll(1000, SelectMode.SelectRead))
+ {
+ var read = client.Receive(received, total, payload.Length - total, SocketFlags.None);
+ Assert.NotEqual(0, read);
+ total += read;
+ }
+ }
+
+ Assert.Equal(payload, received);
+
+ // FIN follows once the send completes
+ var eof = -1;
+ while (eof != 0 && deadline.ElapsedMilliseconds < 5000)
+ {
+ NetState.Slice();
+ if (client.Poll(1000, SelectMode.SelectRead))
+ {
+ eof = client.Receive(received);
+ }
+ }
+
+ Assert.Equal(0, eof);
+ }
+ finally
+ {
+ ns.Dispose();
+ client.Close();
+ }
+ }
+
+ [Fact]
+ public void Send_LargeBeforeDisconnect_IsFullyDrainedThenPeerSeesEof()
+ {
+ var ns = PacketTestUtilities.CreateTestNetState(out var client);
+ ns.Account = new MockAccount();
+
+ try
+ {
+ // Several posts' worth, larger than the loopback kernel buffers, so the drain spans completions
+ const int chunk = 32 * 1024;
+ var payload = new byte[6 * chunk];
+ new System.Random(7).NextBytes(payload);
+
+ for (var offset = 0; offset < payload.Length; offset += chunk)
+ {
+ ns.Send(payload.AsSpan(offset, chunk));
+ }
+
+ ns.Disconnect("redirect");
+ NetState.Slice(); // flush, then handoff
+
+ Assert.True(ns._socket.DisconnectPending);
+
+ var received = new byte[payload.Length];
+ var total = 0;
+ var deadline = Stopwatch.StartNew();
+
+ while (total < payload.Length && deadline.ElapsedMilliseconds < 10000)
+ {
+ NetState.Slice();
+ if (client.Poll(1000, SelectMode.SelectRead))
+ {
+ var read = client.Receive(received, total, payload.Length - total, SocketFlags.None);
+ Assert.NotEqual(0, read);
+ total += read;
+ }
+ }
+
+ Assert.Equal(payload.Length, total);
+ Assert.Equal(payload, received);
+
+ var eof = -1;
+ while (eof != 0 && deadline.ElapsedMilliseconds < 10000)
+ {
+ NetState.Slice();
+ if (client.Poll(1000, SelectMode.SelectRead))
+ {
+ eof = client.Receive(received);
+ }
+ }
+
+ Assert.Equal(0, eof);
+ }
+ finally
+ {
+ ns.Dispose();
+ client.Close();
+ }
+ }
+
+ [Fact]
+ public void PendingDisconnect_ForceClosesAfterDrainTimeout()
+ {
+ var ns = CreateAuthenticatedNetState();
+
+ try
+ {
+ ns.Disconnect("test");
+ NetState.Slice(); // handoff; the in-flight recv keeps it pending and arms the deadline
+
+ Assert.True(ns._socket.DisconnectPending);
+ var armed = Core.TickCount;
+
+ ns.CheckAlive(armed + NetState.DrainTimeoutMs - 1);
+ Assert.True(ns._socket.Connected);
+
+ ns.CheckAlive(armed + NetState.DrainTimeoutMs);
+ Assert.False(ns._socket.Connected);
+ }
+ finally
+ {
+ ns.Dispose();
+ }
+ }
+
+ [Fact]
+ public void SendBufferExhausted_WhileDisconnectQueued_KeepsFirstReason()
+ {
+ var ns = CreateAuthenticatedNetState();
+
+ try
+ {
+ var capacity = ns._socket.SendBuffer.PhysicalSize;
+
+ ns.Send(new byte[capacity + 1]); // cannot fit; queues the disconnect
+ ns.Send(new byte[capacity + 2]); // same tick; must not re-report
+
+ Assert.Contains($"needed {capacity + 1}", ns._disconnectReason);
+ }
+ finally
+ {
+ ns.Dispose();
+ }
+ }
+
+ [Fact]
+ public void CancelAllTrades_CancelsEveryTrade()
+ {
+ var (from, fromMobile) = CreateClient("from");
+ var (to, toMobile) = CreateClient("to");
+
+ try
+ {
+ from.AddTrade(to);
+ var trade = from.FindTrade(toMobile);
+ Assert.NotNull(trade);
+ Assert.True(trade.Valid);
+
+ from.CancelAllTrades();
+
+ Assert.False(trade.Valid);
+ Assert.Null(from.Trades);
+ Assert.Null(to.Trades);
+ Assert.Null(from.FindTrade(toMobile));
+ Assert.Null(to.FindTrade(fromMobile));
+ }
+ finally
+ {
+ from.Mobile = null;
+ from.Dispose();
+ fromMobile.Delete();
+ to.Mobile = null;
+ to.Dispose();
+ toMobile.Delete();
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs
new file mode 100644
index 000000000..19a8704b7
--- /dev/null
+++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Xunit;
+
+namespace Server.Tests;
+
+public class ObjectPropertyListHashTests
+{
+ private static int BuildHash(params (int cliloc, string arg)[] properties)
+ {
+ var opl = new ObjectPropertyList(null);
+
+ foreach (var (cliloc, arg) in properties)
+ {
+ if (arg == null)
+ {
+ opl.Add(cliloc);
+ }
+ else
+ {
+ opl.Add(cliloc, arg.AsSpan());
+ }
+ }
+
+ opl.Terminate();
+ return opl.Hash;
+ }
+
+ // Two properties sharing an argument: the XOR fold mixed it in twice and cancelled it, so
+ // "10%" and "5%" produced the same revision and the client kept the stale tooltip.
+ [Fact]
+ public void RepeatedArgument_DoesNotCancelOut()
+ {
+ var ten = BuildHash(
+ (1063752, "10"),
+ (1063737, "10"),
+ (1063740, null)
+ );
+
+ var five = BuildHash(
+ (1063752, "5"),
+ (1063737, "5"),
+ (1063740, null)
+ );
+
+ Assert.NotEqual(ten, five);
+ }
+
+ // XOR is self-inverse: any value mixed in an even number of times vanished.
+ [Fact]
+ public void DuplicateProperty_ChangesHash()
+ {
+ var once = BuildHash((1060658, null));
+ var twice = BuildHash((1060658, null), (1060658, null));
+
+ Assert.NotEqual(once, twice);
+ }
+
+ // XOR is commutative, so emission order was invisible to the hash but visible in the tooltip.
+ [Fact]
+ public void PropertyOrder_ChangesHash()
+ {
+ var forward = BuildHash((1060658, "Alpha"), (1060659, "Beta"));
+ var reversed = BuildHash((1060659, "Beta"), (1060658, "Alpha"));
+
+ Assert.NotEqual(forward, reversed);
+ }
+
+ [Fact]
+ public void SwappedArguments_ChangeHash()
+ {
+ var forward = BuildHash((1063752, "10"), (1063737, "5"));
+ var swapped = BuildHash((1063752, "5"), (1063737, "10"));
+
+ Assert.NotEqual(forward, swapped);
+ }
+
+ [Fact]
+ public void IdenticalContent_ProducesIdenticalHash()
+ {
+ var first = BuildHash((1063752, "10"), (1063737, "5"), (1063740, null));
+ var second = BuildHash((1063752, "10"), (1063737, "5"), (1063740, null));
+
+ Assert.Equal(first, second);
+ }
+
+ // The client masks 0x40000000 off the 0xDC revision to match the 0xD6 hash, so the hash has
+ // to stay below that bit.
+ [Fact]
+ public void Hash_StaysWithinTheRevisionMask()
+ {
+ var opl = new ObjectPropertyList(null);
+ opl.Add(1063752, "A rather long argument that pushes the buffer past its initial size".AsSpan());
+ opl.Add(1063737, "12345");
+ opl.Add(1063740);
+ opl.Terminate();
+
+ Assert.Equal(0x40000000, opl.Hash & ~0x3FFFFFF);
+ }
+
+ // 6- and 8-byte blocks take xxHash3's short-input paths. They still avalanche across all 26
+ // kept bits, so a counter ticking down never repeats the revision it just had.
+ [Fact]
+ public void ShortNumericArguments_ConsecutiveValuesDiffer()
+ {
+ var previous = BuildHash((1060584, "0"));
+
+ for (var charges = 1; charges < 20000; charges++)
+ {
+ var current = BuildHash((1060584, charges.ToString()));
+ Assert.NotEqual(previous, current);
+ previous = current;
+ }
+ }
+
+ [Fact]
+ public void SmallPropertyBlocks_StayWellDistributed()
+ {
+ const int count = 20000;
+
+ var withArgument = new HashSet();
+ var withoutArgument = new HashSet();
+
+ for (var i = 0; i < count; i++)
+ {
+ withArgument.Add(BuildHash((1060584, i.ToString())));
+ withoutArgument.Add(BuildHash((1060000 + i, null)));
+ }
+
+ // Birthday expects ~3 collisions over a 26-bit space; allow an order of magnitude so the
+ // bound holds for any seed. A hash that stopped mixing collapses far past it.
+ Assert.True(withArgument.Count >= count - 30, $"8-byte blocks: {withArgument.Count}/{count}");
+ Assert.True(withoutArgument.Count >= count - 30, $"6-byte blocks: {withoutArgument.Count}/{count}");
+ }
+
+ [Fact]
+ public void EmptyList_IsNonZeroAndDistinctFromPopulated()
+ {
+ var empty = new ObjectPropertyList(null);
+ empty.Terminate();
+
+ Assert.NotEqual(0, empty.Hash);
+ Assert.Equal(0x40000000, empty.Hash & ~0x3FFFFFF);
+ Assert.NotEqual(empty.Hash, BuildHash((1060658, null)));
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs
index f2114a427..48ba16e55 100644
--- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs
+++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs
@@ -20,7 +20,7 @@ internal class RoundTripEntity : ISerializable
{
}
- public void Serialize(IGenericWriter writer)
+ public virtual void Serialize(IGenericWriter writer)
{
writer.Write(Value);
writer.Write(Name);
@@ -33,6 +33,15 @@ internal class RoundTripEntity : ISerializable
}
}
+internal class ThrowingRoundTripEntity : RoundTripEntity
+{
+ public ThrowingRoundTripEntity(Serial serial) : base(serial)
+ {
+ }
+
+ public override void Serialize(IGenericWriter writer) => throw new InvalidOperationException("broken serializer");
+}
+
[Collection("Sequential Server Tests")]
public class GenericEntityPersistenceRoundTripTests
{
@@ -161,4 +170,143 @@ public class GenericEntityPersistenceRoundTripTests
Directory.Delete(dir, true);
}
}
+
+ ///
+ /// A serializer throwing on a worker used to be an unhandled exception on that thread.
+ /// The worker records it, finishes the drain so the handshake completes, and the loop
+ /// fails the save.
+ ///
+ [Fact]
+ public void SerializerException_IsRecordedOnTheWorker_AndTheDrainCompletes()
+ {
+ var source = new SerializationChunkSource();
+ var workers = new SerializationThreadWorker[2];
+ for (var i = 0; i < workers.Length; i++)
+ {
+ workers[i] = new SerializationThreadWorker(i, source);
+ workers[i].AllocateHeap();
+ }
+
+ var persistence = new RoundTripPersistence(2002);
+
+ try
+ {
+ for (var i = 1; i <= 100; i++)
+ {
+ var serial = (Serial)(uint)i;
+ persistence.EntitiesBySerial[serial] = i == 50
+ ? new ThrowingRoundTripEntity(serial)
+ : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" };
+ }
+
+ foreach (var worker in workers)
+ {
+ worker.Wake();
+ }
+
+ source.SetOwner(persistence);
+ Assert.True(persistence.TrySnapshotEntries(out var slotCount));
+ source.PushSlotRanges(persistence, slotCount);
+ source.Flush();
+
+ foreach (var worker in workers)
+ {
+ worker.Sleep();
+ }
+
+ Exception error = null;
+ foreach (var worker in workers)
+ {
+ error ??= worker.Error;
+ }
+
+ Assert.IsType(error);
+ persistence.PostWorldSave();
+
+ // The next drain starts clean.
+ foreach (var worker in workers)
+ {
+ worker.Wake();
+ }
+
+ foreach (var worker in workers)
+ {
+ worker.Sleep();
+ Assert.Null(worker.Error);
+ }
+ }
+ finally
+ {
+ persistence.Unregister();
+
+ foreach (var worker in workers)
+ {
+ worker.Exit();
+ }
+ }
+ }
+
+ ///
+ /// A segment that cannot be written used to be logged and dropped, publishing a save
+ /// without those entities (the loader then deletes them). It now fails the save.
+ ///
+ [Fact]
+ public void WriteSnapshot_FailsTheSave_InsteadOfDroppingASegment()
+ {
+ var source = new SerializationChunkSource();
+ var workers = new SerializationThreadWorker[2];
+ for (var i = 0; i < workers.Length; i++)
+ {
+ workers[i] = new SerializationThreadWorker(i, source);
+ workers[i].AllocateHeap();
+ }
+
+ var previousWorkers = World._threadWorkers;
+ World._threadWorkers = workers;
+
+ var persistence = new RoundTripPersistence(2003);
+ var dir = Path.Combine(Path.GetTempPath(), $"muo-segmentfail-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+
+ try
+ {
+ for (var i = 1; i <= 100; i++)
+ {
+ var serial = (Serial)(uint)i;
+ persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" };
+ }
+
+ // Deliberately not registered: the writer cannot index the type.
+
+ foreach (var worker in workers)
+ {
+ worker.Wake();
+ }
+
+ source.SetOwner(persistence);
+ Assert.True(persistence.TrySnapshotEntries(out var slotCount));
+ source.PushSlotRanges(persistence, slotCount);
+ source.Flush();
+
+ foreach (var worker in workers)
+ {
+ worker.Sleep();
+ }
+
+ Assert.Throws(() => persistence.WriteSnapshot(dir));
+ persistence.PostWorldSave();
+ }
+ finally
+ {
+ persistence.Unregister();
+
+ foreach (var worker in workers)
+ {
+ worker.Exit();
+ }
+
+ World._threadWorkers = previousWorkers;
+ Directory.Delete(dir, true);
+ }
+ }
}
diff --git a/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs b/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs
new file mode 100644
index 000000000..2b1b3aff0
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs
@@ -0,0 +1,83 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace Server.Tests;
+
+///
+/// The publish protocol: a complete snapshot is staged next to Saves/ before the previous
+/// save is touched, and an interrupted publish is finished at the next boot or save.
+///
+[Collection("Sequential Server Tests")]
+public class StagedSavePublishTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), $"muo-staged-{Guid.NewGuid():N}");
+ private readonly string _previousSavePath;
+
+ public StagedSavePublishTests()
+ {
+ Directory.CreateDirectory(_root);
+ _previousSavePath = World.SavePath;
+ World.SetSavePathForTest(Path.Combine(_root, "Saves"));
+ }
+
+ public void Dispose()
+ {
+ World.SetSavePathForTest(_previousSavePath);
+
+ try
+ {
+ Directory.Delete(_root, true);
+ }
+ catch
+ {
+ // best effort
+ }
+ }
+
+ private static void WriteMarker(string dir, string name)
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "marker.txt"), name);
+ }
+
+ private static string ReadMarker(string dir) => File.ReadAllText(Path.Combine(dir, "marker.txt"));
+
+ [Fact]
+ public void NothingStaged_RecoveryIsANoOp()
+ {
+ WriteMarker(World.SavePath, "current");
+
+ World.RecoverStagedSave();
+
+ Assert.Equal("current", ReadMarker(World.SavePath));
+ Assert.Single(Directory.GetDirectories(_root));
+ }
+
+ [Fact]
+ public void StagedSave_ReplacesSaves_AndKeepsThePreviousOne()
+ {
+ WriteMarker(World.SavePath, "old");
+ WriteMarker(World.StagedSavePath, "new");
+
+ World.RecoverStagedSave();
+
+ Assert.Equal("new", ReadMarker(World.SavePath));
+ Assert.False(Directory.Exists(World.StagedSavePath));
+
+ var aside = Array.FindAll(Directory.GetDirectories(_root), d => d.Contains(".previous-"));
+ Assert.Single(aside);
+ Assert.Equal("old", ReadMarker(aside[0]));
+ }
+
+ [Fact]
+ public void StagedSave_WithNoSaves_IsPublished()
+ {
+ WriteMarker(World.StagedSavePath, "new");
+
+ World.RecoverStagedSave();
+
+ Assert.Equal("new", ReadMarker(World.SavePath));
+ Assert.Single(Directory.GetDirectories(_root));
+ }
+}
diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs
index 06c676b25..18aa27be5 100644
--- a/Projects/Server/Items/Container.cs
+++ b/Projects/Server/Items/Container.cs
@@ -153,6 +153,27 @@ public partial class Container : Item
public virtual bool IsDecoContainer => !Movable && !IsLockedDown && !IsSecure && Parent == null && !LiftOverride;
+ ///
+ /// True when this container's direct contents decay on their own schedule: a locked-down,
+ /// non-secure container in a house. Containers whose contents are part of the object
+ /// (game boards, aquariums) override this to false.
+ ///
+ public virtual bool ContentsDecay => IsLockedDown && !IsSecure;
+
+ ///
+ /// Re-evaluates decay registration for every direct child. Call when
+ /// may have changed.
+ ///
+ public void UpdateContentsDecayRegistration()
+ {
+ var items = Items;
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ items[i].UpdateDecayRegistration();
+ }
+ }
+
public static int GlobalMaxItems { get; set; } = 125;
public static int GlobalMaxWeight { get; set; } = 400;
diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs
index 13f249f2f..03ff9d000 100644
--- a/Projects/Server/Items/Item.cs
+++ b/Projects/Server/Items/Item.cs
@@ -426,8 +426,14 @@ public partial class Item : IHued, IComparable- , ISpawnable, IObjectPropert
get => GetTempFlag(LockedDownFlag);
set
{
+ if (GetTempFlag(LockedDownFlag) == value)
+ {
+ return;
+ }
+
SetTempFlag(LockedDownFlag, value);
InvalidateProperties();
+ OnSecurityChanged();
}
}
@@ -437,8 +443,26 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
get => GetTempFlag(SecureFlag);
set
{
+ if (GetTempFlag(SecureFlag) == value)
+ {
+ return;
+ }
+
SetTempFlag(SecureFlag, value);
InvalidateProperties();
+ OnSecurityChanged();
+ }
+ }
+
+ // Lockdown and secure status decide whether this item and, for a container, its direct
+ // contents are decay-eligible (see CanDecay); re-evaluate both.
+ private void OnSecurityChanged()
+ {
+ UpdateDecayRegistration();
+
+ if (this is Container container)
+ {
+ container.UpdateContentsDecayRegistration();
}
}
@@ -2351,10 +2375,15 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
public bool AtPoint(int x, int y) => m_Location.m_X == x && m_Location.m_Y == y;
- public virtual bool CanDecay() => Decays && Parent == null && Map != Map.Internal;
+ // Ground items decay; so do the direct contents of a container whose ContentsDecay is true
+ // (a locked-down, non-secure house container), unless the content item is itself locked
+ // down or secured. Nested containers do not propagate: only direct children qualify.
+ public virtual bool CanDecay() =>
+ Decays && Map != Map.Internal &&
+ (Parent == null || Parent is Container { ContentsDecay: true } && !IsLockedDown && !IsSecure);
public virtual bool OnDecay() =>
- CanDecay() && Region.Find(Location, Map).OnDecay(this);
+ CanDecay() && Region.Find(GetWorldLocation(), Map).OnDecay(this);
public DateTime ScheduledDecayTime
{
diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs
index f69f92076..5bedb504c 100644
--- a/Projects/Server/Network/NetState/NetState.Network.cs
+++ b/Projects/Server/Network/NetState/NetState.Network.cs
@@ -61,6 +61,9 @@ public partial class NetState
///
public static IIORingGroup Ring => _socketManager?.Ring;
+ // Test hook
+ internal static RingSocketManager SocketManager => _socketManager;
+
///
/// Waits for network I/O completions or until the specified timeout expires.
/// Used by the game loop to sleep efficiently while remaining responsive to network events.
@@ -107,6 +110,9 @@ public partial class NetState
return;
}
+ // Seed from a real tick; a zero default suppresses the sweep when ticks start negative
+ _nextAliveCheck = Core.TickCount;
+
// Initialize IP rate limiter
_ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token);
@@ -539,6 +545,11 @@ public partial class NetState
// - Waits for in-flight I/O to complete
// - Ensures buffers aren't released while kernel is still using them
ns._socket.Disconnect();
+
+ if (ns._socket.DisconnectPending)
+ {
+ ns.ArmDrainDeadline(curTicks);
+ }
}
}
diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs
index 04f1a5a76..e161462fc 100755
--- a/Projects/Server/Network/NetState/NetState.cs
+++ b/Projects/Server/Network/NetState/NetState.cs
@@ -36,6 +36,7 @@ public partial class NetState : IComparable, IValueLinkListNode _flushPending = new(2048);
private static readonly Queue _pendingDisconnects = new(256); // Processed AFTER flush
@@ -54,7 +55,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode= 0; --i)
{
- if (Trades != null)
+ // RemoveTrade() nulls the list once empty
+ if (Trades == null)
{
break;
}
@@ -489,6 +493,12 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode
private void SendBufferExhausted(int needed, int writable)
{
+ // One report per disconnect; the first reason wins
+ if (_disconnectQueued)
+ {
+ return;
+ }
+
var sendBuffer = _socket?.SendBuffer;
var unacked = sendBuffer?.InFlightBytes ?? 0;
var capacity = sendBuffer?.PhysicalSize ?? 0;
@@ -1053,31 +1069,52 @@ public partial class NetState : IComparable, IValueLinkListNode= 0)
+ if (_socket == null)
{
return;
}
if (_socket.DisconnectPending)
{
- LogInfo("Force disconnecting stuck socket...");
- _socketManager.DisconnectImmediate(_socket);
- }
- else
- {
- // Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
- // The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
- if (_account != null && Mobile == null)
+ ArmDrainDeadline(curTicks); // transport-initiated drains are first seen here
+
+ if (curTicks - _drainDeadline >= 0 || NextActivityCheck - curTicks < 0)
{
- this.SendClientVersionRequest();
- return;
+ LogInfo("Force disconnecting stuck socket...");
+ _socketManager.DisconnectImmediate(_socket);
}
- LogInfo("Disconnecting due to inactivity...");
- Disconnect("Disconnecting due to inactivity.");
+ return;
}
+
+ if (NextActivityCheck - curTicks >= 0)
+ {
+ return;
+ }
+
+ // Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
+ // The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
+ if (_account != null && Mobile == null)
+ {
+ this.SendClientVersionRequest();
+ return;
+ }
+
+ LogInfo("Disconnecting due to inactivity...");
+ Disconnect("Disconnecting due to inactivity.");
}
public void Trace(ReadOnlySpan buffer)
@@ -1123,8 +1160,8 @@ public partial class NetState : IComparable, IValueLinkListNode
- /// Requests a graceful disconnect. The disconnect is queued and processed after the flush
- /// queue in Slice(), ensuring Send() calls made in the same tick are processed first.
+ /// Requests a graceful disconnect. Processed after the flush queue in Slice(): sends made before
+ /// that handoff are flushed first, sends after it are dropped (see CannotSendPackets).
///
public void Disconnect(string reason)
{
diff --git a/Projects/Server/Network/Packets/OutgoingPackets.cs b/Projects/Server/Network/Packets/OutgoingPackets.cs
index ce2cce0f4..23b8f15c6 100644
--- a/Projects/Server/Network/Packets/OutgoingPackets.cs
+++ b/Projects/Server/Network/Packets/OutgoingPackets.cs
@@ -6,8 +6,8 @@ public static class OutgoingPackets
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool CannotSendPackets(this NetState ns) =>
- // Do not check for NetState.Running. Packets are sent to a "disconnected" socket as part of the OnDisconnect events
- // up until the socket is closed. Closing the connection is done synchronously, therefore packets will not be sent
- // once the Mobile.NetState is null.
- ns == null || ns.SocketHandle == 0 || ns.BlockAllPackets;
+ // Running is not checked: sends between Disconnect() and the Slice() handoff must still go out.
+ // After the handoff the socket is draining (DisconnectPending) or closing (!Connected); new writes
+ // only keep the buffer from draining.
+ ns == null || ns.SocketHandle == 0 || !ns._socket.Connected || ns._socket.DisconnectPending || ns.BlockAllPackets;
}
diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs
index 58b247990..d1955ad2d 100644
--- a/Projects/Server/PropertyList/ObjectPropertyList.cs
+++ b/Projects/Server/PropertyList/ObjectPropertyList.cs
@@ -46,6 +46,13 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
// under the empirically confirmed ~510-char ceiling. For multi-line content use AddChunked().
public const int MaxArgumentLength = 504;
+ // 0xD6 header: packet id, length, unknown, serial, unknown, hash. Properties start after it.
+ private const int HeaderLength = 15;
+
+ // Terminate writes the bare hash into 0xD6, SendOPLInfo writes Hash with bit 30 set, and the
+ // client recovers one from the other by masking off 0x40000000. The hash must stay below it.
+ private const int HashMask = 0x3FFFFFF;
+
private int _hash;
private int _stringNumbersIndex;
private byte[] _buffer;
@@ -89,7 +96,7 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void Reset()
{
- _bufferPos = 15;
+ _bufferPos = HeaderLength;
_hash = 0;
_stringNumbersIndex = 0;
Header = 0;
@@ -120,6 +127,11 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
Resize(length);
}
+ // xxHash3 over the finished property block. Order and repetition sensitive, unlike the
+ // XOR fold it replaces, which collided whenever properties were reordered or shared an
+ // argument.
+ _hash = (int)(HashUtility.ComputeHash64(_buffer.AsSpan(HeaderLength, _bufferPos - HeaderLength)) & HashMask);
+
var writer = new SpanWriter(_buffer);
writer.Seek(_bufferPos, SeekOrigin.Begin);
writer.Write(0);
@@ -129,12 +141,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
writer.WritePacketLength();
}
- private void AddHash(int val)
- {
- _hash ^= val & 0x3FFFFFF;
- _hash ^= (val >> 26) & 0x3F;
- }
-
public void Add(int number)
{
if (number == 0)
@@ -148,8 +154,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
HeaderArgs = "";
}
- AddHash(number);
-
var length = _bufferPos + 6;
while (length > _buffer.Length)
{
@@ -245,9 +249,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
HeaderArgs = chars.ToString();
}
- AddHash(number);
- AddHash(string.GetHashCode(chars, StringComparison.Ordinal));
-
var strLength = chars.Length * 2;
var length = _bufferPos + 6 + strLength;
while (length > _buffer.Length)
@@ -295,9 +296,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
HeaderArgs = chars.ToString();
}
- AddHash(number);
- AddHash(string.GetHashCode(chars, StringComparison.Ordinal));
-
var strLength = chars.Length * 2;
var length = _bufferPos + 6 + strLength;
while (length > _buffer.Length)
diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs
index c7323329f..70e9c6aab 100644
--- a/Projects/Server/Serialization/GenericEntityPersistence.cs
+++ b/Projects/Server/Serialization/GenericEntityPersistence.cs
@@ -28,7 +28,17 @@ namespace Server;
public interface IGenericEntityPersistence
{
+ string Name { get; }
+
+ int EntityCount { get; }
+
void DeserializeIndexes(string savePath, Dictionary typesDb);
+
+ ///
+ /// Enumerates the live entities. Diagnostics only: the dictionary must not be mutated while
+ /// enumerating, so callers snapshot the sequence before doing anything that can add or delete.
+ ///
+ IEnumerable EnumerateEntities();
}
public class GenericEntityPersistence : GenericPersistence, IGenericEntityPersistence, ISlotRangeSource
@@ -85,6 +95,16 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
public Dictionary EntitiesBySerial { get; } = new();
+ public int EntityCount => EntitiesBySerial.Count;
+
+ public IEnumerable EnumerateEntities()
+ {
+ foreach (var entity in EntitiesBySerial.Values)
+ {
+ yield return entity;
+ }
+ }
+
public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this(
name,
priority,
@@ -138,6 +158,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
_selfPosition,
_selfLength
);
+ throw;
}
binPosition += _selfLength;
@@ -185,6 +206,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
}
catch (Exception error)
{
+ // Never publish a partial snapshot: entities missing from the idx are deleted on load.
logger.Error(
error,
"Error writing segment: (Thread: {Thread} - {Start}, {Records} records)",
@@ -192,6 +214,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
segment.HeapStart,
segment.RecordCount
);
+ throw;
}
}
}
@@ -298,9 +321,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
private ushort GetTypeIndex(T entity)
{
// Every path into EntitiesBySerial registers the type first, so this cannot fire.
- // If it ever does, the segment-level catch in WriteSnapshot logs it and moves on —
- // the failed segment's records are dropped from the idx while binPosition rewinds,
- // so treat any occurrence as a serious bug in an insertion path, not a bad entity.
+ // If it does, the save fails; treat it as a bug in an insertion path, not a bad entity.
if (!_typeIndexes.TryGetValue(entity.GetType(), out var typeIndex))
{
throw new InvalidOperationException(
diff --git a/Projects/Server/Serialization/Persistence.cs b/Projects/Server/Serialization/Persistence.cs
index 35d1d7514..edd1b519c 100644
--- a/Projects/Server/Serialization/Persistence.cs
+++ b/Projects/Server/Serialization/Persistence.cs
@@ -34,6 +34,21 @@ public abstract class Persistence
public bool Register() => _registry.Add(this);
+ /// Every registered entity persistence (Items, Mobiles, Guilds, Accounts, ...), in priority order.
+ public static IEnumerable EntityPersistences
+ {
+ get
+ {
+ foreach (var entry in _registry)
+ {
+ if (entry is IGenericEntityPersistence entityPersistence)
+ {
+ yield return entityPersistence;
+ }
+ }
+ }
+ }
+
public void Unregister() => _registry.Remove(this);
public static void Load(string path)
diff --git a/Projects/Server/Serialization/SerializationThreadWorker.cs b/Projects/Server/Serialization/SerializationThreadWorker.cs
index 5a9b5ed1c..74973a80c 100644
--- a/Projects/Server/Serialization/SerializationThreadWorker.cs
+++ b/Projects/Server/Serialization/SerializationThreadWorker.cs
@@ -78,6 +78,12 @@ public class SerializationThreadWorker
internal List Lengths => _lengths;
internal List BufferEntities => _bufferEntities;
+ ///
+ /// First serializer exception during the drain. The drain continues so the handshake
+ /// completes; the loop fails the save once every worker has paused.
+ ///
+ public Exception Error { get; private set; }
+
///
/// Releases the write logs after the snapshot is written so serialized entity
/// references don't linger between saves. Capacity is retained: the logs regrow to
@@ -154,6 +160,25 @@ public class SerializationThreadWorker
public ReadOnlySpan GetHeap(int start, int length) => _heap.AsSpan(start, length);
private long ProcessChunk(in SerializationChunkSource.Chunk chunk, BufferWriter writer)
+ {
+ try
+ {
+ return ProcessChunkCore(in chunk, writer);
+ }
+ catch (Exception ex)
+ {
+ Error ??= ex;
+
+ if (chunk.Buffer != null)
+ {
+ _chunkSource.Return(chunk.Buffer, chunk.Count);
+ }
+
+ return 0;
+ }
+ }
+
+ private long ProcessChunkCore(in SerializationChunkSource.Chunk chunk, BufferWriter writer)
{
if (chunk.Single != null)
{
@@ -213,6 +238,7 @@ public class SerializationThreadWorker
public void DrainInline()
{
ReleaseWriteLogs();
+ Error = null;
var writer = new BufferWriter(_heap, true);
var entities = 0L;
@@ -238,6 +264,7 @@ public class SerializationThreadWorker
while (worker._startEvent.WaitOne())
{
worker.ReleaseWriteLogs();
+ worker.Error = null;
var writer = new BufferWriter(worker._heap, true);
var entities = 0L;
diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj
index fd69557b7..030197955 100644
--- a/Projects/Server/Server.csproj
+++ b/Projects/Server/Server.csproj
@@ -34,13 +34,13 @@
-
+
-
+
-
-
+
+
@@ -50,5 +50,8 @@
<_Parameter1>UOContent.Tests
+
+ <_Parameter1>ModernSpawner.Tests
+
diff --git a/Projects/Server/Text/TextDefinition.cs b/Projects/Server/Text/TextDefinition.cs
index 4951c2290..0dfcc7ba8 100644
--- a/Projects/Server/Text/TextDefinition.cs
+++ b/Projects/Server/Text/TextDefinition.cs
@@ -68,7 +68,26 @@ public class TextDefinition : IEquatable