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/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/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs
index c7323329f..9a1490504 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,
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/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs
new file mode 100644
index 000000000..f596282f6
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs
@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using Server;
+using Server.Guilds;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class GuildSerializePurityTests
+{
+ private static PlayerMobile CreatePlayer()
+ {
+ var m = new PlayerMobile(World.NewMobile);
+ m.DefaultMobileInit();
+ m.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
+ return m;
+ }
+
+ [Fact]
+ public void SerializeDoesNotRecalculateFealtyOrMutateState()
+ {
+ var leader = CreatePlayer();
+ var guild = new Guild(leader, "Purity Test Guild", "PTG");
+ var staleFealty = Core.Now - TimeSpan.FromDays(3);
+ guild.LastFealty = staleFealty;
+
+ var writer = new BufferWriter(true);
+ guild.Serialize(writer);
+
+ Assert.Equal(staleFealty, guild.LastFealty);
+ Assert.Same(leader, guild.Leader);
+
+ var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
+ writer.Seek(0, SeekOrigin.Begin);
+ guild.Serialize(writer);
+ var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
+
+ Assert.Equal(first, second);
+
+ writer.Close();
+ guild.Disband();
+ leader.Delete();
+ }
+
+ [Fact]
+ public void RunMaintenanceRecalculatesStaleFealty()
+ {
+ var leader = CreatePlayer();
+ var guild = new Guild(leader, "Maintenance Test Guild", "MTG");
+ guild.LastFealty = Core.Now - TimeSpan.FromDays(3);
+
+ guild.RunMaintenance();
+
+ Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1));
+
+ guild.Disband();
+ leader.Delete();
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs b/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs
new file mode 100644
index 000000000..f8e1cb3f2
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs
@@ -0,0 +1,42 @@
+using System;
+using Server;
+using Server.Mobiles;
+using Server.Multis;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class HouseDecayPurityTests
+{
+ [Fact]
+ public void ReadingDecayLevelDoesNotStampLastRefreshed()
+ {
+ var decayEnabled = BaseHouse.DecayEnabled;
+ BaseHouse.DecayEnabled = false; // every house is Ageless, the branch that used to restamp on read
+
+ var owner = new PlayerMobile(World.NewMobile);
+ owner.DefaultMobileInit();
+ owner.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca);
+
+ var house = new SmallOldHouse(owner, 0x64);
+
+ try
+ {
+ house.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca);
+
+ var placed = Core.Now - TimeSpan.FromDays(2);
+ house.LastRefreshed = placed;
+
+ Assert.False(house.CanDecay);
+ Assert.Equal(DecayLevel.Ageless, house.DecayLevel);
+ Assert.Equal(placed, house.LastRefreshed);
+ }
+ finally
+ {
+ house.Delete();
+ owner.Delete();
+ BaseHouse.DecayEnabled = decayEnabled;
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
new file mode 100644
index 000000000..978adcc3f
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Commands;
+using Server.Items;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class SaveStabilityTests
+{
+ // Serializes the current time, the classic way a record drifts between saves with no mutation.
+ private sealed class ClockStampedItem : Item
+ {
+ public ClockStampedItem() : base(0x1F03)
+ {
+ }
+
+ public ClockStampedItem(Serial serial) : base(serial)
+ {
+ }
+
+ public override void Serialize(IGenericWriter writer)
+ {
+ base.Serialize(writer);
+ writer.Write(Core.Now);
+ }
+
+ public override void Deserialize(IGenericReader reader)
+ {
+ base.Deserialize(reader);
+ reader.ReadDateTime();
+ }
+ }
+
+ private sealed class CreatureStub : BaseCreature
+ {
+ public CreatureStub() : base(AIType.AI_Animal, FightMode.Closest, 10, 1)
+ {
+ Body = 0xEE; // rat
+ }
+
+ public CreatureStub(Serial serial) : base(serial)
+ {
+ }
+
+ // NPCSpeeds is not configured in the test fixture; the AIType ctor would hit the empty table.
+ public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
+ {
+ activeSpeed = 0.2;
+ passiveSpeed = 0.4;
+ }
+ }
+
+ [Fact]
+ public void StableEntitiesHashIdenticallyAfterTimePasses()
+ {
+ var bag = new Bag();
+ bag.DropItem(new Gold(100));
+ bag.DropItem(new Dagger());
+ bag.MoveToWorld(new Point3D(1450, 1450, 0), Map.Felucca);
+
+ var creature = new CreatureStub();
+ creature.MoveToWorld(new Point3D(1451, 1450, 0), Map.Felucca);
+
+ var entities = new List { bag, creature };
+ entities.AddRange(bag.Items);
+
+ var now = Core._now;
+
+ try
+ {
+ var captured = SaveStability.CaptureHashes(entities);
+ Core._now = now + TimeSpan.FromMinutes(5);
+
+ var report = SaveStability.Compare(entities, captured);
+
+ Assert.Equal(entities.Count, report.Checked);
+ Assert.Equal(0, report.Changed);
+ }
+ finally
+ {
+ Core._now = now;
+ bag.Delete();
+ creature.Delete();
+ }
+ }
+
+ [Fact]
+ public void ClockDerivedRecordIsReportedByType()
+ {
+ var item = new ClockStampedItem();
+ item.MoveToWorld(new Point3D(1452, 1450, 0), Map.Felucca);
+
+ var now = Core._now;
+
+ try
+ {
+ var entities = new List { item };
+ var captured = SaveStability.CaptureHashes(entities);
+
+ // Same tick: the drift is invisible, which is why the command waits in real time.
+ Assert.Equal(0, SaveStability.Compare(entities, captured).Changed);
+
+ Core._now = now + TimeSpan.FromMinutes(1);
+ var report = SaveStability.Compare(entities, captured);
+
+ Assert.Equal(1, report.Changed);
+ Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Changed);
+ Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Total);
+ }
+ finally
+ {
+ Core._now = now;
+ item.Delete();
+ }
+ }
+
+ [Fact]
+ public void SnapshotCoversEveryRegisteredEntityPersistence()
+ {
+ var item = new Bag();
+ item.MoveToWorld(new Point3D(1453, 1450, 0), Map.Felucca);
+ var mobile = new CreatureStub();
+ mobile.MoveToWorld(new Point3D(1454, 1450, 0), Map.Felucca);
+
+ try
+ {
+ var snapshot = SaveStability.SnapshotEntities();
+
+ Assert.Contains(item, snapshot);
+ Assert.Contains(mobile, snapshot);
+
+ var names = new List();
+ foreach (var persistence in Persistence.EntityPersistences)
+ {
+ names.Add(persistence.Name);
+ }
+
+ Assert.Contains("Items", names);
+ Assert.Contains("Mobiles", names);
+ Assert.Contains("Guilds", names);
+ }
+ finally
+ {
+ item.Delete();
+ mobile.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent/Commands/SaveStabilityCommand.cs b/Projects/UOContent/Commands/SaveStabilityCommand.cs
new file mode 100644
index 000000000..0a0cd6d0f
--- /dev/null
+++ b/Projects/UOContent/Commands/SaveStabilityCommand.cs
@@ -0,0 +1,318 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using Server.Logging;
+
+namespace Server.Commands;
+
+///
+/// Measures whether entities serialize to the same bytes over time when nothing changed them.
+/// Hashes every entity of every registered entity persistence, waits, hashes again, and reports
+/// per type how many records changed. Both passes are chunked across ticks so the loop never
+/// stalls, and the wait spans real time so anything derived from (which
+/// is frozen within a tick) shows up. On an idle shard the only legitimate churn is NPC movement
+/// and regeneration; a static type with a high changed fraction is a serialization bug.
+///
+public static class SaveStability
+{
+ private static readonly ILogger logger = LogFactory.GetLogger(typeof(SaveStability));
+
+ private const int DefaultDelaySeconds = 60;
+ private static readonly TimeSpan TickBudget = TimeSpan.FromMilliseconds(20);
+
+ private static Run _current;
+
+ public sealed class TypeStats
+ {
+ public int Total;
+ public int Changed;
+ }
+
+ public sealed record SaveStabilityReport(
+ int Checked,
+ int Changed,
+ Dictionary ByType
+ );
+
+ public static void Configure()
+ {
+ CommandSystem.Register("SaveStability", AccessLevel.Administrator, SaveStability_OnCommand);
+ }
+
+ [Usage("SaveStability [delaySeconds=60] [sampleStride=1] | SaveStability cancel")]
+ [Description("Hashes every entity, waits, hashes again, and reports the types whose serialized bytes changed.")]
+ private static void SaveStability_OnCommand(CommandEventArgs e)
+ {
+ var from = e.Mobile;
+
+ if (e.Length > 0 && e.GetString(0).InsensitiveEquals("cancel"))
+ {
+ if (_current != null)
+ {
+ _current.Cancel();
+ _current = null;
+ from.SendMessage("Save stability check cancelled.");
+ }
+ else
+ {
+ from.SendMessage("No save stability check is running.");
+ }
+
+ return;
+ }
+
+ if (_current != null)
+ {
+ from.SendMessage("A save stability check is already running. Use [SaveStability cancel to stop it.");
+ return;
+ }
+
+ var delaySeconds = e.Length > 0 ? e.GetInt32(0) : DefaultDelaySeconds;
+ var stride = e.Length > 1 ? e.GetInt32(1) : 1;
+
+ if (delaySeconds < 1 || stride < 1)
+ {
+ from.SendMessage("Usage: [SaveStability [delaySeconds] [sampleStride]");
+ return;
+ }
+
+ _current = new Run(from, TimeSpan.FromSeconds(delaySeconds), stride);
+ _current.Start();
+ }
+
+ /// Snapshots every entity of every registered entity persistence, taking every Nth.
+ public static List SnapshotEntities(int stride = 1)
+ {
+ var list = new List();
+ var i = 0;
+
+ foreach (var persistence in Persistence.EntityPersistences)
+ {
+ foreach (var entity in persistence.EnumerateEntities())
+ {
+ if (i++ % stride == 0)
+ {
+ list.Add(entity);
+ }
+ }
+ }
+
+ return list;
+ }
+
+ /// Hashes the serialized bytes of every entity in order; deleted entities hash to 0.
+ public static ulong[] CaptureHashes(List entities)
+ {
+ var hashes = new ulong[entities.Count];
+ var writer = new BufferWriter(true);
+
+ for (var i = 0; i < entities.Count; i++)
+ {
+ hashes[i] = Hash(entities[i], writer);
+ }
+
+ writer.Close();
+ return hashes;
+ }
+
+ /// Re-hashes every entity and reports, per type, how many differ from the captured hashes.
+ public static SaveStabilityReport Compare(List entities, ulong[] captured)
+ {
+ var writer = new BufferWriter(true);
+ var report = new SaveStabilityReport(0, 0, new Dictionary());
+ var checkedCount = 0;
+ var changed = 0;
+
+ for (var i = 0; i < entities.Count; i++)
+ {
+ var entity = entities[i];
+ if (entity.Deleted || captured[i] == 0)
+ {
+ continue;
+ }
+
+ checkedCount++;
+ var type = entity.GetType();
+
+ if (!report.ByType.TryGetValue(type, out var stats))
+ {
+ report.ByType[type] = stats = new TypeStats();
+ }
+
+ stats.Total++;
+
+ if (Hash(entity, writer) != captured[i])
+ {
+ stats.Changed++;
+ changed++;
+ }
+ }
+
+ writer.Close();
+ return report with { Checked = checkedCount, Changed = changed };
+ }
+
+ private static ulong Hash(ISerializable entity, BufferWriter writer)
+ {
+ if (entity.Deleted)
+ {
+ return 0;
+ }
+
+ writer.Seek(0, SeekOrigin.Begin);
+ entity.Serialize(writer);
+ return HashUtility.ComputeHash64(writer.Buffer.AsSpan(0, (int)writer.Position));
+ }
+
+ // Drives capture -> wait -> compare across ticks under a fixed time budget per tick.
+ private sealed class Run
+ {
+ private readonly Mobile _from;
+ private readonly TimeSpan _delay;
+ private readonly int _stride;
+ private readonly BufferWriter _writer = new(true);
+
+ private List _entities;
+ private ulong[] _hashes;
+ private SaveStabilityReport _report;
+ private int _index;
+ private bool _comparing;
+ private Timer _timer;
+
+ public Run(Mobile from, TimeSpan delay, int stride)
+ {
+ _from = from;
+ _delay = delay;
+ _stride = stride;
+ }
+
+ public void Start()
+ {
+ _entities = SnapshotEntities(_stride);
+ _hashes = new ulong[_entities.Count];
+
+ _from.SendMessage($"Save stability: hashing {_entities.Count} entities across ticks...");
+ _timer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.Zero, Step);
+ }
+
+ public void Cancel()
+ {
+ _timer?.Stop();
+ _timer = null;
+ _writer.Close();
+ }
+
+ private void Step()
+ {
+ var started = Stopwatch.GetTimestamp();
+
+ while (_index < _entities.Count)
+ {
+ var entity = _entities[_index];
+
+ if (_comparing)
+ {
+ CompareOne(entity, _index);
+ }
+ else
+ {
+ _hashes[_index] = Hash(entity, _writer);
+ }
+
+ _index++;
+
+ if (Stopwatch.GetElapsedTime(started) >= TickBudget)
+ {
+ return;
+ }
+ }
+
+ _timer.Stop();
+ _timer = null;
+
+ if (!_comparing)
+ {
+ _from.SendMessage($"Save stability: captured. Comparing again in {_delay.TotalSeconds:F0}s.");
+ _comparing = true;
+ _index = 0;
+ _report = new SaveStabilityReport(0, 0, new Dictionary());
+ _timer = Timer.DelayCall(_delay, TimeSpan.Zero, Step);
+ return;
+ }
+
+ Finish();
+ }
+
+ private void CompareOne(ISerializable entity, int index)
+ {
+ if (entity.Deleted || _hashes[index] == 0)
+ {
+ return;
+ }
+
+ var type = entity.GetType();
+
+ if (!_report.ByType.TryGetValue(type, out var stats))
+ {
+ _report.ByType[type] = stats = new TypeStats();
+ }
+
+ stats.Total++;
+
+ if (Hash(entity, _writer) != _hashes[index])
+ {
+ stats.Changed++;
+ }
+ }
+
+ private void Finish()
+ {
+ _writer.Close();
+ _current = null;
+
+ var checkedCount = 0;
+ var changed = 0;
+ var ranked = new List>();
+
+ foreach (var pair in _report.ByType)
+ {
+ checkedCount += pair.Value.Total;
+ changed += pair.Value.Changed;
+
+ if (pair.Value.Changed > 0)
+ {
+ ranked.Add(pair);
+ }
+ }
+
+ ranked.Sort(static (a, b) => b.Value.Changed.CompareTo(a.Value.Changed));
+
+ _from.SendMessage($"Save stability: {changed} of {checkedCount} entities changed over {_delay.TotalSeconds:F0}s.");
+ logger.Information(
+ "Save stability: {Changed} of {Checked} entities changed over {Delay}s ({Types} types)",
+ changed,
+ checkedCount,
+ _delay.TotalSeconds,
+ ranked.Count
+ );
+
+ var shown = 0;
+ foreach (var (type, stats) in ranked)
+ {
+ var percent = stats.Changed * 100.0 / stats.Total;
+ logger.Information(" {Type}: {Changed}/{Total} ({Percent:F1}%)", type.FullName, stats.Changed, stats.Total, percent);
+
+ if (shown++ < 25)
+ {
+ _from.SendMessage($" {type.Name}: {stats.Changed}/{stats.Total} ({percent:F1}%)");
+ }
+ }
+
+ if (ranked.Count > 25)
+ {
+ _from.SendMessage($" ...{ranked.Count - 25} more types in the log.");
+ }
+ }
+ }
+}
diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs
index 054fea588..39134e523 100644
--- a/Projects/UOContent/Items/Aquarium/Aquarium.cs
+++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs
@@ -13,6 +13,8 @@ namespace Server.Items
[SerializationGenerator(4, false)]
public partial class Aquarium : BaseAddonContainer
{
+ public override bool ContentsDecay => false; // fish and decorations are part of the aquarium
+
public static readonly TimeSpan EvaluationInterval = TimeSpan.FromDays(1);
private static readonly Type[] m_Decorations =
diff --git a/Projects/UOContent/Items/Aquarium/FishBowl.cs b/Projects/UOContent/Items/Aquarium/FishBowl.cs
index 9d8375551..1cf2b7507 100644
--- a/Projects/UOContent/Items/Aquarium/FishBowl.cs
+++ b/Projects/UOContent/Items/Aquarium/FishBowl.cs
@@ -8,6 +8,8 @@ namespace Server.Items
[SerializationGenerator(0, false)]
public partial class FishBowl : BaseContainer
{
+ public override bool ContentsDecay => false; // the fish is part of the bowl
+
[Constructible]
public FishBowl() : base(0x241C)
{
diff --git a/Projects/UOContent/Items/Games/BaseBoard.cs b/Projects/UOContent/Items/Games/BaseBoard.cs
index b13d7dde2..be437f9d9 100644
--- a/Projects/UOContent/Items/Games/BaseBoard.cs
+++ b/Projects/UOContent/Items/Games/BaseBoard.cs
@@ -11,6 +11,8 @@ namespace Server.Items;
[SerializationGenerator(2, false)]
public abstract partial class BaseBoard : Container, ISecurable
{
+ public override bool ContentsDecay => false; // pieces are part of the board
+
[SerializedIgnoreDupe]
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs
index 1d508c4ac..6f8d26940 100644
--- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs
+++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs
@@ -53,16 +53,15 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem
[CommandProperty(AccessLevel.GameMaster)]
public BraceletOfBinding Bound
{
- get
+ get => _bound?.Deleted != false ? null : _bound;
+ set
{
- if (_bound?.Deleted == true)
+ if (_bound != value)
{
- _bound = null;
+ _bound = value;
+ this.MarkDirty();
}
-
- return _bound;
}
- set => _bound = value;
}
[CommandProperty(AccessLevel.GameMaster)]
diff --git a/Projects/UOContent/Misc/Guild.cs b/Projects/UOContent/Misc/Guild.cs
index 88ec44773..b2ce6d428 100644
--- a/Projects/UOContent/Misc/Guild.cs
+++ b/Projects/UOContent/Misc/Guild.cs
@@ -498,25 +498,22 @@ namespace Server.Guilds
}
}
- public class WarTimer : Timer
+ public class GuildMaintenanceTimer : Timer
{
- public WarTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0))
+ public GuildMaintenanceTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0))
{
}
public static void Initialize()
{
- if (Guild.NewGuildSystem)
- {
- new WarTimer().Start();
- }
+ new GuildMaintenanceTimer().Start();
}
protected override void OnTick()
{
foreach (var g in World.Guilds.Values)
{
- (g as Guild)?.CheckExpiredWars();
+ (g as Guild)?.RunMaintenance();
}
}
}
@@ -1100,8 +1097,18 @@ namespace Server.Guilds
list.TrimExcess();
}
- public override void Serialize(IGenericWriter writer)
+ ///
+ /// Periodic bookkeeping that used to ride on every world save: the daily fealty
+ /// recalculation, war expiry, and the alliance leadership check. Saves must be pure,
+ /// so this runs from instead.
+ ///
+ public void RunMaintenance()
{
+ if (Disbanded)
+ {
+ return;
+ }
+
if (LastFealty + TimeSpan.FromDays(1.0) < Core.Now)
{
CalculateGuildmaster();
@@ -1110,7 +1117,10 @@ namespace Server.Guilds
CheckExpiredWars();
Alliance?.CheckLeader();
+ }
+ public override void Serialize(IGenericWriter writer)
+ {
writer.Write(5); // version
writer.Write(PendingWars.Count);
diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs
index 19e9c020c..212a60386 100644
--- a/Projects/UOContent/Multis/Houses/BaseHouse.cs
+++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs
@@ -31,6 +31,7 @@ namespace Server.Multis
private DecayLevel m_CurrentStage;
private DecayLevel m_LastDecayLevel;
+ private bool _wasUndecayable; // not serialized: decay tick state only
private Mobile m_Owner;
@@ -198,19 +199,58 @@ namespace Server.Multis
{
get
{
- DecayLevel result;
-
if (!CanDecay)
{
+ return DecayLevel.Ageless;
+ }
+
+ if (DynamicDecay.Enabled)
+ {
+ var stage = m_CurrentStage;
+
+ if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0))
+ {
+ return DecayLevel.DemolitionPending;
+ }
+
+ return stage;
+ }
+
+ return GetOldDecayLevel();
+ }
+ }
+
+ ///
+ /// Advances decay bookkeeping. Runs from the decay tick, never from a read: a getter that
+ /// writes persisted fields makes every house record change on every read.
+ ///
+ public virtual void UpdateDecay()
+ {
+ if (!CanDecay)
+ {
+ if (DynamicDecay.Enabled && m_CurrentStage != DecayLevel.Ageless)
+ {
+ ResetDynamicDecay();
+ }
+
+ _wasUndecayable = true;
+ }
+ else
+ {
+ if (_wasUndecayable)
+ {
+ // Leaving the undecayable state counts as a refresh, which the old getter
+ // guaranteed by restamping LastRefreshed on every read while undecayable.
+ _wasUndecayable = false;
+ LastRefreshed = Core.Now;
+
if (DynamicDecay.Enabled)
{
ResetDynamicDecay();
}
-
- LastRefreshed = Core.Now;
- result = DecayLevel.Ageless;
}
- else if (DynamicDecay.Enabled)
+
+ if (DynamicDecay.Enabled)
{
var stage = m_CurrentStage;
@@ -218,32 +258,19 @@ namespace Server.Multis
{
SetDynamicDecay(++stage);
}
-
- if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0))
- {
- result = DecayLevel.DemolitionPending;
- }
- else
- {
- result = stage;
- }
}
- else
+ }
+
+ var level = DecayLevel;
+
+ if (level != m_LastDecayLevel)
+ {
+ m_LastDecayLevel = level;
+
+ if (Sign?.GettingProperties == false)
{
- result = GetOldDecayLevel();
+ Sign.InvalidateProperties();
}
-
- if (result != m_LastDecayLevel)
- {
- m_LastDecayLevel = result;
-
- if (Sign?.GettingProperties == false)
- {
- Sign.InvalidateProperties();
- }
- }
-
- return result;
}
}
@@ -582,6 +609,7 @@ namespace Server.Multis
return false;
}
+ UpdateDecay();
var oldLevel = DecayLevel;
LastRefreshed = Core.Now;
@@ -598,6 +626,8 @@ namespace Server.Multis
public virtual bool CheckDecay()
{
+ UpdateDecay();
+
if (!Deleted && DecayLevel == DecayLevel.Collapsed)
{
Timer.StartTimer(Decay_Sandbox);
@@ -2969,28 +2999,6 @@ namespace Server.Multis
writer.Write(MaxLockDowns);
writer.Write(MaxSecures);
-
- // Items in locked down containers that aren't locked down themselves must decay!
- for (var i = 0; i < LockDowns.Count; ++i)
- {
- var item = LockDowns[i];
-
- if (item is Container cont && !(cont is BaseBoard or Aquarium or FishBowl))
- {
- var children = cont.Items;
-
- for (var j = 0; j < children.Count; ++j)
- {
- var child = children[j];
-
- if (child.Decays && !child.IsLockedDown && !child.IsSecure &&
- child.LastMoved + child.DecayTime <= Core.Now)
- {
- Timer.StartTimer(child.Delete);
- }
- }
- }
- }
}
public override void Deserialize(IGenericReader reader)