From a2c232f4a84add9922667db33650d70233422c38 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Sun, 6 Sep 2026 09:39:00 -0700
Subject: [PATCH 01/21] fix: Removes side effects from locked down items,
decaying houses, bracelet of binding, and builds (#2608)
## Why
Delta world saves (only changed entities re-serialized during the freeze) need two things from content: `Serialize` must be pure, and an unchanged entity must produce identical bytes on every save. An audit of the tree found the places that break this today. This PR fixes them and adds a diagnostic that measures byte stability in a running world over real time. No wire format changes, no version bumps.
## What
- **Guild**: `Serialize` ran the daily guildmaster recalculation (dictionary-order tie breaks, random picks, mutates another entity), war expiry, and the alliance leadership check on every save. That work now runs from `GuildMaintenanceTimer` (the former `WarTimer`, now started for both guild systems; the war and alliance checks are no-ops without the new guild system).
- **BaseHouse**: the `DecayLevel` getter wrote `LastRefreshed` and advanced the dynamic decay stage on every read, so any undecayable house changed its persisted bytes whenever a sign, gump, or `[Props` looked at it. It is now a pure read; `UpdateDecay()` on the decay tick advances the stage and refreshes a house that leaves the undecayable state.
- **Locked-down container contents decay through `DecayScheduler`** instead of a sweep inside `BaseHouse.Serialize`. `Item.CanDecay` now accepts a parent container whose new `Container.ContentsDecay` is true (locked down and not secure) when the content item is not itself locked down or secured; the `IsLockedDown`/`IsSecure` setters re-evaluate a container and its direct contents; `OnDecay` resolves the region from the world location. `BaseBoard`, `Aquarium` and `FishBowl` override `ContentsDecay` to false, matching the exclusions of the old sweep. Items already inside such containers are picked up at load, where every item re-evaluates its registration. The per-save O(all locked-down items) walk is gone; the work is now event driven.
- **BraceletOfBinding**: the `Bound` getter nulled its backing field for a deleted target; it now returns null for a missing or deleted target without writing.
- **`[SaveStability [delaySeconds=60] [sampleStride=1]`** (Administrator): hashes the serialized bytes of every entity in every registered entity persistence (`Persistence.EntityPersistences`, new), waits the delay in real time, hashes again, and reports per type how many records changed. Both passes are chunked under a 20 ms per-tick budget. Serializing twice inside one tick cannot see drift derived from `Core.Now`, which is frozen within a tick; the real-time wait is the point. On an idle shard the only legitimate churn is NPC movement and regeneration, so a static type with a high changed fraction is a serialization bug. `[SaveStability cancel` stops a run.
Engine additions: `Container.ContentsDecay` / `UpdateContentsDecayRegistration()`, the `CanDecay`/`OnDecay` change above, and diagnostic-facing `IGenericEntityPersistence.Name`, `EntityCount`, `EnumerateEntities()`, and `Persistence.EntityPersistences`.
## Behaviour notes
- Fealty recalculation now happens on the minute timer for both guild systems instead of at save time.
- A house that becomes decayable is refreshed on its next decay tick rather than at the exact moment of transition (at most one minute later).
- Contents of locked-down containers now decay on their own schedule (same `LastMoved + DecayTime` rule) instead of at the next world save after becoming due.
## Testing
- New tests: `GuildSerializePurityTests`, `HouseDecayPurityTests`, `SaveStabilityTests` (stable entities hash identically after the clock advances; a clock-stamped record is reported by type only after the clock advances; the snapshot covers Items, Mobiles and Guilds).
- `LockedDownContainerDecayTests` (Server.Tests): registration on drop, lock/secure/release transitions, excluded containers, nested containers, and decay on schedule through the scheduler.
- Full suites: Server.Tests 855 passed, UOContent.Tests 765 passed.
---
.../Items/LockedDownContainerDecayTests.cs | 173 ++++++++++
Projects/Server/Items/Container.cs | 21 ++
Projects/Server/Items/Item.cs | 33 +-
.../Serialization/GenericEntityPersistence.cs | 20 ++
Projects/Server/Serialization/Persistence.cs | 15 +
.../Tests/Misc/GuildSerializePurityTests.cs | 61 ++++
.../Multis/Houses/HouseDecayPurityTests.cs | 42 +++
.../Tests/WorldSaves/SaveStabilityTests.cs | 152 +++++++++
.../Commands/SaveStabilityCommand.cs | 318 ++++++++++++++++++
Projects/UOContent/Items/Aquarium/Aquarium.cs | 2 +
Projects/UOContent/Items/Aquarium/FishBowl.cs | 2 +
Projects/UOContent/Items/Games/BaseBoard.cs | 2 +
.../Special/Solen Items/BraceletOfBinding.cs | 11 +-
Projects/UOContent/Misc/Guild.cs | 26 +-
Projects/UOContent/Multis/Houses/BaseHouse.cs | 110 +++---
15 files changed, 921 insertions(+), 67 deletions(-)
create mode 100644 Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs
create mode 100644 Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs
create mode 100644 Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs
create mode 100644 Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
create mode 100644 Projects/UOContent/Commands/SaveStabilityCommand.cs
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)
From 84153fba58b6f004cab5143fe28ea07e5e5ce448 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Sun, 6 Sep 2026 09:43:44 -0700
Subject: [PATCH 02/21] fix: Fixes dirty-tracking gaps in generated content:
setters, sub-object owners, BaseVendor (#2609)
## Why
Delta world saves re-serialize an entity only when it has been marked dirty. An audit of the generated classes found three ways serialized state changes without a mark; this PR closes the ones that do not need a new generator package.
## What
- **Custom `[SerializableProperty]` setters mark dirty.** Twelve hand-written setters assigned their backing field without `this.MarkDirty()`. `PlagueBeastLord.OpenedBy` was an auto-property carrying the attribute with no backing field at all; it is now a generated field with the same name, order and command-property exposure.
- **Generated sub-objects are linked to their owner.** Without a `[DirtyTrackingEntity]` member the generator emits setters that mark nothing. Eight entity-owned sub-objects now carry the link and receive the owner through the constructor the generator calls: `BOBFilter` (owner is `IEntity`: a `PlayerMobile` or a `BulkOrderBook`), `BOBLargeSubEntry`, `PuzzleChestSolution` and `PuzzleChestSolutionAndTime`, `TalismanAttribute` (the random factories now take the talisman), `VendorItem`, `PlayerBBMessage`, `RaffleEntry`, `ShardPollOption`. Migration schemas were regenerated with the pinned tool; the only change is the value rule argument becoming `DeserializationRequiresParent`.
- **BaseVendor uses the generator; restock amounts are no longer persisted.** The only state it wrote was which buy entries had grown restock amounts, packed by index into the live `SBInfos` tables. Restock is transient now and rebuilds on load; version 1 records are read and discarded through the legacy path. Every vendor subclass now serializes through a generated chain.
## Generator 4.1.0
This PR adopts SerializationGenerator 4.1.0 (modernuo/SerializationGenerator#55): SG3019/SG3020 diagnostics, `[VolatileSerializedState]`, `StopXxx()` timer helpers, and owner-constructor preference for sub-objects (so dictionary values are constructed with their owner; the 4.0.0 relink fallback is gone). `PlayerVendor.v3.json` gains `DeserializationRequiresParent` for its `VendorItem` values so the migration content struct constructs them with their vendor.
SG3019 is an error under `TreatWarningsAsErrors` and generator diagnostics ignore pragmas, so the five contexts that live in whole-file player-keyed persistences (`ChampionTitle`, `ChampionTitleContext`, `MurderContext`, `VirtueContext`, `JailRecord`) now carry a `[DirtyTrackingEntity]` link to their `PlayerMobile`, which is where they will live once those blobs move onto the player record. `JailSystem.EmptyRecord` keeps a null player (`[CanBeNull]`). `ChampionTitle` needs both a context and a player constructor because the generator resolves the rule against the containing type but emits the call with the parent field; a comment in the file records this.
## Wire format
Unchanged. No version bumps except `BaseVendor` 1 to 2 (which now writes nothing of its own). Schema diffs are rule-argument only (`DeserializationRequiresParent`).
## Testing
Server.Tests 848 passed, UOContent.Tests 759 passed.
---
.config/dotnet-tools.json | 2 +-
Projects/Server/Server.csproj | 4 +-
.../Engines/Bulk Orders/Books/BOBFilter.cs | 5 +
.../Bulk Orders/Books/BOBLargeEntry.cs | 4 +-
.../Bulk Orders/Books/BOBLargeSubEntry.cs | 10 +-
.../Bulk Orders/Books/BulkOrderBook.cs | 4 +-
.../Engines/CannedEvil/ChampionTitle.cs | 12 ++
.../CannedEvil/ChampionTitleContext.cs | 7 +-
.../UOContent/Engines/Khaldun/PuzzleChest.cs | 26 ++--
.../UOContent/Engines/Plants/PlantItem.cs | 1 +
.../Player Murder System/MurderContext.cs | 1 +
.../UOContent/Engines/Spawners/BaseSpawner.cs | 1 +
.../Engines/Virtues/VirtueContext.cs | 7 ++
.../UOContent/Engines/Virtues/VirtueSystem.cs | 4 +-
Projects/UOContent/Items/Armor/BaseArmor.cs | 3 +
.../Fillable Containers/FillableContainer.cs | 1 +
Projects/UOContent/Items/Food/Beverage.cs | 1 +
.../Minor Artifacts/ML/BloodwoodSpirit.cs | 2 +-
.../Items/Minor Artifacts/ML/TotemOfVoid.cs | 2 +-
.../Items/Misc/PlayerBulletinBoards.cs | 16 +--
.../Skill Items/Magical/Misc/RecallRune.cs | 1 +
.../Musical Instruments/BaseInstrument.cs | 2 +
.../Special/House Raffle/HouseRaffleStone.cs | 13 +-
.../Talismans/BaseTalisman.Migrations.cs | 6 +-
.../UOContent/Items/Talismans/BaseTalisman.cs | 48 ++++----
.../Items/Talismans/RandomTalisman.cs | 6 +-
.../Items/Talismans/TalismanAttribute.cs | 14 ++-
...r.Engines.BulkOrders.BOBLargeEntry.v1.json | 2 +-
...r.Engines.BulkOrders.BulkOrderBook.v3.json | 2 +-
...es.CannedEvil.ChampionTitleContext.v1.json | 18 +--
.../Server.Items.BasePlayerBB.v0.json | 4 +-
.../Server.Items.BaseTalisman.v1.json | 6 +-
.../Server.Items.HouseRaffleStone.v4.json | 2 +-
.../Server.Items.PuzzleChest.v1.json | 4 +-
.../Server.Misc.ShardPoller.v1.json | 2 +-
.../Server.Mobiles.BaseVendor.v2.json | 4 +
.../Server.Mobiles.PlayerVendor.v3.json | 2 +-
.../Server.Mobiles.PlayerVendor.v4.json | 2 +-
Projects/UOContent/Misc/Loot.cs | 6 +-
Projects/UOContent/Misc/ShardPoller.cs | 18 ++-
.../Monsters/Misc/Melee/PlagueBeast.cs | 6 +-
.../Monsters/Misc/Melee/PlagueBeastLord.cs | 6 +-
Projects/UOContent/Mobiles/PlayerMobile.cs | 6 +-
.../UOContent/Mobiles/Vendors/BaseVendor.cs | 111 +++---------------
.../UOContent/Mobiles/Vendors/PlayerVendor.cs | 4 +-
.../UOContent/Mobiles/Vendors/VendorItem.cs | 12 +-
.../Systems/JailSystem/JailRecord.cs | 7 ++
.../Systems/JailSystem/JailSystem.cs | 6 +-
Projects/UOContent/UOContent.csproj | 4 +-
49 files changed, 223 insertions(+), 214 deletions(-)
create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json
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/Projects/Server/Server.csproj b/Projects/Server/Server.csproj
index fd69557b7..ab982279f 100644
--- a/Projects/Server/Server.csproj
+++ b/Projects/Server/Server.csproj
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs
index f63e4f8d4..5b72a3c54 100644
--- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs
+++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs
@@ -5,6 +5,11 @@ namespace Server.Engines.BulkOrders;
[SerializationGenerator(2)]
public partial class BOBFilter
{
+ [DirtyTrackingEntity]
+ private IEntity _owner;
+
+ public BOBFilter(IEntity owner) => _owner = owner;
+
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeType))]
private int _type;
diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs
index 44f1e727c..b2d8873d7 100644
--- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs
+++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs
@@ -26,7 +26,7 @@ public partial class BOBLargeEntry : BaseBOBEntry
for (var i = 0; i < _entries.Length; ++i)
{
- _entries[i] = new BOBLargeSubEntry(bod.Entries[i]);
+ _entries[i] = new BOBLargeSubEntry(this, bod.Entries[i]);
}
}
@@ -76,7 +76,7 @@ public partial class BOBLargeEntry : BaseBOBEntry
for (var i = 0; i < Entries.Length; ++i)
{
- _entries[i] = new BOBLargeSubEntry();
+ _entries[i] = new BOBLargeSubEntry(this);
_entries[i].Deserialize(reader);
}
}
diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs
index 8a4a91637..8337dc91b 100644
--- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs
+++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs
@@ -6,6 +6,9 @@ namespace Server.Engines.BulkOrders;
[SerializationGenerator(0)]
public partial class BOBLargeSubEntry
{
+ [DirtyTrackingEntity]
+ private BOBLargeEntry _parent;
+
[SerializableField(0, setter: "private")]
private Type _itemType;
@@ -21,12 +24,11 @@ public partial class BOBLargeSubEntry
[SerializableField(3, setter: "private")]
private int _graphic;
- public BOBLargeSubEntry()
- {
- }
+ public BOBLargeSubEntry(BOBLargeEntry parent) => _parent = parent;
- public BOBLargeSubEntry(LargeBulkEntry lbe)
+ public BOBLargeSubEntry(BOBLargeEntry parent, LargeBulkEntry lbe)
{
+ _parent = parent;
_itemType = lbe.Details.Type;
_amountCur = lbe.Amount;
_number = lbe.Details.Number;
diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs
index a0c17c575..e4d788a7c 100644
--- a/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs
+++ b/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs
@@ -43,7 +43,7 @@ public partial class BulkOrderBook : Item, ISecurable
LootType = LootType.Blessed;
_entries = [];
- _filter = new BOBFilter();
+ _filter = new BOBFilter(this);
_level = SecureLevel.CoOwners;
}
@@ -224,7 +224,7 @@ public partial class BulkOrderBook : Item, ISecurable
_bookName = reader.ReadString();
- _filter = new BOBFilter();
+ _filter = new BOBFilter(this);
_filter.Deserialize(reader);
var count = reader.ReadEncodedInt();
diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs
index c1970a449..dca633bb7 100644
--- a/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs
+++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs
@@ -1,11 +1,23 @@
using System;
using ModernUO.Serialization;
+using Server.Mobiles;
namespace Server.Engines.CannedEvil;
[SerializationGenerator(0)]
public partial class ChampionTitle
{
+ [DirtyTrackingEntity]
+ private PlayerMobile _player;
+
+ public ChampionTitle(ChampionTitleContext context) : this(context?.Player)
+ {
+ }
+
+ // The generator resolves the deserialization constructor against the owning type, but emits the
+ // owner's own dirty-tracking reference (the player) at the call site, so both overloads exist.
+ public ChampionTitle(PlayerMobile player) => _player = player;
+
[EncodedInt]
[SerializableField(0)]
private int _value;
diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs
index 644e2b965..f610e485f 100644
--- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs
+++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs
@@ -16,6 +16,7 @@ public partial class ChampionTitleContext
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _harrower;
+ [DirtyTrackingEntity]
private PlayerMobile _player;
public PlayerMobile Player => _player;
@@ -45,7 +46,7 @@ public partial class ChampionTitleContext
throw new NotImplementedException($"Cannot find ChampionSpawnType value {type}.");
}
- title = new ChampionTitle();
+ title = new ChampionTitle(this);
title.Deserialize(reader);
}
}
@@ -290,7 +291,7 @@ public partial class ChampionTitleContext
return null;
}
- return title ??= new ChampionTitle();
+ return title ??= new ChampionTitle(this);
}
public void SetValue(ChampionSpawnType type, int value)
@@ -313,7 +314,7 @@ public partial class ChampionTitleContext
}
else
{
- title = new ChampionTitle();
+ title = new ChampionTitle(this);
}
title.Value = value;
diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs
index 53932c8f0..2da3b198b 100644
--- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs
+++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs
@@ -23,11 +23,19 @@ namespace Server.Items
[SerializationGenerator(1)]
public partial class PuzzleChestSolution
{
+ [DirtyTrackingEntity]
+ private PuzzleChest _chest;
+
[SerializableField(0)]
private PuzzleChestCylinder[] _cylinders;
public const int Length = 5;
+ // Declared first: the generator picks the first matching constructor, and a deserialized
+ // solution must know its chest to mark it dirty.
+ public PuzzleChestSolution(PuzzleChest chest) : this() => _chest = chest;
+
+ // Transient solutions (player guesses being edited in a gump) have no owning chest.
public PuzzleChestSolution() =>
_cylinders = [RandomCylinder(), RandomCylinder(), RandomCylinder(), RandomCylinder(), RandomCylinder()];
@@ -42,6 +50,9 @@ namespace Server.Items
solution.Cylinders.AsSpan().CopyTo(Cylinders);
}
+ protected PuzzleChestSolution(PuzzleChest chest, PuzzleChestSolution solution) : this(solution) =>
+ _chest = chest;
+
private void Deserialize(IGenericReader reader, int version)
{
var length = reader.ReadEncodedInt();
@@ -174,10 +185,11 @@ namespace Server.Items
[SerializableField(0)]
private DateTime _when;
- public PuzzleChestSolutionAndTime(DateTime when, PuzzleChestSolution solution) : base(solution) => _when = when;
+ public PuzzleChestSolutionAndTime(PuzzleChest chest, DateTime when, PuzzleChestSolution solution)
+ : base(chest, solution) => _when = when;
- // For serialization
- public PuzzleChestSolutionAndTime()
+ // The generator deserializes guesses through this constructor so each one knows its chest.
+ public PuzzleChestSolutionAndTime(PuzzleChest chest) : base(chest)
{
}
}
@@ -214,7 +226,7 @@ namespace Server.Items
private void Deserialize(IGenericReader reader, int version)
{
- _solution = new PuzzleChestSolution();
+ _solution = new PuzzleChestSolution(this);
_solution.Deserialize(reader);
var length = reader.ReadEncodedInt();
@@ -238,7 +250,7 @@ namespace Server.Items
for (var i = 0; i < guessCount; i++)
{
var m = reader.ReadEntity();
- (_guesses[m] = new PuzzleChestSolutionAndTime()).Deserialize(reader);
+ (_guesses[m] = new PuzzleChestSolutionAndTime(this)).Deserialize(reader);
}
}
@@ -329,7 +341,7 @@ namespace Server.Items
}
else
{
- (_guesses ??= []).Add(m, new PuzzleChestSolutionAndTime(Core.Now, solution));
+ (_guesses ??= []).Add(m, new PuzzleChestSolutionAndTime(this, Core.Now, solution));
StartCleanupTimer();
m.SendGump(new StatusGump(correctCylinders, correctColors));
@@ -525,7 +537,7 @@ namespace Server.Items
}
}
- Solution = new PuzzleChestSolution();
+ Solution = new PuzzleChestSolution(this);
}
private void StartCleanupTimer()
diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs
index 5f66b1a7a..f829b0e20 100644
--- a/Projects/UOContent/Engines/Plants/PlantItem.cs
+++ b/Projects/UOContent/Engines/Plants/PlantItem.cs
@@ -96,6 +96,7 @@ public partial class PlantItem : Item, ISecurable
var ratio = PlantSystem != null ? (double)PlantSystem.Hits / PlantSystem.MaxHits : 1.0;
_plantStatus = value;
+ this.MarkDirty();
if (_plantStatus >= PlantStatus.DecorativePlant)
{
diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs
index 384c91c98..9be182300 100644
--- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs
+++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs
@@ -58,6 +58,7 @@ public partial class MurderContext
_lastMurderTime = Core.Now;
}
+ [DirtyTrackingEntity]
public PlayerMobile _player;
public PlayerMobile Player => _player;
diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs
index 8efc54b8c..750a08257 100644
--- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs
+++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs
@@ -308,6 +308,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
_walkingRange = value;
InvalidateProperties();
+ this.MarkDirty();
}
}
diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs
index 7524f77ed..e12d803f6 100644
--- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs
+++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs
@@ -8,6 +8,13 @@ namespace Server.Engines.Virtues;
[SerializationGenerator(1)]
public partial class VirtueContext
{
+ [DirtyTrackingEntity]
+ private PlayerMobile _player;
+
+ public PlayerMobile Player => _player;
+
+ public VirtueContext(PlayerMobile player) => _player = player;
+
private void MigrateFrom(V0Content content)
{
// Save-flagged values arrive as nullables; unset flags fall back to the same
diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs
index e57a2d386..457899b91 100644
--- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs
+++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs
@@ -99,7 +99,7 @@ public class VirtueSystem : GenericPersistence
for (var i = 0; i < contextCount; i++)
{
var player = reader.ReadEntity();
- var virtues = new VirtueContext();
+ var virtues = new VirtueContext(player);
virtues.Deserialize(reader);
if (player != null && virtues.IsUsed())
@@ -122,7 +122,7 @@ public class VirtueSystem : GenericPersistence
ref var context = ref CollectionsMarshal.GetValueRefOrAddDefault(_playerVirtues, from, out var exists);
if (!exists)
{
- context = new VirtueContext();
+ context = new VirtueContext(from);
}
return context;
diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs
index f4062326d..a55400e5a 100644
--- a/Projects/UOContent/Items/Armor/BaseArmor.cs
+++ b/Projects/UOContent/Items/Armor/BaseArmor.cs
@@ -195,6 +195,7 @@ namespace Server.Items
{
UnscaleDurability();
_quality = value;
+ this.MarkDirty();
ScaleDurability();
}
}
@@ -213,6 +214,7 @@ namespace Server.Items
{
UnscaleDurability();
_durability = value;
+ this.MarkDirty();
ScaleDurability();
}
}
@@ -246,6 +248,7 @@ namespace Server.Items
UnscaleDurability();
_resource = value;
+ this.MarkDirty();
if (CraftItem.RetainsColor(GetType()))
{
diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs
index da4d8b77f..60ca33cb2 100644
--- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs
+++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs
@@ -53,6 +53,7 @@ public abstract partial class FillableContainer : LockableContainer
ClearContents();
_contentType = value;
+ this.MarkDirty();
Respawn();
}
}
diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs
index 85eecf17f..85e27531e 100644
--- a/Projects/UOContent/Items/Food/Beverage.cs
+++ b/Projects/UOContent/Items/Food/Beverage.cs
@@ -350,6 +350,7 @@ public abstract partial class BaseBeverage : Item, IHasQuantity
set
{
_quantity = Math.Clamp(value, 0, MaxQuantity);
+ this.MarkDirty();
InvalidateProperties();
diff --git a/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs b/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs
index ad854cef6..4d6a70dd5 100644
--- a/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs
+++ b/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs
@@ -13,7 +13,7 @@ public partial class BloodwoodSpirit : BaseTalisman
Removal = TalismanRemoval.Damage;
Blessed = GetRandomBlessed();
- Protection = GetRandomProtection(false);
+ Protection = GetRandomProtection(this, false);
SkillBonuses.SetValues(0, SkillName.SpiritSpeak, 10.0);
SkillBonuses.SetValues(1, SkillName.Necromancy, 5.0);
diff --git a/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs b/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs
index 546f906c4..885130daf 100644
--- a/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs
+++ b/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs
@@ -14,7 +14,7 @@ public partial class TotemOfVoid : BaseTalisman
MaxChargeTime = 1800;
Blessed = GetRandomBlessed();
- Protection = GetRandomProtection(false);
+ Protection = GetRandomProtection(this, false);
Attributes.RegenHits = 2;
Attributes.LowerManaCost = 10;
diff --git a/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs b/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs
index 0dcd418aa..1c193c32d 100644
--- a/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs
+++ b/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs
@@ -74,13 +74,13 @@ public abstract partial class BasePlayerBB : Item, ISecurable
if (_greeting != null)
{
- board.Greeting = new PlayerBBMessage(_greeting.Time, _greeting.Poster, _greeting.Message);
+ board.Greeting = new PlayerBBMessage(board, _greeting.Time, _greeting.Poster, _greeting.Message);
}
for (var i = 0; i < _messages.Count; i++)
{
var message = _messages[i];
- board.AddToMessages(new PlayerBBMessage(message.Time, message.Poster, message.Message));
+ board.AddToMessages(new PlayerBBMessage(board, message.Time, message.Poster, message.Message));
}
}
@@ -176,7 +176,7 @@ public abstract partial class BasePlayerBB : Item, ISecurable
if (text.Length > 0)
{
- var message = new PlayerBBMessage(Core.Now, from, text);
+ var message = new PlayerBBMessage(board, Core.Now, from, text);
if (_greeting)
{
@@ -265,6 +265,9 @@ public abstract partial class BasePlayerBB : Item, ISecurable
[SerializationGenerator(0)]
public partial class PlayerBBMessage
{
+ [DirtyTrackingEntity]
+ private BasePlayerBB _board;
+
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _time;
@@ -277,12 +280,11 @@ public partial class PlayerBBMessage
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _message;
- public PlayerBBMessage()
- {
- }
+ public PlayerBBMessage(BasePlayerBB board) => _board = board;
- public PlayerBBMessage(DateTime time, Mobile poster, string message)
+ public PlayerBBMessage(BasePlayerBB board, DateTime time, Mobile poster, string message)
{
+ _board = board;
_time = time;
_poster = poster;
_message = message;
diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs
index 14db7ac1e..1bb5a7f6e 100644
--- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs
+++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs
@@ -42,6 +42,7 @@ public partial class RecallRune : Item
set
{
_house = value;
+ this.MarkDirty();
CalculateHue();
InvalidateProperties();
}
diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs
index f7716a4a1..5adff2ce7 100644
--- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs
+++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs
@@ -92,6 +92,7 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer
{
UnscaleUses();
_quality = value;
+ this.MarkDirty();
InvalidateProperties();
ScaleUses();
}
@@ -109,6 +110,7 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer
set
{
_usesRemaining = value;
+ this.MarkDirty();
InvalidateProperties();
}
}
diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs
index 59e2b8277..21b0e5eb2 100644
--- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs
+++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs
@@ -16,6 +16,9 @@ namespace Server.Items;
[SerializationGenerator(0)]
public partial class RaffleEntry
{
+ [DirtyTrackingEntity]
+ private HouseRaffleStone _stone;
+
[SerializableField(0, setter: "private")]
private Mobile _from;
@@ -25,15 +28,17 @@ public partial class RaffleEntry
[SerializableField(2, setter: "private")]
private DateTime _date;
- public RaffleEntry(Mobile from)
+ public RaffleEntry(HouseRaffleStone stone, Mobile from)
{
+ _stone = stone;
_from = from;
_address = from?.NetState?.Address ?? IPAddress.None;
_date = Core.Now;
}
- public RaffleEntry()
+ public RaffleEntry(HouseRaffleStone stone)
{
+ _stone = stone;
_from = null;
_address = null;
_date = Core.Now;
@@ -454,7 +459,7 @@ public partial class HouseRaffleStone : Item
if (_ticketPrice == 0 || from.Backpack?.ConsumeTotal(typeof(Gold), _ticketPrice) == true ||
Banker.Withdraw(from, _ticketPrice))
{
- AddToEntries(new RaffleEntry(from));
+ AddToEntries(new RaffleEntry(this, from));
from.SendMessage(MessageHue, "You have successfully entered the plot's raffle.");
}
@@ -539,7 +544,7 @@ public partial class HouseRaffleStone : Item
for (var i = 0; i < entryCount; i++)
{
- var entry = new RaffleEntry();
+ var entry = new RaffleEntry(this);
entry.Deserialize(reader);
if (entry.From == null)
diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs
index 85d41c852..c2da33683 100644
--- a/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs
+++ b/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs
@@ -26,19 +26,19 @@ public partial class BaseTalisman
BlessedFor = reader.ReadEntity();
}
- _protection = new TalismanAttribute();
+ _protection = new TalismanAttribute(this);
if (GetOldSaveFlag(flags, OldSaveFlag.Protection))
{
_protection.Deserialize(reader);
}
- _killer = new TalismanAttribute();
+ _killer = new TalismanAttribute(this);
if (GetOldSaveFlag(flags, OldSaveFlag.Killer))
{
_killer.Deserialize(reader);
}
- _summoner = new TalismanAttribute();
+ _summoner = new TalismanAttribute(this);
if (GetOldSaveFlag(flags, OldSaveFlag.Summoner))
{
_summoner.Deserialize(reader);
diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs
index b3f0c2ed4..f7b118df6 100644
--- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs
+++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs
@@ -155,7 +155,7 @@ public partial class BaseTalisman : Item, IAosItem
public bool ShouldSerializeProtection() => !_protection.IsEmpty;
- private TalismanAttribute ProtectionDefaultValue() => new();
+ private TalismanAttribute ProtectionDefaultValue() => new(this);
[SerializedIgnoreDupe]
[InvalidateProperties]
@@ -166,7 +166,7 @@ public partial class BaseTalisman : Item, IAosItem
public bool ShouldSerializeKiller() => !_killer.IsEmpty;
- private TalismanAttribute KillerDefaultValue() => new();
+ private TalismanAttribute KillerDefaultValue() => new(this);
[SerializedIgnoreDupe]
[InvalidateProperties]
@@ -177,7 +177,7 @@ public partial class BaseTalisman : Item, IAosItem
public bool ShouldSerializeSummoner() => !_summoner.IsEmpty;
- private TalismanAttribute SummonerDefaultValue() => new();
+ private TalismanAttribute SummonerDefaultValue() => new(this);
[InvalidateProperties]
[SerializableField(5)]
@@ -267,9 +267,9 @@ public partial class BaseTalisman : Item, IAosItem
{
Layer = Layer.Talisman;
- _protection = new TalismanAttribute();
- _killer = new TalismanAttribute();
- _summoner = new TalismanAttribute();
+ _protection = new TalismanAttribute(this);
+ _killer = new TalismanAttribute(this);
+ _summoner = new TalismanAttribute(this);
Attributes = new AosAttributes(this);
SkillBonuses = new AosSkillBonuses(this);
}
@@ -319,9 +319,9 @@ public partial class BaseTalisman : Item, IAosItem
return;
}
- talisman._summoner = new TalismanAttribute(_summoner);
- talisman._protection = new TalismanAttribute(_protection);
- talisman._killer = new TalismanAttribute(_killer);
+ talisman._summoner = new TalismanAttribute(talisman, _summoner);
+ talisman._protection = new TalismanAttribute(talisman, _protection);
+ talisman._killer = new TalismanAttribute(talisman, _killer);
talisman.Attributes = new AosAttributes(newItem, Attributes);
talisman.SkillBonuses = new AosSkillBonuses(newItem, SkillBonuses);
}
@@ -660,17 +660,17 @@ public partial class BaseTalisman : Item, IAosItem
public virtual void SetSummoner(Type type, TextDefinition name)
{
- _summoner = new TalismanAttribute(type, name);
+ _summoner = new TalismanAttribute(this, type, name);
}
public virtual void SetProtection(Type type, TextDefinition name, int amount)
{
- _protection = new TalismanAttribute(type, name, amount);
+ _protection = new TalismanAttribute(this, type, name, amount);
}
public virtual void SetKiller(Type type, TextDefinition name, int amount)
{
- _killer = new TalismanAttribute(type, name, amount);
+ _killer = new TalismanAttribute(this, type, name, amount);
}
public virtual void StartTimer()
@@ -712,18 +712,18 @@ public partial class BaseTalisman : Item, IAosItem
public static Type GetRandomSummonType() => _summons.RandomElement();
- public static TalismanAttribute GetRandomSummoner()
+ public static TalismanAttribute GetRandomSummoner(BaseTalisman owner)
{
if (Utility.RandomDouble() < 0.975)
{
- return new TalismanAttribute();
+ return new TalismanAttribute(owner);
}
var num = Utility.Random(_summons.Length);
return num > 14
- ? new TalismanAttribute(_summons[num], _summonLabels[num], 10)
- : new TalismanAttribute(_summons[num], _summonLabels[num]);
+ ? new TalismanAttribute(owner, _summons[num], _summonLabels[num], 10)
+ : new TalismanAttribute(owner, _summons[num], _summonLabels[num]);
}
public static TalismanRemoval GetRandomRemoval()
@@ -736,32 +736,32 @@ public partial class BaseTalisman : Item, IAosItem
return TalismanRemoval.None;
}
- public static TalismanAttribute GetRandomKiller() => GetRandomKiller(true);
+ public static TalismanAttribute GetRandomKiller(BaseTalisman owner) => GetRandomKiller(owner, true);
- public static TalismanAttribute GetRandomKiller(bool includingNone)
+ public static TalismanAttribute GetRandomKiller(BaseTalisman owner, bool includingNone)
{
if (includingNone && Utility.RandomBool())
{
- return new TalismanAttribute();
+ return new TalismanAttribute(owner);
}
var num = Utility.Random(_killers.Length);
- return new TalismanAttribute(_killers[num], _killerLabels[num], Utility.RandomMinMax(10, 100));
+ return new TalismanAttribute(owner, _killers[num], _killerLabels[num], Utility.RandomMinMax(10, 100));
}
- public static TalismanAttribute GetRandomProtection() => GetRandomProtection(true);
+ public static TalismanAttribute GetRandomProtection(BaseTalisman owner) => GetRandomProtection(owner, true);
- public static TalismanAttribute GetRandomProtection(bool includingNone)
+ public static TalismanAttribute GetRandomProtection(BaseTalisman owner, bool includingNone)
{
if (includingNone && Utility.RandomBool())
{
- return new TalismanAttribute();
+ return new TalismanAttribute(owner);
}
var num = Utility.Random(_killers.Length);
- return new TalismanAttribute(_killers[num], _killerLabels[num], Utility.RandomMinMax(5, 60));
+ return new TalismanAttribute(owner, _killers[num], _killerLabels[num], Utility.RandomMinMax(5, 60));
}
public static SkillName GetRandomSkill() => _skills.RandomElement();
diff --git a/Projects/UOContent/Items/Talismans/RandomTalisman.cs b/Projects/UOContent/Items/Talismans/RandomTalisman.cs
index 5235c6f20..60529c203 100644
--- a/Projects/UOContent/Items/Talismans/RandomTalisman.cs
+++ b/Projects/UOContent/Items/Talismans/RandomTalisman.cs
@@ -8,7 +8,7 @@ public partial class RandomTalisman : BaseTalisman
[Constructible]
public RandomTalisman() : base(GetRandomItemID())
{
- Summoner = GetRandomSummoner();
+ Summoner = GetRandomSummoner(this);
if (Summoner.IsEmpty)
{
@@ -36,8 +36,8 @@ public partial class RandomTalisman : BaseTalisman
Blessed = GetRandomBlessed();
Slayer = GetRandomSlayer();
- Protection = GetRandomProtection();
- Killer = GetRandomKiller();
+ Protection = GetRandomProtection(this);
+ Killer = GetRandomKiller(this);
Skill = GetRandomSkill();
ExceptionalBonus = GetRandomExceptional();
SuccessBonus = GetRandomSuccessful();
diff --git a/Projects/UOContent/Items/Talismans/TalismanAttribute.cs b/Projects/UOContent/Items/Talismans/TalismanAttribute.cs
index e1a7ad6ec..153951081 100644
--- a/Projects/UOContent/Items/Talismans/TalismanAttribute.cs
+++ b/Projects/UOContent/Items/Talismans/TalismanAttribute.cs
@@ -7,6 +7,9 @@ namespace Server.Items;
[SerializationGenerator(1, false)]
public partial class TalismanAttribute
{
+ [DirtyTrackingEntity]
+ private BaseTalisman _owner;
+
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Type _type;
@@ -19,12 +22,12 @@ public partial class TalismanAttribute
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _amount;
- public TalismanAttribute() : this(null, null)
- {
- }
+ public TalismanAttribute(BaseTalisman owner) => _owner = owner;
- public TalismanAttribute(TalismanAttribute copy)
+ public TalismanAttribute(BaseTalisman owner, TalismanAttribute copy)
{
+ _owner = owner;
+
if (copy != null)
{
_type = copy.Type;
@@ -33,8 +36,9 @@ public partial class TalismanAttribute
}
}
- public TalismanAttribute(Type type, TextDefinition name, int amount = 0)
+ public TalismanAttribute(BaseTalisman owner, Type type, TextDefinition name, int amount = 0)
{
+ _owner = owner;
_type = type;
_name = name;
_amount = amount;
diff --git a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json
index ec12a5845..43bc92702 100644
--- a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json
+++ b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json
@@ -9,7 +9,7 @@
"ruleArguments": [
"Server.Engines.BulkOrders.BOBLargeSubEntry",
"RawSerializableMigrationRule",
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json
index 37899091a..74f878450 100644
--- a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json
+++ b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json
@@ -28,7 +28,7 @@
"type": "Server.Engines.BulkOrders.BOBFilter",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json
index af6658d06..c2811c097 100644
--- a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json
+++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json
@@ -16,7 +16,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -25,7 +25,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -34,7 +34,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -43,7 +43,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -52,7 +52,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -61,7 +61,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -70,7 +70,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -79,7 +79,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -88,7 +88,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json b/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json
index 2d19ebd1a..09f2db9d1 100644
--- a/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json
+++ b/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json
@@ -20,7 +20,7 @@
"type": "Server.Items.PlayerBBMessage",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- "",
+ "DeserializationRequiresParent",
"@CanBeNull"
]
},
@@ -31,7 +31,7 @@
"ruleArguments": [
"Server.Items.PlayerBBMessage",
"RawSerializableMigrationRule",
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json b/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json
index 958652db9..2d997d4a5 100644
--- a/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json
+++ b/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json
@@ -26,7 +26,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -35,7 +35,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -44,7 +44,7 @@
"usesSaveFlag": true,
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
diff --git a/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json b/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json
index b2d79f227..351cb26dc 100644
--- a/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json
+++ b/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json
@@ -66,7 +66,7 @@
"ruleArguments": [
"Server.Items.RaffleEntry",
"RawSerializableMigrationRule",
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json
index 508755f69..fb3360ba2 100644
--- a/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json
+++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json
@@ -7,7 +7,7 @@
"type": "Server.Items.PuzzleChestSolution",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
- ""
+ "DeserializationRequiresParent"
]
},
{
@@ -32,7 +32,7 @@
"Server.Items.PuzzleChestSolutionAndTime",
"RawSerializableMigrationRule",
"2",
- "",
+ "DeserializationRequiresParent",
"@CanBeNull"
]
}
diff --git a/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json b/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json
index 46345e368..e8611014a 100644
--- a/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json
+++ b/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json
@@ -38,7 +38,7 @@
"ruleArguments": [
"Server.Misc.ShardPollOption",
"RawSerializableMigrationRule",
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json
new file mode 100644
index 000000000..887fd9bdf
--- /dev/null
+++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json
@@ -0,0 +1,4 @@
+{
+ "version": 2,
+ "type": "Server.Mobiles.BaseVendor"
+}
\ No newline at end of file
diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json
index aee2b1da6..407b93cfc 100644
--- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json
+++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json
@@ -55,7 +55,7 @@
"Server.Mobiles.VendorItem",
"RawSerializableMigrationRule",
"1",
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json
index 6b9c9eb1c..a2c354874 100644
--- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json
+++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json
@@ -55,7 +55,7 @@
"Server.Mobiles.VendorItem",
"RawSerializableMigrationRule",
"1",
- ""
+ "DeserializationRequiresParent"
]
}
]
diff --git a/Projects/UOContent/Misc/Loot.cs b/Projects/UOContent/Misc/Loot.cs
index 2477c25b5..808364298 100644
--- a/Projects/UOContent/Misc/Loot.cs
+++ b/Projects/UOContent/Misc/Loot.cs
@@ -738,7 +738,7 @@ namespace Server
{
var talisman = new BaseTalisman(BaseTalisman.GetRandomItemID());
- talisman.Summoner = BaseTalisman.GetRandomSummoner();
+ talisman.Summoner = BaseTalisman.GetRandomSummoner(talisman);
if (talisman.Summoner.IsEmpty)
{
@@ -758,8 +758,8 @@ namespace Server
talisman.Blessed = BaseTalisman.GetRandomBlessed();
talisman.Slayer = BaseTalisman.GetRandomSlayer();
- talisman.Protection = BaseTalisman.GetRandomProtection();
- talisman.Killer = BaseTalisman.GetRandomKiller();
+ talisman.Protection = BaseTalisman.GetRandomProtection(talisman);
+ talisman.Killer = BaseTalisman.GetRandomKiller(talisman);
talisman.Skill = BaseTalisman.GetRandomSkill();
talisman.ExceptionalBonus = BaseTalisman.GetRandomExceptional();
talisman.SuccessBonus = BaseTalisman.GetRandomSuccessful();
diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs
index 8516e6678..643657c1f 100644
--- a/Projects/UOContent/Misc/ShardPoller.cs
+++ b/Projects/UOContent/Misc/ShardPoller.cs
@@ -187,7 +187,7 @@ public partial class ShardPoller : Item
for (var i = 0; i < _options.Length; ++i)
{
- var option = _options[i] = new ShardPollOption();
+ var option = _options[i] = new ShardPollOption(this);
option.Deserialize(reader);
}
}
@@ -212,15 +212,23 @@ public partial class ShardPoller : Item
[SerializationGenerator(1, false)]
public partial class ShardPollOption
{
+ [DirtyTrackingEntity]
+ private ShardPoller _poller;
+
private int _lineBreaks = -1;
[SerializableField(1)]
private IPAddress[] _voters;
- public ShardPollOption() => _voters = [];
-
- public ShardPollOption(string title)
+ public ShardPollOption(ShardPoller poller)
{
+ _poller = poller;
+ _voters = [];
+ }
+
+ public ShardPollOption(ShardPoller poller, string title)
+ {
+ _poller = poller;
_title = title;
_voters = [];
}
@@ -600,7 +608,7 @@ public partial class ShardPollPrompt : Prompt
if (_option == null)
{
- _poller.AddOption(new ShardPollOption(text));
+ _poller.AddOption(new ShardPollOption(_poller, text));
}
else
{
diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs
index 8c7984360..05fc563b7 100644
--- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs
@@ -21,7 +21,11 @@ namespace Server.Mobiles
public int DevourGoal
{
get => IsParagon ? _devourGoal + 25 : _devourGoal;
- set => _devourGoal = value;
+ set
+ {
+ _devourGoal = value;
+ this.MarkDirty();
+ }
}
[Constructible]
diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs
index bbcef613e..0fd0c5bb3 100644
--- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs
@@ -52,9 +52,9 @@ namespace Server.Mobiles
VirtualArmor = 50;
}
- [CommandProperty(AccessLevel.GameMaster)]
- [SerializableProperty(3)]
- public Mobile OpenedBy { get; set; }
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ [SerializableField(3)]
+ private Mobile _openedBy;
[CommandProperty(AccessLevel.GameMaster)]
diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs
index 83c0e4cca..d0b1b2c7a 100644
--- a/Projects/UOContent/Mobiles/PlayerMobile.cs
+++ b/Projects/UOContent/Mobiles/PlayerMobile.cs
@@ -199,7 +199,7 @@ namespace Server.Mobiles
VisibilityList = new List();
PermaFlags = new List();
- BOBFilter = new BOBFilter();
+ BOBFilter = new BOBFilter(this);
m_GameTime = TimeSpan.Zero;
m_GuildRank = RankDefinition.Lowest;
@@ -2958,7 +2958,7 @@ namespace Server.Mobiles
case 13: // just removed m_PaidInsurance list
case 12:
{
- BOBFilter = new BOBFilter();
+ BOBFilter = new BOBFilter(this);
BOBFilter.Deserialize(reader);
goto case 11;
}
@@ -3081,7 +3081,7 @@ namespace Server.Mobiles
}
PermaFlags ??= new List();
- BOBFilter ??= new BOBFilter();
+ BOBFilter ??= new BOBFilter(this);
// Default to member if going from older version to new version (only time it should be null)
m_GuildRank ??= RankDefinition.Member;
diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
index 485d56c1d..135dd5ca4 100644
--- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
+++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
using Server.Engines.BulkOrders;
@@ -24,7 +25,8 @@ namespace Server.Mobiles
ThighBoots
}
- public abstract class BaseVendor : BaseCreature, IVendor
+ [SerializationGenerator(2, false)]
+ public abstract partial class BaseVendor : BaseCreature, IVendor
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseVendor));
private const int MaxSell = 500;
@@ -1297,109 +1299,28 @@ namespace Server.Mobiles
Region.GetRegion()?.CheckVendorAccess(this, from) != false ||
Region != from.Region && from.Region.GetRegion()?.CheckVendorAccess(this, from) != false;
- public override void Serialize(IGenericWriter writer)
+ [AfterDeserialization]
+ private void AfterDeserialization()
{
- base.Serialize(writer);
-
- writer.Write(1); // version
-
- var sbInfos = SBInfos;
-
- for (var i = 0; i < sbInfos?.Count; ++i)
- {
- var sbInfo = sbInfos[i];
- var buyInfo = sbInfo.BuyInfo;
-
- for (var j = 0; j < buyInfo?.Count; ++j)
- {
- var gbi = buyInfo[j];
-
- var maxAmount = gbi.MaxAmount;
-
- var doubled = maxAmount switch
- {
- 40 => 1,
- 80 => 2,
- 160 => 3,
- 320 => 4,
- 640 => 5,
- 999 => 6,
- _ => 0
- };
-
- if (doubled > 0)
- {
- writer.WriteEncodedInt(1 + j * sbInfos.Count + i);
- writer.WriteEncodedInt(doubled);
- }
- }
- }
-
- writer.WriteEncodedInt(0);
- }
-
- public override void Deserialize(IGenericReader reader)
- {
- base.Deserialize(reader);
-
- var version = reader.ReadInt();
-
LoadSBInfo();
- var sbInfos = SBInfos;
-
- switch (version)
- {
- case 1:
- {
- int index;
-
- while ((index = reader.ReadEncodedInt()) > 0)
- {
- var doubled = reader.ReadEncodedInt();
-
- if (sbInfos != null)
- {
- index -= 1;
- var sbInfoIndex = index % sbInfos.Count;
- var buyInfoIndex = index / sbInfos.Count;
-
- if (sbInfoIndex >= 0 && sbInfoIndex < sbInfos.Count)
- {
- var sbInfo = sbInfos[sbInfoIndex];
- var buyInfo = sbInfo.BuyInfo;
-
- if (buyInfo != null && buyInfoIndex >= 0 && buyInfoIndex < buyInfo.Count)
- {
- var gbi = buyInfo[buyInfoIndex];
-
- var amount = doubled switch
- {
- 1 => 40,
- 2 => 80,
- 3 => 160,
- 4 => 320,
- 5 => 640,
- 6 => 999,
- _ => 20
- };
-
- gbi.Amount = gbi.MaxAmount = amount;
- }
- }
- }
- }
-
- break;
- }
- }
-
if (IsParagon)
{
IsParagon = false;
}
}
+ // Version 1 persisted which buy entries had grown restock amounts, packed by index into
+ // the live SBInfos tables. Restock is transient now: it rebuilds from SBInfos on load, so
+ // the pairs are read and discarded.
+ private void Deserialize(IGenericReader reader, int version)
+ {
+ while (reader.ReadEncodedInt() > 0)
+ {
+ reader.ReadEncodedInt();
+ }
+ }
+
public override void AddCustomContextEntries(Mobile from, ref PooledRefList list)
{
if (from.Alive && IsActiveVendor)
diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs
index 9b8f5a2f3..6dc44c973 100644
--- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs
+++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs
@@ -188,7 +188,7 @@ public partial class PlayerVendor : Mobile
for (var i = 0; i < count; i++)
{
var item = reader.ReadEntity
- ();
- var vi = new VendorItem();
+ var vi = new VendorItem(this);
vi.Deserialize(reader);
_sellItems[item] = vi;
}
@@ -447,7 +447,7 @@ public partial class PlayerVendor : Mobile
{
RemoveVendorItem(item);
- var vi = new VendorItem(item, price, description, created);
+ var vi = new VendorItem(this, item, price, description, created);
ReplaceInSellItems(item, vi);
item.InvalidateProperties();
diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs
index 99d1446e6..4c1e12467 100644
--- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs
+++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs
@@ -7,6 +7,9 @@ namespace Server.Mobiles;
[SerializationGenerator(0, false)]
public partial class VendorItem
{
+ [DirtyTrackingEntity]
+ private PlayerVendor _vendor;
+
[SerializableField(0)]
private Item _item;
@@ -16,12 +19,13 @@ public partial class VendorItem
[SerializableField(3)]
private DateTime _created;
- public VendorItem()
- {
- }
+ // The generator deserializes dictionary values through this constructor so every entry
+ // knows its vendor.
+ public VendorItem(PlayerVendor vendor) => _vendor = vendor;
- public VendorItem(Item item, int price, string description, DateTime created)
+ public VendorItem(PlayerVendor vendor, Item item, int price, string description, DateTime created)
{
+ _vendor = vendor;
_item = item;
_price = price;
_description = description ?? "";
diff --git a/Projects/UOContent/Systems/JailSystem/JailRecord.cs b/Projects/UOContent/Systems/JailSystem/JailRecord.cs
index c57ef8622..2cec17d55 100644
--- a/Projects/UOContent/Systems/JailSystem/JailRecord.cs
+++ b/Projects/UOContent/Systems/JailSystem/JailRecord.cs
@@ -1,11 +1,18 @@
using System;
using ModernUO.Serialization;
+using Server.Mobiles;
namespace Server.Systems.JailSystem;
[SerializationGenerator(0)]
public partial class JailRecord
{
+ [DirtyTrackingEntity]
+ [CanBeNull]
+ private PlayerMobile _player;
+
+ public JailRecord(PlayerMobile player) => _player = player;
+
[SerializableField(0)]
private int _jailCount;
diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs
index c79c44303..8802d0487 100644
--- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs
+++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs
@@ -42,7 +42,7 @@ public class JailSystem : GenericPersistence
// Jail map, change this for custom maps
public static readonly Map JailMap = Map.Felucca;
- private static readonly JailRecord EmptyRecord = new();
+ private static readonly JailRecord EmptyRecord = new(null);
private static readonly HashSet CurrentlyBeingJailed = [];
private static readonly Dictionary PlayerJailRecords = [];
@@ -96,7 +96,7 @@ public class JailSystem : GenericPersistence
if (!PlayerJailRecords.TryGetValue(player, out var record))
{
- PlayerJailRecords[player] = record = new JailRecord();
+ PlayerJailRecords[player] = record = new JailRecord(player);
}
record.JailCount++;
@@ -409,7 +409,7 @@ public class JailSystem : GenericPersistence
for (var i = 0; i < count; i++)
{
var player = reader.ReadEntity();
- var record = new JailRecord();
+ var record = new JailRecord(player);
record.Deserialize(reader);
if (player != null)
diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj
index 60f28ac6f..40919e197 100644
--- a/Projects/UOContent/UOContent.csproj
+++ b/Projects/UOContent/UOContent.csproj
@@ -50,8 +50,8 @@
-
-
+
+
From 003491472f08520bf9905abe54d715b894545902 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Sun, 6 Sep 2026 13:46:14 -0700
Subject: [PATCH 03/21] =?UTF-8?q?fix:=20world=20save=20failure=20handling?=
=?UTF-8?q?=20=E2=80=94=20no=20partial=20snapshots,=20no=20worker=20crash,?=
=?UTF-8?q?=20staged=20publication=20(#2612)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three pre-existing world-save failure-handling bugs, found while reviewing the delta-saves engine (#2610), split out so they land and get adopted on their own before the larger change. #2610 will be rebased on top once this merges. Tracked in project Delta Saves (#7).
## 1. A snapshot missing a segment was published
`GenericEntityPersistence.WriteSnapshot` logged a segment (or self-payload) write error and carried on. The save was published without those records, and every entity in the failed segment was deleted at the next load. The error now propagates: `WriteFiles` never moves a partial snapshot over `Saves/`, the previous save stays authoritative, and staff are told.
## 2. A serializer exception killed the process, or published a partial freeze
An entity whose `Serialize` threw on a worker thread was an unhandled exception on that thread, which terminates the process. On the inline (main-thread) drain it was caught, but the snapshot was still queued for writing with whatever the workers had produced. Workers now record the first exception and keep draining so the queue empties and the wake/pause handshake completes; after every worker paused, `Snapshot` treats a recorded error (or a failure of the drain itself) as a failed save: nothing is written, the previous save stays, staff are told, and the world resumes. A `WorldSave` handler throwing is logged but does not invalidate the serialized snapshot.
## 3. Publication was not transactional
Publishing moved the previous `Saves/` away (`AutoArchive` in `WorldSavePostSnapshot`) and only then moved the new files in. A subscriber throwing after the archive, or the final move failing, left nothing at `Saves/` until the next save; a crash in that window lost the newest save at the next boot.
The snapshot now moves to `Saves.next` as soon as its files are complete; only then do subscribers archive the previous save (same event, same `OldSavePath`, so `AutoArchive` and shard subscribers are unchanged), and the staged directory is renamed into place, which is atomic on one volume (file-by-file move across volumes). A staged directory is by construction a complete save newer than `Saves/`, so an interrupted publish is finished at the next boot (before load) or before the next save: whatever sits at `Saves/` is set aside as `Saves.previous-` (never deleted) and the staged save is published, with a warning naming the directory to archive or delete by hand.
## Tests
Server.Tests 860 / UOContent.Tests green. New: a throwing serializer is recorded on the worker and the next drain starts clean; a segment that cannot be indexed fails `WriteSnapshot` instead of dropping records; staged-save recovery with and without an existing `Saves/`, and as a no-op.
---
.../GenericEntityPersistenceRoundTripTests.cs | 150 +++++++++++++++++-
.../Serialization/StagedSavePublishTests.cs | 83 ++++++++++
.../Serialization/GenericEntityPersistence.cs | 7 +-
.../SerializationThreadWorker.cs | 27 ++++
Projects/Server/World/World.cs | 144 +++++++++++++++--
5 files changed, 390 insertions(+), 21 deletions(-)
create mode 100644 Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs
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/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs
index 9a1490504..70e9c6aab 100644
--- a/Projects/Server/Serialization/GenericEntityPersistence.cs
+++ b/Projects/Server/Serialization/GenericEntityPersistence.cs
@@ -158,6 +158,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
_selfPosition,
_selfLength
);
+ throw;
}
binPosition += _selfLength;
@@ -205,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)",
@@ -212,6 +214,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer
segment.HeapStart,
segment.RecordCount
);
+ throw;
}
}
}
@@ -318,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/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/World/World.cs b/Projects/Server/World/World.cs
index c00fc85f6..7ce1b52f2 100644
--- a/Projects/Server/World/World.cs
+++ b/Projects/Server/World/World.cs
@@ -122,6 +122,8 @@ public static class World
UseMultiThreadedSaves = ServerConfiguration.GetOrUpdateSetting("world.useMultithreadedSaves", true);
}
+ internal static void SetSavePathForTest(string path) => SavePath = path;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void WaitForWriteCompletion() => _diskWriteHandle.WaitOne();
@@ -195,6 +197,8 @@ public static class World
logger.Information("Loading world");
var watch = Stopwatch.StartNew();
+ RecoverStagedSave();
+
Persistence.Load(SavePath);
EventSink.InvokeWorldLoad();
@@ -271,6 +275,8 @@ public static class World
{
try
{
+ RecoverStagedSave();
+
// Allocate the heaps for the GC
foreach (var worker in _threadWorkers)
{
@@ -322,22 +328,49 @@ public static class World
}
Persistence.SerializeAll();
- PauseSerializationThreads();
- LogWorkerBalance();
-
- EventSink.InvokeWorldSave();
}
catch (Exception ex)
{
exception = ex;
}
- WorldState = WorldState.WritingSave;
- ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath);
+ // Always join the workers; any serializer exception fails the save.
+ try
+ {
+ PauseSerializationThreads();
+ }
+ catch (Exception ex)
+ {
+ exception ??= ex;
+ }
+
+ for (var i = 0; i < _threadWorkers.Length; i++)
+ {
+ exception ??= _threadWorkers[i].Error;
+ }
+
+ if (exception == null)
+ {
+ LogWorkerBalance();
+
+ try
+ {
+ EventSink.InvokeWorldSave();
+ }
+ catch (Exception ex)
+ {
+ logger.Error(ex, "A WorldSave handler failed");
+ Persistence.TraceException(ex);
+ }
+ }
+
watch.Stop();
if (exception == null)
{
+ WorldState = WorldState.WritingSave;
+ ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath);
+
var duration = watch.Elapsed.TotalSeconds;
logger.Information("Saving world {Status} ({Duration:F2} seconds)", "done", duration);
@@ -349,6 +382,9 @@ public static class World
Persistence.TraceException(exception);
BroadcastStaff(0x35, true, "World save failed! Check the logs!");
+
+ _diskWriteHandle.Set();
+ FinishWorldSave();
}
}
@@ -361,17 +397,7 @@ public static class World
logger.Information("Writing world save snapshot");
Persistence.WriteSnapshotAll(snapshotPath);
-
- try
- {
- EventSink.InvokeWorldSavePostSnapshot(SavePath, snapshotPath);
- PathUtility.MoveDirectoryContents(snapshotPath, SavePath);
- Directory.SetLastWriteTimeUtc(SavePath, Core.Now);
- }
- catch (Exception ex)
- {
- Persistence.TraceException(ex);
- }
+ PublishSnapshot(snapshotPath);
watch.Stop();
logger.Information("Writing world save snapshot {Status} ({Duration:F2} seconds)", "done", watch.Elapsed.TotalSeconds);
@@ -388,6 +414,90 @@ public static class World
Core.LoopContext.Post(FinishWorldSave);
}
+ ///
+ /// A complete snapshot is staged here before the previous save is touched; a staged
+ /// directory is always a complete save newer than .
+ ///
+ internal static string StagedSavePath => SavePath + ".next";
+
+ // Stage, let subscribers archive the previous save, then rename the staged save into place.
+ private static void PublishSnapshot(string snapshotPath)
+ {
+ var staging = StagedSavePath;
+
+ if (Directory.Exists(staging))
+ {
+ SetAside(staging, "unpublished");
+ }
+
+ MoveDirectory(snapshotPath, staging);
+ PublishStagedSave(archive: true);
+ }
+
+ private static void PublishStagedSave(bool archive)
+ {
+ var staging = StagedSavePath;
+
+ if (archive)
+ {
+ EventSink.InvokeWorldSavePostSnapshot(SavePath, staging);
+ }
+
+ if (Directory.Exists(SavePath))
+ {
+ SetAside(SavePath, "previous");
+ }
+
+ MoveDirectory(staging, SavePath);
+ Directory.SetLastWriteTimeUtc(SavePath, Core.Now);
+ }
+
+ ///
+ /// Finishes an interrupted publish. Runs at boot (before load) and before every save;
+ /// whatever is at Saves/ is set aside, never deleted.
+ ///
+ internal static void RecoverStagedSave()
+ {
+ var staging = StagedSavePath;
+
+ if (!Directory.Exists(staging))
+ {
+ return;
+ }
+
+ logger.Warning(
+ "A complete world save was staged at {Staging} but never published; publishing it now.",
+ staging
+ );
+
+ PublishStagedSave(archive: false);
+ }
+
+ private static void SetAside(string path, string reason)
+ {
+ var aside = $"{path}.{reason}-{Core.Now:yyyy-MM-dd-HH-mm-ss-fff}";
+ MoveDirectory(path, aside);
+ logger.Warning("Set aside {Path} as {Aside}; delete or archive it by hand.", path, aside);
+ }
+
+ // Atomic rename on one volume, file-by-file move otherwise.
+ private static void MoveDirectory(string source, string destination)
+ {
+ try
+ {
+ Directory.Move(source, destination);
+ }
+ catch (IOException)
+ {
+ if (Directory.Exists(destination))
+ {
+ throw;
+ }
+
+ PathUtility.MoveDirectoryContents(source, destination);
+ }
+ }
+
private static void FinishWorldSave()
{
WorldState = WorldState.Running;
From 392c4e16d586c54b2e2d56cded3ab16144dac584 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Sun, 6 Sep 2026 14:11:51 -0700
Subject: [PATCH 04/21] refactor: Changes BaseCreature to the
SerializationGenerator (delta save requirement) (#2611)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pure SerializationGenerator conversion of `BaseCreature`, split out of #2592 so it can serve as the reference for converting every other large hand-written class in the Delta Saves project (#7, phase 3). Two behaviour changes from #2592 are deliberately not here and follow in their own PRs on top of this one: `SpeedClass`, and the collapse of `ControlMaster`/`SummonMaster` into one reference (a creature can lose or keep either independently: Blade Spirits and Energy Vortexes are summoned but never controlled, EnragedCreature and talisman summons keep a summon master with neither flag set).
## What this is
- `BaseCreature` becomes `[SerializationGenerator(23, false)]` with a `[SerializableField]` per serialized slot and `[SaveFlag]` elision on nearly every field, so a stock creature serializes to its version plus flags. Field orders run 0..53 with no gaps (54 fields).
- The hand-written reader stays as `private void Deserialize(IGenericReader reader, int version)` for every pre-codegen version (0..22); post-codegen bumps use `MigrateFrom` from here on. `[AfterDeserialization]` carries the post-load fixups main did after reading (stat timers, AI type, followers, reacquire seeding).
- `ControlMaster` and `SummonMaster` stay two independent fields with main's semantics, each elided when null.
- `DamageMin`/`DamageMax`/`ActiveSpeed`/`PassiveSpeed` are no longer virtual (nothing in the tree overrode them); comments swept to the constraints that matter.
- Direct writes to serialized backing fields outside the generated setters (`SetDamage`, `SetResistance`, the move-speed helpers, the loot flag, feed loyalty, the delete timer) call `this.MarkDirty()`, matching the #2609 standard, so the class is ready for delta saves once `Mobile` is audited.
- Schema `Server.Mobiles.BaseCreature.v23.json` regenerated by the tool (a second run produces no diff).
## Deferred to follow-up PRs
SpeedClass: the serialized `_speedClass` field, `DefaultSpeedClass` replacing the type constant, `ApplySpeedClass`/`OnSpeedClassChange`, "None means custom", the four-speeds-as-one-block elision, `NPCSpeeds.FindEntry(SpeedLevel)`, the constructor fallback to Medium, and their tests.
Master references: serializing one `Master` with a `Controlled`/`Summoned` fan-out and the `SetControlMaster` lockstep.
## Tests
UOContent.Tests 776 / Server.Tests 855 green. `BaseCreatureSerializationTests` covers: a default creature elides to version + flags; a populated creature round-trips with exact byte consumption; back-to-back saves are byte-identical; an uncontrolled summon keeps its SummonMaster; byte-authentic v22 legacy streams (replicas of main's `Serialize`) load through the legacy reader for a wild tamable, a controlled pet, a controlled summon with an anchored `SummonEnd`, and a summon-master-only creature (the EnragedCreature shape); a running delete timer round-trips through `[DeserializeTimer]`; `Friends`, `CurrentWayPoint` and `HomeMap` round-trip; a `BaseVendor` stub round-trips the generated BaseVendor v2 → generated BaseCreature v23 chain.
## Behaviour notes for reviewers
- `ActiveMoveSpeed`/`PassiveMoveSpeed` getters return the raw override (0 = inherit); `CurrentMoveSpeed` is the resolved pace.
- Speeds elided as table defaults re-snap to the current `npc-speeds.json` on load, so table edits reach unmodified spawns on restart.
- `GetSpeeds` no longer throws on the save/load path when the table has no entry for the type: saves elide against the creature's own values and loads keep the stream. Construction still throws (`InvalidOperationException`, was `KeyNotFoundException`). An elided load with no table entry would otherwise resume at speed 0, so `[AfterDeserialization]` logs once and paces it at Medium.
- `virtual` removed from `ActiveSpeed`, `PassiveSpeed`, `DamageMin`, `DamageMax` (no overrides in the tree; forks may have some).
- `ControlMaster`, `SummonMaster`, `ControlOrder`, `Tamable`, `IsParagon` are `[SerializableProperty]` over hand-written setters because follower bookkeeping must run before the assignment, which a `fieldChanged` hook cannot express; the wire format is identical.
## Prerequisites for cherry-picking
#2609 (BaseVendor is already generated on top of BaseCreature) and SerializationGenerator 4.1.0.
---
.../Tests/Mobiles/AI/MoveSpeedTests.cs | 25 +-
.../Mobiles/BaseCreatureSerializationTests.cs | 455 +++++
.../Spells/Necromancy/BloodOathSpellTests.cs | 2 +-
.../Engines/CannedEvil/ChampionTitleSystem.cs | 1 -
.../PlayerMurderSystem.cs | 1 -
.../UOContent/Engines/Virtues/VirtueSystem.cs | 1 -
.../UOContent/Items/Misc/ProjectedItem.cs | 1 -
.../Items/Weapons/BaseWeapon.Migrations.cs | 1 -
.../Server.Mobiles.BaseCreature.v23.json | 471 ++++++
.../Mobiles/Abilities/MonsterAbility.cs | 4 +-
Projects/UOContent/Mobiles/BaseCreature.cs | 1490 +++++++++--------
Projects/UOContent/Mobiles/CreatureEvents.cs | 13 +
.../Mobiles/Monsters/LBR/Meers/MeerMage.cs | 2 +-
Projects/UOContent/Mobiles/NPCSpeeds.cs | 24 +-
Projects/UOContent/Skills/AntiMacroSystem.cs | 1 -
Projects/UOContent/Skills/DetectHidden.cs | 1 -
.../Spells/Necromancy/BloodOathSpell.cs | 4 +-
.../Spells/Spellweaving/GiftOfLife.cs | 2 +-
dev-docs/content-patterns.md | 5 +-
19 files changed, 1763 insertions(+), 741 deletions(-)
create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json
create mode 100644 Projects/UOContent/Mobiles/CreatureEvents.cs
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
index 7d9243a29..8637c5e59 100644
--- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
@@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable
{
var bc = NewCreature();
- Assert.Equal(0.3, bc.ActiveMoveSpeed);
- Assert.Equal(0.6, bc.PassiveMoveSpeed);
+ // 0 = no override; the resolved pace comes from CurrentMoveSpeed.
+ Assert.Equal(0, bc.ActiveMoveSpeed);
+ Assert.Equal(0, bc.PassiveMoveSpeed);
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
}
@@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable
bc.SetSpeed(0.2, 0.4);
- Assert.Equal(0.2, bc.ActiveMoveSpeed);
- Assert.Equal(0.4, bc.PassiveMoveSpeed);
+ Assert.Equal(0, bc.ActiveMoveSpeed);
+ Assert.Equal(0, bc.PassiveMoveSpeed);
}
[Fact]
@@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable
bc.ActiveMoveSpeed = 0;
- Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again
+ Assert.Equal(0, bc.ActiveMoveSpeed); // inheriting again
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
+ bc.SetCurrentSpeedToActive();
+ Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock
}
[Fact]
@@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable
bc.ScaleMoveSpeed(1.0 / 1.2);
Assert.Equal(0.5, bc.ActiveMoveSpeed);
- Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
+ Assert.Equal(0, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
}
[Fact]
@@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable
bc.MigrateMoveSpeeds();
- Assert.Equal(0.35, bc.ActiveMoveSpeed);
- Assert.Equal(0.6, bc.PassiveMoveSpeed);
+ Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock
+ Assert.Equal(0, bc.PassiveMoveSpeed);
}
[Theory]
@@ -211,9 +214,9 @@ public class MoveSpeedTests : IDisposable
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
- // The v22 tail is the last block; exact consumption catches any offset mistake.
+ // The BaseCreature tail is the last block; exact consumption catches any offset mistake.
Assert.Equal(buffer.Length, reader.Position);
- Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed);
- Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed);
+ Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed);
+ Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed);
}
}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
new file mode 100644
index 000000000..51a4de573
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
@@ -0,0 +1,455 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Items;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles;
+
+// BaseCreature's move to the SerializationGenerator (v23) is guarded three ways: the new
+// SaveFlag format round-trips both a default and a fully-populated creature with exact
+// byte consumption, back-to-back saves are byte-identical (freeze-time stability), and a
+// byte-authentic pre-codegen v22 stream (written by a fossilized replica of the old
+// Serialize) loads through the legacy path with the table-speed migration applied.
+[Collection("Sequential UOContent Tests")]
+public class BaseCreatureSerializationTests : IDisposable
+{
+ private readonly List _created = new();
+ private readonly List- _createdItems = new();
+
+ public void Dispose()
+ {
+ for (var i = 0; i < _created.Count; i++)
+ {
+ _created[i].Delete();
+ }
+
+ for (var i = 0; i < _createdItems.Count; i++)
+ {
+ _createdItems[i].Delete();
+ }
+ }
+
+ private class CreatureStub : BaseCreature
+ {
+ public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9;
+
+ public CreatureStub(Serial serial) : base(serial) => Body = 0xC9;
+
+ public DateTime SummonEndValue => SummonEnd;
+
+ // Stands in for the npc-speeds table (unconfigured in the test fixture).
+ public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
+ {
+ activeSpeed = 0.3;
+ passiveSpeed = 0.6;
+ }
+
+ public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
+ {
+ activeMoveSpeed = 0.6;
+ passiveMoveSpeed = 1.2;
+ }
+ }
+
+ private CreatureStub NewCreature()
+ {
+ var bc = new CreatureStub();
+ _created.Add(bc);
+ return bc;
+ }
+
+ // ReadEntity resolves references through the world table, so the master must be registered.
+ private PlayerMobile NewMaster()
+ {
+ var master = new PlayerMobile(World.NewMobile);
+ master.DefaultMobileInit();
+ World.AddEntity(master);
+ _created.Add(master);
+ return master;
+ }
+
+ private static byte[] Snapshot(Mobile m)
+ {
+ var writer = new BufferWriter(true);
+ m.Serialize(writer);
+
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+ return buffer;
+ }
+
+ private CreatureStub Load(byte[] buffer)
+ {
+ var copy = new CreatureStub(World.NewMobile);
+ _created.Add(copy);
+ var reader = new BufferReader(buffer);
+ copy.Deserialize(reader);
+
+ Assert.Equal(buffer.Length, reader.Position); // exact consumption
+ return copy;
+ }
+
+ [Fact]
+ public void DefaultCreature_RoundTrips_AndElidesEverything()
+ {
+ var bc = NewCreature();
+
+ var buffer = Snapshot(bc);
+ var copy = Load(buffer);
+
+ Assert.Equal(AIType.AI_Melee, copy.AI);
+ Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception);
+ Assert.Equal(0.3, copy.ActiveSpeed);
+ Assert.Equal(0.6, copy.PassiveSpeed);
+ Assert.Equal(0.6, copy.CurrentSpeed);
+ Assert.Equal(0.6, copy.ActiveMoveSpeed); // pulled from the table, not the wire
+ Assert.Equal(1.2, copy.PassiveMoveSpeed);
+ Assert.Equal(100, copy.PhysicalDamage);
+ Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
+ Assert.Equal(1, copy.ControlSlots);
+ Assert.NotNull(copy.Owners);
+ Assert.Empty(copy.Owners);
+ }
+
+ [Fact]
+ public void BackToBackSaves_AreByteIdentical()
+ {
+ var bc = NewCreature();
+ bc.SetDamage(5, 10);
+ bc.PhysicalResistanceSeed = 25;
+
+ Assert.Equal(Snapshot(bc), Snapshot(bc));
+ }
+
+ [Fact]
+ public void PopulatedCreature_RoundTrips()
+ {
+ var bc = NewCreature();
+ var master = NewMaster();
+
+ bc.Tamable = true;
+ bc.MinTameSkill = 47.1;
+ bc.SetControlMaster(master);
+ bc.Owners.Add(master);
+ bc.ControlOrder = OrderType.Guard;
+ bc.SetDamage(11, 17);
+ bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table
+ bc.SetMoveSpeed(0.25, 0.5);
+ bc.PhysicalResistanceSeed = 40;
+ bc.EnergyResistSeed = 15;
+ bc.FireDamage = 25;
+ bc.PhysicalDamage = 75;
+ bc.HitsMaxSeed = 250;
+ bc.Loyalty = 55;
+ bc.Home = new Point3D(1000, 1100, 5);
+ bc.RangeHome = 4;
+ bc.Team = 3;
+ bc.IsBonded = true;
+ bc.BondingBegin = Core.Now;
+ bc.RemoveIfUntamed = true;
+ bc.RemoveStep = 2;
+ bc.CorpseNameOverride = "a test corpse";
+
+ var copy = Load(Snapshot(bc));
+
+ Assert.True(copy.Controlled);
+ Assert.Equal(master, copy.ControlMaster);
+ Assert.Equal(OrderType.Guard, copy.ControlOrder);
+ Assert.True(copy.Tamable);
+ Assert.Equal(47.1, copy.MinTameSkill);
+ Assert.Equal(11, copy.DamageMin);
+ Assert.Equal(17, copy.DamageMax);
+ Assert.Equal(0.2, copy.ActiveSpeed);
+ Assert.Equal(0.4, copy.PassiveSpeed);
+ Assert.Equal(0.25, copy.ActiveMoveSpeed);
+ Assert.Equal(0.5, copy.PassiveMoveSpeed);
+ Assert.Equal(40, copy.PhysicalResistanceSeed);
+ Assert.Equal(15, copy.EnergyResistSeed);
+ Assert.Equal(25, copy.FireDamage);
+ Assert.Equal(75, copy.PhysicalDamage);
+ Assert.Equal(250, copy.HitsMaxSeed);
+ Assert.Equal(55, copy.Loyalty);
+ Assert.Equal(new Point3D(1000, 1100, 5), copy.Home);
+ Assert.Equal(4, copy.RangeHome);
+ Assert.Equal(3, copy.Team);
+ Assert.True(copy.IsBonded);
+ Assert.Equal(bc.BondingBegin, copy.BondingBegin);
+ Assert.True(copy.RemoveIfUntamed);
+ Assert.Equal(2, copy.RemoveStep);
+ Assert.Equal("a test corpse", copy.CorpseNameOverride);
+ Assert.Equal(master, copy.LastOwner);
+ }
+
+ [Fact]
+ public void UncontrolledSummon_KeepsItsSummonMaster()
+ {
+ var bc = NewCreature();
+ var master = NewMaster();
+
+ // Energy vortex-style: summoned with a master, never controlled.
+ bc.Summoned = true;
+ bc.SummonMaster = master;
+
+ var copy = Load(Snapshot(bc));
+
+ Assert.True(copy.Summoned);
+ Assert.False(copy.Controlled);
+ Assert.Equal(master, copy.SummonMaster);
+ Assert.Null(copy.ControlMaster);
+ }
+
+ [Fact]
+ public void ReferenceFields_RoundTrip()
+ {
+ var bc = NewCreature();
+ var friend = NewMaster();
+ var wayPoint = new WayPoint();
+ _createdItems.Add(wayPoint);
+
+ bc.AddPetFriend(friend);
+ bc.CurrentWayPoint = wayPoint;
+ bc.HomeMap = Map.Felucca;
+
+ var copy = Load(Snapshot(bc));
+
+ Assert.Equal(friend, Assert.Single(copy.Friends));
+ Assert.Equal(wayPoint, copy.CurrentWayPoint);
+ Assert.Equal(Map.Felucca, copy.HomeMap);
+ }
+
+ [Fact]
+ public void RunningDeleteTimer_RoundTrips()
+ {
+ var bc = NewCreature();
+ bc.BeginDeleteTimer();
+ Assert.True(bc.DeleteTimeLeft > TimeSpan.Zero);
+
+ var copy = Load(Snapshot(bc));
+
+ // Anchored: the remaining countdown survives, not the absolute deadline.
+ Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0));
+ }
+
+ private sealed class VendorStub : BaseVendor
+ {
+ private static readonly List _sbInfos = [];
+
+ public VendorStub() : base("the stub")
+ {
+ }
+
+ public VendorStub(Serial serial) : base(serial)
+ {
+ }
+
+ protected override List SBInfos => _sbInfos;
+
+ public override void InitSBInfo()
+ {
+ }
+
+ public override void InitOutfit()
+ {
+ }
+
+ public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
+ {
+ activeSpeed = 0.3;
+ passiveSpeed = 0.6;
+ }
+ }
+
+ // BaseVendor is generated on top of the generated BaseCreature; the chain must write
+ // and read both sections in order with exact consumption.
+ [Fact]
+ public void GeneratedVendorChain_RoundTrips()
+ {
+ var vendor = new VendorStub();
+ _created.Add(vendor);
+ vendor.Home = new Point3D(1500, 1600, 0);
+ vendor.RangeHome = 2;
+
+ var buffer = Snapshot(vendor);
+
+ var copy = new VendorStub(World.NewMobile);
+ _created.Add(copy);
+ var reader = new BufferReader(buffer);
+ copy.Deserialize(reader);
+
+ Assert.Equal(buffer.Length, reader.Position);
+ Assert.Equal(AIType.AI_Vendor, copy.AI);
+ Assert.Equal(FightMode.None, copy.FightMode);
+ Assert.Equal(new Point3D(1500, 1600, 0), copy.Home);
+ Assert.Equal(2, copy.RangeHome);
+ }
+
+ private sealed class MobileStub : Mobile
+ {
+ public MobileStub() => Body = 0xC9;
+ }
+
+ // Byte-authentic replica of the pre-codegen v22 tail — fossilized so the legacy
+ // upgrade path stays covered without an old save binary. The full stream is a plain
+ // Mobile section (identical layout for every Mobile subclass) followed by this tail.
+ private static void WriteLegacyV22Tail(
+ IGenericWriter writer,
+ bool controlled = false,
+ Mobile controlMaster = null,
+ bool summoned = false,
+ Mobile summonMaster = null,
+ DateTime summonEnd = default
+ )
+ {
+ writer.Write(22); // version
+ writer.Write((int)AIType.AI_Melee); // current AI
+ writer.Write((int)AIType.AI_Melee); // default AI
+ writer.Write(10); // RangePerception
+ writer.Write(1); // RangeFight
+ writer.Write(0); // Team
+ writer.Write(0.3); // active (matches the stub table)
+ writer.Write(0.6); // passive
+ writer.Write(0.6); // current
+ writer.Write(2000); // Home X
+ writer.Write(2100); // Home Y
+ writer.Write(7); // Home Z
+ writer.Write(6); // RangeHome
+ writer.Write((int)FightMode.Closest);
+ writer.Write(controlled);
+ writer.Write(controlMaster);
+ writer.Write((Mobile)null); // control target
+ writer.Write(Point3D.Zero); // control dest
+ writer.Write((int)OrderType.None);
+ writer.Write(0.0); // min tame skill
+ writer.Write(true); // tamable
+ writer.Write(summoned);
+ if (summoned)
+ {
+ writer.WriteAnchoredTime(summonEnd);
+ }
+
+ writer.Write(2); // control slots
+ writer.Write(73); // loyalty
+ writer.Write((Item)null); // waypoint
+ writer.Write(summonMaster);
+ writer.Write(180); // hits seed
+ writer.Write(-1); // stam seed
+ writer.Write(-1); // mana seed
+ writer.Write(7); // damage min
+ writer.Write(14); // damage max
+ writer.Write(30); // phys resist
+ writer.Write(100); // phys damage
+ writer.Write(10); // fire resist
+ writer.Write(0); // fire damage
+ writer.Write(0); // cold resist
+ writer.Write(0); // cold damage
+ writer.Write(0); // poison resist
+ writer.Write(0); // poison damage
+ writer.Write(0); // energy resist
+ writer.Write(0); // energy damage
+ writer.Write(new List()); // owners
+ writer.Write(false); // dead pet
+ writer.Write(false); // bonded
+ writer.Write(DateTime.MinValue); // bonding begin
+ writer.Write(DateTime.MinValue); // abandon time
+ writer.Write(true); // has generated loot
+ writer.Write(false); // paragon
+ writer.Write(false); // has friends
+ writer.Write(false); // remove if untamed
+ writer.Write(0); // remove step
+ writer.Write(TimeSpan.Zero); // delete time left
+ writer.Write((string)null); // corpse name override
+ writer.Write((Map)null); // home map
+ writer.Write(0.0); // active move speed (v22)
+ writer.Write(0.0); // passive move speed (v22)
+ }
+
+ private CreatureStub LoadLegacyV22(
+ bool controlled = false,
+ Mobile controlMaster = null,
+ bool summoned = false,
+ Mobile summonMaster = null,
+ DateTime summonEnd = default
+ )
+ {
+ // Every serialized BaseCreature starts with the Mobile base section; a plain
+ // Mobile donor produces a byte-authentic one.
+ var donor = new MobileStub();
+ donor.DefaultMobileInit();
+ _created.Add(donor);
+
+ var writer = new BufferWriter(true);
+ donor.Serialize(writer);
+ WriteLegacyV22Tail(writer, controlled, controlMaster, summoned, summonMaster, summonEnd);
+
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+
+ var copy = new CreatureStub(World.NewMobile);
+ _created.Add(copy);
+ var reader = new BufferReader(buffer);
+ copy.Deserialize(reader);
+
+ Assert.Equal(buffer.Length, reader.Position);
+ return copy;
+ }
+
+ [Fact]
+ public void LegacyV22Stream_LoadsThroughLegacyPath()
+ {
+ var copy = LoadLegacyV22();
+
+ Assert.Equal(10, copy.RangePerception);
+ Assert.Equal(new Point3D(2000, 2100, 7), copy.Home);
+ Assert.Equal(6, copy.RangeHome);
+ Assert.True(copy.Tamable);
+ Assert.Equal(2, copy.ControlSlots);
+ Assert.Equal(73, copy.Loyalty);
+ Assert.Equal(180, copy.HitsMaxSeed);
+ Assert.Equal(7, copy.DamageMin);
+ Assert.Equal(14, copy.DamageMax);
+ Assert.Equal(30, copy.PhysicalResistanceSeed);
+ Assert.Equal(10, copy.FireResistSeed);
+ Assert.Equal(0.3, copy.ActiveSpeed);
+ // v22 wrote explicit zeros for the move overrides ("inherit"), so the resolved
+ // pace falls back to the think clock.
+ Assert.Equal(0, copy.ActiveMoveSpeed);
+ Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting
+ }
+
+ // ControlMaster and SummonMaster are independent references: a summon master can
+ // exist without Summoned (EnragedCreature), and a controlled summon carries both.
+ [Theory]
+ [InlineData(true, true, false, false)] // controlled pet
+ [InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire
+ [InlineData(false, false, false, true)] // EnragedCreature shape: SummonMaster only
+ public void LegacyV22Stream_KeepsBothMasterReferences(
+ bool controlled,
+ bool hasControlMaster,
+ bool summoned,
+ bool hasSummonMaster
+ )
+ {
+ var master = NewMaster();
+ var summonEnd = Core.Now + TimeSpan.FromMinutes(5);
+
+ var copy = LoadLegacyV22(
+ controlled,
+ hasControlMaster ? master : null,
+ summoned,
+ hasSummonMaster ? master : null,
+ summonEnd
+ );
+
+ Assert.Equal(controlled, copy.Controlled);
+ Assert.Equal(summoned, copy.Summoned);
+ Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster);
+ Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster);
+
+ if (summoned)
+ {
+ Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1));
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs
index 0264a282e..08658b1a1 100644
--- a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs
+++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs
@@ -121,7 +121,7 @@ public class BloodOathSpellTests
BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5));
- BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
+ CreatureEvents.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
Assert.Null(BloodOathSpell.GetBloodOath(target));
Assert.False(BloodOathSpell.RemoveCurse(caster));
diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs
index 6160c4595..4c74ee832 100644
--- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs
+++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.CodeGeneratedEvents;
-using Server.Collections;
using Server.Mobiles;
namespace Server.Engines.CannedEvil;
diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs
index d1be82aa9..80ba553fc 100644
--- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs
+++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using ModernUO.CodeGeneratedEvents;
-using Server.Collections;
using Server.Logging;
using Server.Misc;
using Server.Mobiles;
diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs
index 457899b91..9a0fd7148 100644
--- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs
+++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.CodeGeneratedEvents;
-using Server.Collections;
using Server.Logging;
using Server.Mobiles;
diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs
index 03e93d0ea..71e339074 100644
--- a/Projects/UOContent/Items/Misc/ProjectedItem.cs
+++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
-using Server.Collections;
using Server.Network;
namespace Server.Items;
diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs
index 3385598d9..78937f06f 100644
--- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs
+++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs
@@ -1,5 +1,4 @@
using System;
-using Server.Engines.Craft;
namespace Server.Items;
diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json
new file mode 100644
index 000000000..b700154ab
--- /dev/null
+++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json
@@ -0,0 +1,471 @@
+{
+ "version": 23,
+ "type": "Server.Mobiles.BaseCreature",
+ "properties": [
+ {
+ "name": "DefaultAI",
+ "type": "Server.Mobiles.AIType",
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "CurrentAI",
+ "type": "Server.Mobiles.AIType",
+ "usesSaveFlag": true,
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "RangePerception",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "RangeFight",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "RangeHome",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "Team",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "FightMode",
+ "type": "Server.Mobiles.FightMode",
+ "usesSaveFlag": true,
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "ActiveSpeed",
+ "type": "double",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "PassiveSpeed",
+ "type": "double",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "CurrentSpeed",
+ "type": "double",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "ActiveMoveSpeed",
+ "type": "double",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "PassiveMoveSpeed",
+ "type": "double",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Home",
+ "type": "Server.Point3D",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Point3D"
+ ]
+ },
+ {
+ "name": "HomeMap",
+ "type": "Server.Map",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Map"
+ ]
+ },
+ {
+ "name": "Controlled",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "ControlMaster",
+ "type": "Server.Mobile",
+ "usesSaveFlag": true,
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "ControlTarget",
+ "type": "Server.Mobile",
+ "usesSaveFlag": true,
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "ControlDest",
+ "type": "Server.Point3D",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Point3D"
+ ]
+ },
+ {
+ "name": "ControlOrder",
+ "type": "Server.Mobiles.OrderType",
+ "usesSaveFlag": true,
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "MinTameSkill",
+ "type": "double",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Tamable",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Summoned",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SummonEnd",
+ "type": "System.DateTime",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "AnchoredTime"
+ ]
+ },
+ {
+ "name": "SummonMaster",
+ "type": "Server.Mobile",
+ "usesSaveFlag": true,
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "ControlSlots",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "Loyalty",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "CurrentWayPoint",
+ "type": "Server.Items.WayPoint",
+ "usesSaveFlag": true,
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "HitsMaxSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "StamMaxSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "ManaMaxSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "DamageMin",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "DamageMax",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "PhysicalResistanceSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "FireResistSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "ColdResistSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "PoisonResistSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "EnergyResistSeed",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "PhysicalDamage",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "FireDamage",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "ColdDamage",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "PoisonDamage",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "EnergyDamage",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "Owners",
+ "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
+ "usesSaveFlag": true,
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "@Tidy",
+ "Server.Mobile",
+ "SerializableInterfaceMigrationRule"
+ ]
+ },
+ {
+ "name": "IsDeadPet",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "IsBonded",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "BondingBegin",
+ "type": "System.DateTime",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "OwnerAbandonTime",
+ "type": "System.DateTime",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "HasGeneratedLoot",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "IsParagon",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Friends",
+ "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
+ "usesSaveFlag": true,
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "@Tidy",
+ "Server.Mobile",
+ "SerializableInterfaceMigrationRule"
+ ]
+ },
+ {
+ "name": "RemoveIfUntamed",
+ "type": "bool",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "RemoveStep",
+ "type": "int",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "EncodedInt"
+ ]
+ },
+ {
+ "name": "PendingDeleteTimer",
+ "type": "Server.Timer",
+ "usesSaveFlag": true,
+ "rule": "TimerMigrationRule",
+ "ruleArguments": [
+ "@AnchoredTimer"
+ ]
+ },
+ {
+ "name": "CorpseNameOverride",
+ "type": "string",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs
index fc227507e..75c6fc3dc 100644
--- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs
+++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs
@@ -99,8 +99,8 @@ public abstract class MonsterAbility
{
}
- [OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
- [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
+ [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
+ [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
public static void InvalidateNextAbilityTriggers(BaseCreature source)
{
var abilities = source.GetMonsterAbilities();
diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs
index 404aaede8..5ea68cb13 100644
--- a/Projects/UOContent/Mobiles/BaseCreature.cs
+++ b/Projects/UOContent/Mobiles/BaseCreature.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using ModernUO.CodeGeneratedEvents;
+using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
using Server.Engines.ConPVP;
@@ -13,6 +14,7 @@ using Server.Engines.Virtues;
using Server.Ethics;
using Server.Factions;
using Server.Items;
+using Server.Logging;
using Server.Misc;
using Server.Multis;
using Server.Network;
@@ -133,8 +135,17 @@ namespace Server.Mobiles
public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage);
}
+ [SerializationGenerator(23, false)]
public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver
{
+ private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature));
+
+ // Medium bucket; used when an elided load finds no table entry (a 0-delay AI timer would spin).
+ private const double FallbackActiveSpeed = 0.25;
+ private const double FallbackPassiveSpeed = 0.5;
+
+ private static bool _loggedMissingSpeeds;
+
public enum Allegiance
{
None,
@@ -160,7 +171,7 @@ namespace Server.Mobiles
public const int DefaultRangePerception = 16;
- private const double ChanceToRummage = 0.5; // 50%
+ private const double ChanceToRummage = 0.5;
private const double MinutesToNextRummageMin = 1.0;
private const double MinutesToNextRummageMax = 4.0;
@@ -227,7 +238,7 @@ namespace Server.Mobiles
private static readonly Type[] _gold =
{
- // white wyrms eat gold..
+ // White wyrms eat gold.
typeof(Gold)
};
@@ -250,54 +261,536 @@ namespace Server.Mobiles
typeof(AncientSmithyHammer), typeof(Scorp)
};
- private bool _summoned;
+ // --- Serialized state ---------------------------------------------------------
+ // Fields matching their defaults (including npc-speeds table values) are elided by [SaveFlag].
- private bool m_bTamable;
- private int m_ColdResistance;
+ [SerializableField(0, setter: "private")]
+ private AIType _defaultAI;
- private bool _controlled; // Is controlled
- private Mobile m_ControlMaster; // My master
- private OrderType m_ControlOrder; // My order
+ [SerializableField(1, setter: "private")]
+ [SaveFlag(nameof(ShouldSerializeCurrentAI), nameof(CurrentAIDefaultValue))]
+ private AIType _currentAI;
- private AIType m_CurrentAI; // The current AI
+ private bool ShouldSerializeCurrentAI() => _currentAI != _defaultAI;
+ private AIType CurrentAIDefaultValue() => _defaultAI;
+
+ [EncodedInt]
+ [SerializableField(2)]
+ [SaveFlag(nameof(ShouldSerializeRangePerception), nameof(RangePerceptionDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _rangePerception;
+
+ private bool ShouldSerializeRangePerception() => _rangePerception != DefaultRangePerception;
+
+ private int RangePerceptionDefaultValue() => DefaultRangePerception;
+
+ [EncodedInt]
+ [SerializableField(3)]
+ [SaveFlag(nameof(ShouldSerializeRangeFight), nameof(RangeFightDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _rangeFight;
+
+ private bool ShouldSerializeRangeFight() => _rangeFight != 1;
+
+ private int RangeFightDefaultValue() => 1;
+
+ [EncodedInt]
+ [SerializableField(4)]
+ [SaveFlag(nameof(ShouldSerializeRangeHome), nameof(RangeHomeDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _rangeHome = 10;
+
+ private bool ShouldSerializeRangeHome() => _rangeHome != 10;
+
+ private int RangeHomeDefaultValue() => 10;
+
+ [EncodedInt]
+ [SerializableField(5, fieldChanged: nameof(OnTeamChange))]
+ [SaveFlag(nameof(ShouldSerializeTeam))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _team;
+
+ private bool ShouldSerializeTeam() => _team != 0;
+
+ private void OnTeamChange(int oldValue, int newValue) => OnTeamChange();
+
+ [SerializableField(6)]
+ [SaveFlag(nameof(ShouldSerializeFightMode), nameof(FightModeDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private FightMode _fightMode;
+
+ private bool ShouldSerializeFightMode() => _fightMode != FightMode.Closest;
+
+ private FightMode FightModeDefaultValue() => FightMode.Closest;
+
+ /// Seconds per AI decision while engaged; see for movement pace.
+ [SerializableField(7)]
+ [SaveFlag(nameof(ShouldSerializeActiveSpeed), nameof(ActiveSpeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _activeSpeed;
+
+ private bool ShouldSerializeActiveSpeed()
+ {
+ GetSpeeds(out var activeSpeed, out _);
+ return _activeSpeed != activeSpeed;
+ }
+
+ private double ActiveSpeedDefaultValue()
+ {
+ GetSpeeds(out var activeSpeed, out _);
+ return activeSpeed;
+ }
+
+ /// Seconds per AI decision while idle; see for movement pace.
+ [SerializableField(8)]
+ [SaveFlag(nameof(ShouldSerializePassiveSpeed), nameof(PassiveSpeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _passiveSpeed;
+
+ private bool ShouldSerializePassiveSpeed()
+ {
+ GetSpeeds(out _, out var passiveSpeed);
+ return _passiveSpeed != passiveSpeed;
+ }
+
+ private double PassiveSpeedDefaultValue()
+ {
+ GetSpeeds(out _, out var passiveSpeed);
+ return passiveSpeed;
+ }
+
+ [SerializableField(9, fieldChanged: nameof(OnCurrentSpeedChange))]
+ [SaveFlag(nameof(ShouldSerializeCurrentSpeed), nameof(CurrentSpeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _currentSpeed;
- // Movement clock (seconds per step); 0 = inherit the matching think value.
+ private bool ShouldSerializeCurrentSpeed() => _currentSpeed != _passiveSpeed;
+
+ private double CurrentSpeedDefaultValue() => _passiveSpeed;
+
+ private void OnCurrentSpeedChange(double oldValue, double newValue) => AIObject?.OnCurrentSpeedChanged();
+
+ ///
+ /// Movement clock (seconds per step) while engaged; 0 = inherit
+ /// . resolves the pace.
+ ///
+ [SerializableField(10, allowFieldChange: nameof(CoerceMoveSpeed))]
+ [SaveFlag(nameof(ShouldSerializeActiveMoveSpeed), nameof(ActiveMoveSpeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _activeMoveSpeed;
+
+ ///
+ /// Movement clock (seconds per step) while idle; 0 = inherit
+ /// . resolves the pace.
+ ///
+ [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed))]
+ [SaveFlag(nameof(ShouldSerializePassiveMoveSpeed), nameof(PassiveMoveSpeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
private double _passiveMoveSpeed;
+ private bool CoerceMoveSpeed(ref double value)
+ {
+ value = Math.Max(0, value); // anything non-positive means "inherit"
+ return true;
+ }
+
+ private bool ShouldSerializeActiveMoveSpeed()
+ {
+ GetMoveSpeeds(out var activeMoveSpeed, out _);
+ return _activeMoveSpeed != activeMoveSpeed;
+ }
+
+ private double ActiveMoveSpeedDefaultValue()
+ {
+ GetMoveSpeeds(out var activeMoveSpeed, out _);
+ return activeMoveSpeed;
+ }
+
+ private bool ShouldSerializePassiveMoveSpeed()
+ {
+ GetMoveSpeeds(out _, out var passiveMoveSpeed);
+ return _passiveMoveSpeed != passiveMoveSpeed;
+ }
+
+ private double PassiveMoveSpeedDefaultValue()
+ {
+ GetMoveSpeeds(out _, out var passiveMoveSpeed);
+ return passiveMoveSpeed;
+ }
+
+ [SerializableField(12)]
+ [SaveFlag(nameof(ShouldSerializeHome))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private Point3D _home;
+
+ private bool ShouldSerializeHome() => _home != Point3D.Zero;
+
+ [SerializableField(13)]
+ [SaveFlag(nameof(ShouldSerializeHomeMap))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private Map _homeMap;
+
+ private bool ShouldSerializeHomeMap() => _homeMap != null;
+
+ [SerializableField(14, fieldChanged: nameof(OnControlledChange))]
+ [SaveFlag(nameof(ShouldSerializeControlled))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private bool _controlled;
+
+ private bool ShouldSerializeControlled() => _controlled;
+
+ private void OnControlledChange(bool oldValue, bool newValue)
+ {
+ Delta(MobileDelta.Noto);
+ InvalidateProperties();
+ }
+
+ // Follower bookkeeping brackets the assignment, so the property is hand-written.
+ private Mobile _controlMaster;
+
+ private bool ShouldSerializeControlMaster() => _controlMaster != null;
+
+ [SerializableField(16)]
+ [SaveFlag(nameof(ShouldSerializeControlTarget))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private Mobile _controlTarget;
+
+ private bool ShouldSerializeControlTarget() => _controlTarget != null;
+
+ [SerializableField(17)]
+ [SaveFlag(nameof(ShouldSerializeControlDest))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private Point3D _controlDest;
+
+ private bool ShouldSerializeControlDest() => _controlDest != Point3D.Zero;
+
+ // Order logic must run on equal re-assignment, so the property is hand-written.
+ private OrderType _controlOrder;
+
+ private bool ShouldSerializeControlOrder() => _controlOrder != OrderType.None;
+
+ [SerializableField(19)]
+ [SaveFlag(nameof(ShouldSerializeMinTameSkill))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private double _minTameSkill;
+
+ private bool ShouldSerializeMinTameSkill() => _minTameSkill != 0;
+
+ // The getter masks paragons, so the property is hand-written.
+ private bool _tamable;
+
+ private bool ShouldSerializeTamable() => _tamable;
+
+ [SerializableField(21, fieldChanged: nameof(OnSummonedChange))]
+ [SaveFlag(nameof(ShouldSerializeSummoned))]
+ [SerializedCommandProperty(AccessLevel.Administrator)]
+ private bool _summoned;
+
+ private bool ShouldSerializeSummoned() => _summoned;
+
+ private void OnSummonedChange(bool oldValue, bool newValue)
+ {
+ NextReacquireTime = Core.TickCount;
+ Delta(MobileDelta.Noto);
+ InvalidateProperties();
+ }
+
+ [AnchoredDateTime]
+ [SerializableField(22, getter: "protected", setter: "protected")]
+ [SaveFlag(nameof(ShouldSerializeSummonEnd))]
+ private DateTime _summonEnd;
+
+ private bool ShouldSerializeSummonEnd() => _summoned;
+
+ // Follower bookkeeping brackets the assignment, so the property is hand-written.
+ private Mobile _summonMaster;
+
+ private bool ShouldSerializeSummonMaster() => _summonMaster != null;
+
+ [EncodedInt]
+ [SerializableField(24)]
+ [SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.Administrator)]
+ private int _controlSlots = 1;
+
+ private bool ShouldSerializeControlSlots() => _controlSlots != 1;
+
+ private int ControlSlotsDefaultValue() => 1;
+
+ [EncodedInt]
+ [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))]
+ [SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _loyalty;
+
+ private bool ShouldSerializeLoyalty() => _loyalty != MaxLoyalty;
+
+ private int LoyaltyDefaultValue() => MaxLoyalty;
+
+ private bool ClampLoyalty(ref int value)
+ {
+ value = Math.Clamp(value, 0, MaxLoyalty);
+ return true;
+ }
+
+ [SerializableField(26)]
+ [SaveFlag(nameof(ShouldSerializeCurrentWayPoint))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private WayPoint _currentWayPoint;
+
+ private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null;
+
+ [EncodedInt]
+ [SerializableField(27)]
+ [SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _hitsMaxSeed = -1;
+
+ private bool ShouldSerializeHitsMaxSeed() => _hitsMaxSeed != -1;
+
+ private int HitsMaxSeedDefaultValue() => -1;
+
+ [EncodedInt]
+ [SerializableField(28)]
+ [SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _stamMaxSeed = -1;
+
+ private bool ShouldSerializeStamMaxSeed() => _stamMaxSeed != -1;
+
+ private int StamMaxSeedDefaultValue() => -1;
+
+ [EncodedInt]
+ [SerializableField(29)]
+ [SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _manaMaxSeed = -1;
+
+ private bool ShouldSerializeManaMaxSeed() => _manaMaxSeed != -1;
+
+ private int ManaMaxSeedDefaultValue() => -1;
+
+ [EncodedInt]
+ [SerializableField(30)]
+ [SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _damageMin = -1;
+
+ private bool ShouldSerializeDamageMin() => _damageMin != -1;
+
+ private int DamageMinDefaultValue() => -1;
+
+ [EncodedInt]
+ [SerializableField(31)]
+ [SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _damageMax = -1;
+
+ private bool ShouldSerializeDamageMax() => _damageMax != -1;
+
+ private int DamageMaxDefaultValue() => -1;
+
+ [EncodedInt]
+ [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _physicalResistanceSeed;
+
+ private bool ShouldSerializePhysicalResistanceSeed() => _physicalResistanceSeed != 0;
+
+ private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances();
+
+ [EncodedInt]
+ [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SaveFlag(nameof(ShouldSerializeFireResistSeed))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _fireResistSeed;
+
+ private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0;
+
+ [EncodedInt]
+ [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SaveFlag(nameof(ShouldSerializeColdResistSeed))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _coldResistSeed;
+
+ private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0;
+
+ [EncodedInt]
+ [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SaveFlag(nameof(ShouldSerializePoisonResistSeed))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _poisonResistSeed;
+
+ private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0;
+
+ [EncodedInt]
+ [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SaveFlag(nameof(ShouldSerializeEnergyResistSeed))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _energyResistSeed;
+
+ private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0;
+
+ [EncodedInt]
+ [SerializableField(37)]
+ [SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _physicalDamage = 100;
+
+ private bool ShouldSerializePhysicalDamage() => _physicalDamage != 100;
+
+ private int PhysicalDamageDefaultValue() => 100;
+
+ [EncodedInt]
+ [SerializableField(38)]
+ [SaveFlag(nameof(ShouldSerializeFireDamage))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _fireDamage;
+
+ private bool ShouldSerializeFireDamage() => _fireDamage != 0;
+
+ [EncodedInt]
+ [SerializableField(39)]
+ [SaveFlag(nameof(ShouldSerializeColdDamage))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _coldDamage;
+
+ private bool ShouldSerializeColdDamage() => _coldDamage != 0;
+
+ [EncodedInt]
+ [SerializableField(40)]
+ [SaveFlag(nameof(ShouldSerializePoisonDamage))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _poisonDamage;
+
+ private bool ShouldSerializePoisonDamage() => _poisonDamage != 0;
+
+ [EncodedInt]
+ [SerializableField(41)]
+ [SaveFlag(nameof(ShouldSerializeEnergyDamage))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _energyDamage;
+
+ private bool ShouldSerializeEnergyDamage() => _energyDamage != 0;
+
+ [Tidy]
+ [SerializableField(42, setter: "private")]
+ [SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))]
+ private List _owners;
+
+ private bool ShouldSerializeOwners()
+ {
+ _owners?.Tidy();
+ return _owners?.Count > 0;
+ }
+
+ private List OwnersDefaultValue() => new();
+
+ [SerializableField(43)]
+ [SaveFlag(nameof(ShouldSerializeIsDeadPet))]
+ private bool _isDeadPet;
+
+ private bool ShouldSerializeIsDeadPet() => _isDeadPet;
+
+ [SerializableField(44, fieldChanged: nameof(OnBondedChange))]
+ [SaveFlag(nameof(ShouldSerializeIsBonded))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private bool _isBonded;
+
+ private bool ShouldSerializeIsBonded() => _isBonded;
+
+ private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties();
+
+ [SerializableField(45)]
+ [SaveFlag(nameof(ShouldSerializeBondingBegin))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private DateTime _bondingBegin;
+
+ private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue;
+
+ [SerializableField(46)]
+ [SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private DateTime _ownerAbandonTime;
+
+ private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue;
+
+ [SerializableField(47)]
+ [SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))]
+ private bool _hasGeneratedLoot;
+
+ private bool ShouldSerializeHasGeneratedLoot() => _hasGeneratedLoot;
+
+ // The setter converts the creature, which must not run at load, so the property is hand-written.
+ private bool _isParagon;
+
+ private bool ShouldSerializeIsParagon() => _isParagon;
+
+ [Tidy]
+ [SerializableField(49, setter: "private")]
+ [SaveFlag(nameof(ShouldSerializeFriends))]
+ private List _friends;
+
+ private bool ShouldSerializeFriends()
+ {
+ _friends?.Tidy();
+ return _friends?.Count > 0;
+ }
+
+ [SerializableField(50)]
+ [SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private bool _removeIfUntamed;
+
+ private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed;
+
+ [EncodedInt]
+ [SerializableField(51)]
+ [SaveFlag(nameof(ShouldSerializeRemoveStep))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private int _removeStep;
+
+ private bool ShouldSerializeRemoveStep() => _removeStep != 0;
+
+ [SerializableField(52, setter: "private")]
+ [SaveFlag(nameof(ShouldSerializePendingDeleteTimer))]
+ [DeserializeTimer(nameof(DeserializePendingDeleteTimer))]
+ private Timer _pendingDeleteTimer;
+
+ // Stabled and controlled pets never resume a delete countdown.
+ private bool ShouldSerializePendingDeleteTimer() =>
+ _pendingDeleteTimer?.Running == true && !IsStabled && !(_controlled && _controlMaster != null);
+
+ private void DeserializePendingDeleteTimer(TimeSpan delay)
+ {
+ _pendingDeleteTimer = new DeleteTimer(this, delay);
+ _pendingDeleteTimer.Start();
+ }
+
+ [SerializableField(53)]
+ [SaveFlag(nameof(ShouldSerializeCorpseNameOverride))]
+ [SerializedCommandProperty(AccessLevel.GameMaster)]
+ private string _corpseNameOverride;
+
+ private bool ShouldSerializeCorpseNameOverride() => _corpseNameOverride != null;
+
+ // --- Non-serialized state -------------------------------------------------------
+
// Herding - forces the mob to walk to a specific location, paced by the movement
// clock at HerdingMoveSpeed. Thinking is unaffected.
private IPoint2D _targetLocation;
- private int m_DamageMax = -1;
-
- private int m_DamageMin = -1;
- private AIType m_DefaultAI; // The default AI
-
- private DeleteTimer m_DeleteTimer;
- private int m_EnergyResistance;
-
private int m_FailedReturnHome; /* return to home failure counter */
- private int m_FireResistance;
- private bool m_HasGeneratedLoot; // have we generated our loot yet?
private TimerExecutionToken _healTimerToken;
- private Point3D m_Home; // The home position of the creature, used by some AI
-
private DateTime m_IdleReleaseTime;
- private bool m_IsBonded;
-
private bool m_IsStabled;
protected int m_KillersLuck;
- private int m_Loyalty;
-
private DateTime m_MLNextShout;
private List m_MLQuests;
@@ -310,24 +803,13 @@ namespace Server.Mobiles
private long m_NextRummageTime;
- private bool m_Paragon;
-
- private int m_PhysicalResistance;
- private int m_PoisonResistance;
-
- /* until we are sure about who should be getting deleted, move them instead */
- /* On OSI, they despawn */
-
+ // On OSI these despawn; we queue a return home instead of deleting.
private bool m_ReturnQueued;
protected bool m_Spawning;
- private Mobile m_SummonMaster;
-
private SkillName m_Teaching = (SkillName)(-1);
- private int m_Team; // Monster Team
-
public BaseCreature(
AIType ai,
FightMode mode = FightMode.Closest,
@@ -335,10 +817,10 @@ namespace Server.Mobiles
int iRangeFight = 1
)
{
- m_Loyalty = MaxLoyalty; // Wonderfully Happy
+ _loyalty = MaxLoyalty;
- m_CurrentAI = ai;
- m_DefaultAI = ai;
+ _currentAI = ai;
+ _defaultAI = ai;
RangePerception = iRangePerception;
RangeFight = iRangeFight;
@@ -348,20 +830,28 @@ namespace Server.Mobiles
GetSpeeds(out var activeSpeed, out var passiveSpeed);
GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed);
+ if (activeSpeed <= 0 || passiveSpeed <= 0)
+ {
+ // A 0-delay creature would spin its AI timer; only construction refuses.
+ throw new InvalidOperationException(
+ $"{GetType()} constructed without speeds - is Data/npc-speeds.json missing?"
+ );
+ }
+
ActiveSpeed = activeSpeed;
PassiveSpeed = passiveSpeed;
CurrentSpeed = passiveSpeed;
- m_Team = 0;
+ _team = 0;
Debug = false;
_controlled = false;
- m_ControlMaster = null;
+ _controlMaster = null;
ControlTarget = null;
- m_ControlOrder = OrderType.None;
+ _controlOrder = OrderType.None;
- m_bTamable = false;
+ _tamable = false;
Owners = new List();
@@ -406,14 +896,10 @@ namespace Server.Mobiles
public virtual InhumanSpeech SpeechType => null;
- /* Do not serialize this till the code is finalized */
-
+ // Deliberately not serialized until the feature is finalized.
[CommandProperty(AccessLevel.GameMaster)]
public bool SeeksHome { get; set; }
- [CommandProperty(AccessLevel.GameMaster)]
- public string CorpseNameOverride { get; set; }
-
[CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
public bool IsStabled
{
@@ -436,20 +922,20 @@ namespace Server.Mobiles
public virtual bool FollowsAcquireRules => true;
- protected DateTime SummonEnd { get; set; }
-
public virtual Faction FactionAllegiance => null;
public virtual int FactionSilverWorth => 30;
public virtual double WeaponAbilityChance => 0.4;
+ [SerializableProperty(48, useField: nameof(_isParagon))]
+ [SaveFlag(nameof(ShouldSerializeIsParagon))]
[CommandProperty(AccessLevel.GameMaster)]
public bool IsParagon
{
- get => m_Paragon;
+ get => _isParagon;
set
{
- if (m_Paragon == value)
+ if (_isParagon == value)
{
return;
}
@@ -463,9 +949,10 @@ namespace Server.Mobiles
Paragon.UnConvert(this);
}
- m_Paragon = value;
+ _isParagon = value;
InvalidateProperties();
+ this.MarkDirty();
}
}
@@ -474,8 +961,6 @@ namespace Server.Mobiles
public virtual FoodType FavoriteFood => FoodType.Meat;
public virtual PackInstinct PackInstinct => PackInstinct.None;
- public List Owners { get; private set; }
-
public virtual bool AllowMaleTamer => true;
public virtual bool AllowFemaleTamer => true;
public virtual bool SubdueBeforeTame => false;
@@ -499,11 +984,11 @@ namespace Server.Mobiles
public virtual bool DeathAdderCharmable => false;
- // TODO: Find the pub 31 tweaks to the DispelDifficulty and apply them of course.
- // at this skill level we dispel 50% chance
+ //TODO Apply the pub 31 DispelDifficulty tweaks
+ // Skill level at which dispel succeeds 50% of the time.
public virtual double DispelDifficulty => 0.0;
- // at difficulty - focus we have 0%, at difficulty + focus we have 100%
+ // 0% at difficulty - focus, 100% at difficulty + focus.
public virtual double DispelFocus => 20.0;
public virtual bool DisplayWeight => Backpack is StrongBackpack;
@@ -539,21 +1024,11 @@ namespace Server.Mobiles
}
public virtual bool IsNecroFamiliar =>
- Summoned && m_ControlMaster != null &&
- SummonFamiliarSpell.Table.TryGetValue(m_ControlMaster, out var bc) && bc == this;
+ Summoned && _controlMaster != null &&
+ SummonFamiliarSpell.Table.TryGetValue(_controlMaster, out var bc) && bc == this;
public virtual bool DeleteCorpseOnDeath => !Core.AOS && _summoned;
- [CommandProperty(AccessLevel.GameMaster)]
- public int Loyalty
- {
- get => m_Loyalty;
- set => m_Loyalty = Math.Clamp(value, 0, MaxLoyalty);
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public WayPoint CurrentWayPoint { get; set; }
-
public virtual Mobile ConstantFocus => null;
public virtual bool DisallowAllMoves => false;
@@ -566,57 +1041,25 @@ namespace Server.Mobiles
public virtual bool AlwaysAttackable => false;
- [CommandProperty(AccessLevel.GameMaster)]
- public virtual int DamageMin
- {
- get => m_DamageMin;
- set => m_DamageMin = value;
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public virtual int DamageMax
- {
- get => m_DamageMax;
- set => m_DamageMax = value;
- }
-
[CommandProperty(AccessLevel.GameMaster)]
public override int HitsMax =>
HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000);
- [CommandProperty(AccessLevel.GameMaster)]
- public int HitsMaxSeed { get; set; } = -1;
-
[CommandProperty(AccessLevel.GameMaster)]
public override int StamMax =>
StamMaxSeed <= 0 ? Dex : Math.Clamp(StamMaxSeed + GetStatOffset(StatType.Dex), 1, 65000);
- [CommandProperty(AccessLevel.GameMaster)]
- public int StamMaxSeed { get; set; } = -1;
-
[CommandProperty(AccessLevel.GameMaster)]
public override int ManaMax =>
ManaMaxSeed <= 0 ? Int : Math.Clamp(ManaMaxSeed + GetStatOffset(StatType.Int), 1, 65000);
- [CommandProperty(AccessLevel.GameMaster)]
- public int ManaMaxSeed { get; set; } = -1;
-
public virtual bool CanOpenDoors => !Body.IsAnimal && !Body.IsSea;
public virtual bool CanMoveOverObstacles => Core.AOS || Body.IsMonster;
public virtual bool CanDestroyObstacles => false;
- /*
- Seems this actually was removed on OSI somewhere between the original bug report and now.
- We will call it ML, until we can get better information. I suspect it was on the OSI TC when
- originally it taken out of RunUO, and not implemented on OSIs production shards until more
- recently. Either way, this is, or was, accurate OSI behavior, and just entirely
- removing it was incorrect. OSI followers were distracted by being attacked well into
- AoS, at very least.
-
- */
-
+ // OSI followers were distracted by attacks well into AoS; removed around ML.
public virtual bool CanBeDistracted => !Core.ML;
public override bool ShouldCheckStatTimers => false;
@@ -628,43 +1071,27 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public AIType AI
{
- get => m_CurrentAI;
+ get => _currentAI;
set
{
- m_CurrentAI = value;
+ _currentAI = value;
- if (m_CurrentAI == AIType.AI_Use_Default)
+ if (_currentAI == AIType.AI_Use_Default)
{
- m_CurrentAI = m_DefaultAI;
+ _currentAI = _defaultAI;
}
- ChangeAIType(m_CurrentAI);
+ this.MarkDirty();
+ ChangeAIType(_currentAI);
}
}
[CommandProperty(AccessLevel.Administrator)]
public bool Debug { get; set; }
- [CommandProperty(AccessLevel.GameMaster)]
- public int Team
- {
- get => m_Team;
- set
- {
- m_Team = value;
- OnTeamChange();
- }
- }
-
[CommandProperty(AccessLevel.GameMaster)]
public Mobile FocusMob { get; set; }
- [CommandProperty(AccessLevel.GameMaster)]
- public FightMode FightMode { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int RangePerception { get; set; }
-
///
/// How far a chase may stretch before the creature gives up its combatant. Between
/// RangePerception and this leash it keeps chasing but may switch to closer targets.
@@ -672,57 +1099,6 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public virtual int ChaseLeashRange => RangePerception * 2;
- [CommandProperty(AccessLevel.GameMaster)]
- public int RangeFight { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int RangeHome { get; set; } = 10;
-
- /// Seconds per AI decision while engaged; see for movement pace.
- [CommandProperty(AccessLevel.GameMaster)]
- public virtual double ActiveSpeed
- {
- get => _activeSpeed;
- set
- {
- if (Math.Abs(_activeSpeed - value) > .0001)
- {
- _activeSpeed = value;
- }
- }
- }
-
- /// Seconds per AI decision while idle; see for movement pace.
- [CommandProperty(AccessLevel.GameMaster)]
- public virtual double PassiveSpeed
- {
- get => _passiveSpeed;
- set
- {
- _passiveSpeed = value;
- if (Math.Abs(_passiveSpeed - value) > .0001)
- {
- _passiveSpeed = value;
- }
- }
- }
-
- /// Seconds per step while engaged. Inherits ; set 0 to re-inherit.
- [CommandProperty(AccessLevel.GameMaster)]
- public virtual double ActiveMoveSpeed
- {
- get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed;
- set => _activeMoveSpeed = value > 0 ? value : 0;
- }
-
- /// Seconds per step while idle. Inherits ; set 0 to re-inherit.
- [CommandProperty(AccessLevel.GameMaster)]
- public virtual double PassiveMoveSpeed
- {
- get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed;
- set => _passiveMoveSpeed = value > 0 ? value : 0;
- }
-
// Herded creatures walk at a fixed standard pace regardless of their own speed
// (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6).
private const double HerdingMoveSpeed = 0.3;
@@ -734,20 +1110,6 @@ namespace Server.Mobiles
set => _targetLocation = value;
}
- [CommandProperty(AccessLevel.GameMaster)]
- public double CurrentSpeed
- {
- get => _currentSpeed;
- set
- {
- if (Math.Abs(_currentSpeed - value) > 0.0001)
- {
- _currentSpeed = value;
- AIObject?.OnCurrentSpeedChanged();
- }
- }
- }
-
///
/// Resolved seconds per step: a verbatim active/passive
/// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint)
@@ -764,104 +1126,87 @@ namespace Server.Mobiles
return HerdingMoveSpeed;
}
- return _currentSpeed == _activeSpeed ? ActiveMoveSpeed
- : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed
- : _currentSpeed;
- }
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public Point3D Home
- {
- get => m_Home;
- set => m_Home = value;
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public Map HomeMap { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public bool Controlled
- {
- get => _controlled;
- set
- {
- if (_controlled == value)
+ if (_currentSpeed == _activeSpeed)
{
- return;
+ return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed;
}
- _controlled = value;
- Delta(MobileDelta.Noto);
+ if (_currentSpeed == _passiveSpeed)
+ {
+ return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed;
+ }
- InvalidateProperties();
+ return _currentSpeed;
}
}
+ [SerializableProperty(15, useField: nameof(_controlMaster))]
+ [SaveFlag(nameof(ShouldSerializeControlMaster))]
[CommandProperty(AccessLevel.GameMaster)]
public Mobile ControlMaster
{
- get => m_ControlMaster;
+ get => _controlMaster;
set
{
- if (m_ControlMaster == value || this == value)
+ if (_controlMaster == value || this == value)
{
return;
}
RemoveFollowers();
- m_ControlMaster = value;
+ _controlMaster = value;
AddFollowers();
- if (m_ControlMaster != null)
+ if (_controlMaster != null)
{
StopDeleteTimer();
}
Delta(MobileDelta.Noto);
+ this.MarkDirty();
}
}
+ [SerializableProperty(23, useField: nameof(_summonMaster))]
+ [SaveFlag(nameof(ShouldSerializeSummonMaster))]
[CommandProperty(AccessLevel.GameMaster)]
public Mobile SummonMaster
{
- get => m_SummonMaster;
+ get => _summonMaster;
set
{
- if (m_SummonMaster == value || this == value)
+ if (_summonMaster == value || this == value)
{
return;
}
RemoveFollowers();
- m_SummonMaster = value;
+ _summonMaster = value;
AddFollowers();
Delta(MobileDelta.Noto);
+ this.MarkDirty();
}
}
- [CommandProperty(AccessLevel.GameMaster)]
- public Mobile ControlTarget { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public Point3D ControlDest { get; set; }
-
// Fires on every assignment, not only changes: a reissued order is a command
// (retarget, break off combat, re-anchor Home). Handlers receive the previous order.
+ [SerializableProperty(18, useField: nameof(_controlOrder))]
+ [SaveFlag(nameof(ShouldSerializeControlOrder))]
[CommandProperty(AccessLevel.GameMaster)]
public OrderType ControlOrder
{
- get => m_ControlOrder;
+ get => _controlOrder;
set
{
- var previous = m_ControlOrder;
- m_ControlOrder = value;
+ var previous = _controlOrder;
+ _controlOrder = value;
AIObject?.OnCurrentOrderChanged(previous);
InvalidateProperties();
- m_ControlMaster?.InvalidateProperties();
+ _controlMaster?.InvalidateProperties();
+ this.MarkDirty();
}
}
@@ -880,39 +1225,19 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public DateTime BardEndTime { get; set; }
- [CommandProperty(AccessLevel.GameMaster)]
- public double MinTameSkill { get; set; }
-
+ [SerializableProperty(20, useField: nameof(_tamable))]
+ [SaveFlag(nameof(ShouldSerializeTamable))]
[CommandProperty(AccessLevel.GameMaster)]
public bool Tamable
{
- get => m_bTamable && !m_Paragon;
- set => m_bTamable = value;
- }
-
- [CommandProperty(AccessLevel.Administrator)]
- public bool Summoned
- {
- get => _summoned;
+ get => _tamable && !_isParagon;
set
{
- if (_summoned == value)
- {
- return;
- }
-
- NextReacquireTime = Core.TickCount;
-
- _summoned = value;
- Delta(MobileDelta.Noto);
-
- InvalidateProperties();
+ _tamable = value;
+ this.MarkDirty();
}
}
- [CommandProperty(AccessLevel.Administrator)]
- public int ControlSlots { get; set; } = 1;
-
public virtual bool NoHouseRestrictions => false;
public virtual bool IsHouseSummonable => false;
@@ -943,7 +1268,7 @@ namespace Server.Mobiles
// Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the
// next scan to at most this far away. Zero (paragons) scans on the very next
// think; larger is dumber; pure ReacquireDelay is the oblivious floor.
- public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0);
+ public virtual TimeSpan AcquireOnApproachDelay => _isParagon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0);
// Reactive range is tighter than the periodic scan's RangePerception: approach
// aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep.
@@ -990,13 +1315,6 @@ namespace Server.Mobiles
public virtual bool ReturnsToHome =>
SeeksHome && Home != Point3D.Zero && !m_ReturnQueued && !Controlled && !Summoned;
- // used for deleting untamed creatures [in houses]
- [CommandProperty(AccessLevel.GameMaster)]
- public bool RemoveIfUntamed { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int RemoveStep { get; set; }
-
public virtual bool CanGiveMLQuest => MLQuests.Count != 0;
public virtual bool StaticMLQuester => true;
@@ -1031,114 +1349,25 @@ namespace Server.Mobiles
}
}
- [CommandProperty(AccessLevel.GameMaster)]
- public bool IsBonded
- {
- get => m_IsBonded;
- set
- {
- m_IsBonded = value;
- InvalidateProperties();
- }
- }
-
- public bool IsDeadPet { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public DateTime BondingBegin { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public DateTime OwnerAbandonTime { get; set; }
-
[CommandProperty(AccessLevel.GameMaster)]
public TimeSpan DeleteTimeLeft
{
get
{
- if (m_DeleteTimer?.Running == true)
+ if (_pendingDeleteTimer?.Running == true)
{
- return m_DeleteTimer.Next - Core.Now;
+ return _pendingDeleteTimer.Next - Core.Now;
}
return TimeSpan.Zero;
}
}
- public override int BasePhysicalResistance => m_PhysicalResistance;
- public override int BaseFireResistance => m_FireResistance;
- public override int BaseColdResistance => m_ColdResistance;
- public override int BasePoisonResistance => m_PoisonResistance;
- public override int BaseEnergyResistance => m_EnergyResistance;
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int PhysicalResistanceSeed
- {
- get => m_PhysicalResistance;
- set
- {
- m_PhysicalResistance = value;
- UpdateResistances();
- }
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int FireResistSeed
- {
- get => m_FireResistance;
- set
- {
- m_FireResistance = value;
- UpdateResistances();
- }
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int ColdResistSeed
- {
- get => m_ColdResistance;
- set
- {
- m_ColdResistance = value;
- UpdateResistances();
- }
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int PoisonResistSeed
- {
- get => m_PoisonResistance;
- set
- {
- m_PoisonResistance = value;
- UpdateResistances();
- }
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int EnergyResistSeed
- {
- get => m_EnergyResistance;
- set
- {
- m_EnergyResistance = value;
- UpdateResistances();
- }
- }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int PhysicalDamage { get; set; } = 100;
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int FireDamage { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int ColdDamage { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int PoisonDamage { get; set; }
-
- [CommandProperty(AccessLevel.GameMaster)]
- public int EnergyDamage { get; set; }
+ public override int BasePhysicalResistance => _physicalResistanceSeed;
+ public override int BaseFireResistance => _fireResistSeed;
+ public override int BaseColdResistance => _coldResistSeed;
+ public override int BasePoisonResistance => _poisonResistSeed;
+ public override int BaseEnergyResistance => _energyResistSeed;
[CommandProperty(AccessLevel.GameMaster)]
public int ChaosDamage { get; set; }
@@ -1146,15 +1375,12 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public int DirectDamage { get; set; }
- // Is immune to breath damages
public virtual bool BreathImmune => false;
- public virtual bool CanFlee => !m_Paragon;
+ public virtual bool CanFlee => !_isParagon;
public DateTime EndFleeTime { get; set; }
- public List Friends { get; private set; }
-
public virtual bool AllowNewPetFriend => Friends == null || Friends.Count < 5;
public virtual Ethic EthicAllegiance => null;
@@ -1206,7 +1432,6 @@ namespace Server.Mobiles
public HonorContext ReceivedHonorContext { get; set; }
public List MLQuests =>
- // Assign the quests if we don't have one, and if it is still null, return an empty list
(m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList;
public virtual MonsterAbility[] GetMonsterAbilities() => null;
@@ -1432,7 +1657,7 @@ namespace Server.Mobiles
return false;
}
- if (m_Team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0)
+ if (_team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0)
{
return true;
}
@@ -1550,7 +1775,7 @@ namespace Server.Mobiles
var chance = Math.Clamp(700 + bonus, 220, 990);
- chance -= (MaxLoyalty - m_Loyalty) * 10;
+ chance -= (MaxLoyalty - _loyalty) * 10;
return chance / 1000.0;
}
@@ -1642,7 +1867,7 @@ namespace Server.Mobiles
public override bool CheckPoisonImmunity(Mobile from, Poison poison) =>
base.CheckPoisonImmunity(from, poison) ||
- (m_Paragon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level;
+ (_isParagon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level;
public void Unpacify()
{
@@ -1669,7 +1894,6 @@ namespace Server.Mobiles
}
int disruptThreshold;
- // NPCs can use bandages too!
if (!Core.AOS)
{
disruptThreshold = 0;
@@ -1919,163 +2143,28 @@ namespace Server.Mobiles
}
}
- public override void Serialize(IGenericWriter writer)
+ // Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom.
+ private void Deserialize(IGenericReader reader, int version)
{
- base.Serialize(writer);
+ _currentAI = (AIType)reader.ReadInt();
+ _defaultAI = (AIType)reader.ReadInt();
- writer.Write(22); // version
+ _rangePerception = reader.ReadInt();
+ _rangeFight = reader.ReadInt();
- writer.Write((int)m_CurrentAI);
- writer.Write((int)m_DefaultAI);
-
- writer.Write(RangePerception);
- writer.Write(RangeFight);
-
- writer.Write(m_Team);
-
- writer.Write(_activeSpeed);
- writer.Write(_passiveSpeed);
- writer.Write(_currentSpeed);
-
- writer.Write(m_Home.X);
- writer.Write(m_Home.Y);
- writer.Write(m_Home.Z);
-
- // Version 1
- writer.Write(RangeHome);
-
- // Version 2
- writer.Write((int)FightMode);
-
- writer.Write(_controlled);
- writer.Write(m_ControlMaster);
- writer.Write(ControlTarget);
- writer.Write(ControlDest);
- writer.Write((int)m_ControlOrder);
- writer.Write(MinTameSkill);
- // Removed in version 9
- // writer.Write( (double) m_dMaxTameSkill );
- writer.Write(m_bTamable);
- writer.Write(_summoned);
-
- if (_summoned)
- {
- writer.WriteAnchoredTime(SummonEnd);
- }
-
- writer.Write(ControlSlots);
-
- // Version 3
- writer.Write(m_Loyalty);
-
- // Version 4
- writer.Write(CurrentWayPoint);
-
- // Verison 5
- writer.Write(m_SummonMaster);
-
- // Version 6
- writer.Write(HitsMaxSeed);
- writer.Write(StamMaxSeed);
- writer.Write(ManaMaxSeed);
- writer.Write(m_DamageMin);
- writer.Write(m_DamageMax);
-
- // Version 7
- writer.Write(m_PhysicalResistance);
- writer.Write(PhysicalDamage);
-
- writer.Write(m_FireResistance);
- writer.Write(FireDamage);
-
- writer.Write(m_ColdResistance);
- writer.Write(ColdDamage);
-
- writer.Write(m_PoisonResistance);
- writer.Write(PoisonDamage);
-
- writer.Write(m_EnergyResistance);
- writer.Write(EnergyDamage);
-
- // Version 8
- Owners.Tidy();
- writer.Write(Owners);
-
- // Version 10
- writer.Write(IsDeadPet);
- writer.Write(m_IsBonded);
- writer.Write(BondingBegin);
- writer.Write(OwnerAbandonTime);
-
- // Version 11
- writer.Write(m_HasGeneratedLoot);
-
- // Version 12
- writer.Write(m_Paragon);
-
- var hasFriends = Friends?.Count > 0;
-
- // Version 13
- writer.Write(hasFriends);
-
- if (hasFriends)
- {
- Friends.Tidy();
- writer.Write(Friends);
- }
-
- // Version 14
- writer.Write(RemoveIfUntamed);
- writer.Write(RemoveStep);
-
- // Version 17
- if (IsStabled || Controlled && ControlMaster != null)
- {
- writer.Write(TimeSpan.Zero);
- }
- else
- {
- writer.Write(DeleteTimeLeft);
- }
-
- // Version 18
- writer.Write(CorpseNameOverride);
-
- // Version 19
- writer.Write(HomeMap);
-
- // Version 22 (0 = inherit the matching think value)
- writer.Write(_activeMoveSpeed);
- writer.Write(_passiveMoveSpeed);
- }
-
- public override void Deserialize(IGenericReader reader)
- {
- base.Deserialize(reader);
-
- NextReacquireTime = Core.TickCount;
-
- var version = reader.ReadInt();
-
- m_CurrentAI = (AIType)reader.ReadInt();
- m_DefaultAI = (AIType)reader.ReadInt();
-
- RangePerception = reader.ReadInt();
- RangeFight = reader.ReadInt();
-
- m_Team = reader.ReadInt();
+ _team = reader.ReadInt();
_activeSpeed = reader.ReadDouble();
_passiveSpeed = reader.ReadDouble();
_currentSpeed = reader.ReadDouble();
- m_Home.X = reader.ReadInt();
- m_Home.Y = reader.ReadInt();
- m_Home.Z = reader.ReadInt();
+ _home.X = reader.ReadInt();
+ _home.Y = reader.ReadInt();
+ _home.Z = reader.ReadInt();
if (version >= 1)
{
- RangeHome = reader.ReadInt();
+ _rangeHome = reader.ReadInt();
if (version < 20)
{
@@ -2096,121 +2185,121 @@ namespace Server.Mobiles
}
else
{
- RangeHome = 0;
+ _rangeHome = 0;
}
if (version >= 2)
{
- FightMode = (FightMode)reader.ReadInt();
+ _fightMode = (FightMode)reader.ReadInt();
_controlled = reader.ReadBool();
- m_ControlMaster = reader.ReadEntity();
- ControlTarget = reader.ReadEntity();
- ControlDest = reader.ReadPoint3D();
- m_ControlOrder = (OrderType)reader.ReadInt();
+ _controlMaster = reader.ReadEntity();
+ _controlTarget = reader.ReadEntity();
+ _controlDest = reader.ReadPoint3D();
+ _controlOrder = (OrderType)reader.ReadInt();
- MinTameSkill = reader.ReadDouble();
+ _minTameSkill = reader.ReadDouble();
if (version < 9)
{
reader.ReadDouble();
}
- m_bTamable = reader.ReadBool();
+ _tamable = reader.ReadBool();
_summoned = reader.ReadBool();
if (_summoned)
{
- SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
- new UnsummonTimer(this, SummonEnd - Core.Now).Start();
+ // The UnsummonTimer is restarted in AfterDeserialization.
+ _summonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
}
- ControlSlots = reader.ReadInt();
+ _controlSlots = reader.ReadInt();
}
else
{
- FightMode = FightMode.Closest;
+ _fightMode = FightMode.Closest;
_controlled = false;
- m_ControlMaster = null;
- ControlTarget = null;
- m_ControlOrder = OrderType.None;
+ _controlMaster = null;
+ _controlTarget = null;
+ _controlOrder = OrderType.None;
}
if (version >= 3)
{
- m_Loyalty = reader.ReadInt();
+ _loyalty = reader.ReadInt();
}
else
{
- m_Loyalty = MaxLoyalty; // Wonderfully Happy
+ _loyalty = MaxLoyalty;
}
if (version >= 4)
{
- CurrentWayPoint = reader.ReadEntity();
+ _currentWayPoint = reader.ReadEntity();
}
if (version >= 5)
{
- m_SummonMaster = reader.ReadEntity();
+ _summonMaster = reader.ReadEntity();
}
if (version >= 6)
{
- HitsMaxSeed = reader.ReadInt();
- StamMaxSeed = reader.ReadInt();
- ManaMaxSeed = reader.ReadInt();
- m_DamageMin = reader.ReadInt();
- m_DamageMax = reader.ReadInt();
+ _hitsMaxSeed = reader.ReadInt();
+ _stamMaxSeed = reader.ReadInt();
+ _manaMaxSeed = reader.ReadInt();
+ _damageMin = reader.ReadInt();
+ _damageMax = reader.ReadInt();
}
if (version >= 7)
{
- m_PhysicalResistance = reader.ReadInt();
- PhysicalDamage = reader.ReadInt();
+ _physicalResistanceSeed = reader.ReadInt();
+ _physicalDamage = reader.ReadInt();
- m_FireResistance = reader.ReadInt();
- FireDamage = reader.ReadInt();
+ _fireResistSeed = reader.ReadInt();
+ _fireDamage = reader.ReadInt();
- m_ColdResistance = reader.ReadInt();
- ColdDamage = reader.ReadInt();
+ _coldResistSeed = reader.ReadInt();
+ _coldDamage = reader.ReadInt();
- m_PoisonResistance = reader.ReadInt();
- PoisonDamage = reader.ReadInt();
+ _poisonResistSeed = reader.ReadInt();
+ _poisonDamage = reader.ReadInt();
- m_EnergyResistance = reader.ReadInt();
- EnergyDamage = reader.ReadInt();
+ _energyResistSeed = reader.ReadInt();
+ _energyDamage = reader.ReadInt();
}
if (version >= 8)
{
- Owners = reader.ReadEntityList();
+ _owners = reader.ReadEntityList();
}
else
{
- Owners = new List();
+ _owners = new List();
}
if (version >= 10)
{
- IsDeadPet = reader.ReadBool();
- m_IsBonded = reader.ReadBool();
- BondingBegin = reader.ReadDateTime();
- OwnerAbandonTime = reader.ReadDateTime();
+ _isDeadPet = reader.ReadBool();
+ _isBonded = reader.ReadBool();
+ _bondingBegin = reader.ReadDateTime();
+ _ownerAbandonTime = reader.ReadDateTime();
}
- m_HasGeneratedLoot = version < 11 || reader.ReadBool();
+ _hasGeneratedLoot = version < 11 || reader.ReadBool();
- m_Paragon = version >= 12 && reader.ReadBool();
+ _isParagon = version >= 12 && reader.ReadBool();
if (version >= 13 && reader.ReadBool())
{
- Friends = reader.ReadEntityList();
+ _friends = reader.ReadEntityList();
}
- else if (version < 13 && m_ControlOrder >= OrderType.Unfriend)
+ else if (version < 13 && _controlOrder >= OrderType.Unfriend)
{
- ++m_ControlOrder;
+ ++_controlOrder;
}
if (version < 16 && Loyalty != MaxLoyalty)
@@ -2220,8 +2309,8 @@ namespace Server.Mobiles
if (version >= 14)
{
- RemoveIfUntamed = reader.ReadBool();
- RemoveStep = reader.ReadInt();
+ _removeIfUntamed = reader.ReadBool();
+ _removeStep = reader.ReadInt();
}
var deleteTime = TimeSpan.Zero;
@@ -2238,18 +2327,18 @@ namespace Server.Mobiles
deleteTime = TimeSpan.FromDays(3.0);
}
- m_DeleteTimer = new DeleteTimer(this, deleteTime);
- m_DeleteTimer.Start();
+ _pendingDeleteTimer = new DeleteTimer(this, deleteTime);
+ _pendingDeleteTimer.Start();
}
if (version >= 18)
{
- CorpseNameOverride = reader.ReadString();
+ _corpseNameOverride = reader.ReadString();
}
if (version >= 19)
{
- HomeMap = reader.ReadMap();
+ _homeMap = reader.ReadMap();
}
if (version >= 22)
@@ -2262,25 +2351,61 @@ namespace Server.Mobiles
MigrateMoveSpeeds();
}
- if (version <= 14 && m_Paragon && Hue == 0x31)
+ if (version <= 14 && _isParagon && Hue == 0x31)
{
Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501.
}
+ }
+
+ [AfterDeserialization]
+ private void AfterDeserialization()
+ {
+ NextReacquireTime = Core.TickCount;
+
+ if (_activeSpeed <= 0 || _passiveSpeed <= 0)
+ {
+ if (!_loggedMissingSpeeds)
+ {
+ _loggedMissingSpeeds = true;
+ logger.Error(
+ "{Type} loaded without speeds - is Data/npc-speeds.json missing or changed? Pacing at {Active}/{Passive}.",
+ GetType(),
+ FallbackActiveSpeed,
+ FallbackPassiveSpeed
+ );
+ }
+
+ _activeSpeed = FallbackActiveSpeed;
+ _passiveSpeed = FallbackPassiveSpeed;
+ _currentSpeed = _passiveSpeed;
+ }
if (Core.AOS && NameHue == 0x35)
{
NameHue = -1;
}
+ if (_summoned)
+ {
+ new UnsummonTimer(this, _summonEnd - Core.Now).Start();
+ }
+
+ // An abandoned pet with no persisted countdown still despawns.
+ if (_pendingDeleteTimer == null && LastOwner != null && !_controlled && !IsStabled)
+ {
+ _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0));
+ _pendingDeleteTimer.Start();
+ }
+
CheckStatTimers();
- ChangeAIType(m_CurrentAI);
+ ChangeAIType(_currentAI);
AddFollowers();
if (IsAnimatedDead)
{
- AnimateDeadSpell.Register(m_SummonMaster, this);
+ AnimateDeadSpell.Register(_summonMaster, this);
}
}
@@ -2335,8 +2460,7 @@ namespace Server.Mobiles
return true;
}
- // Note: Yes, this happens for all questers (regardless of type, e.g. escorts),
- // even if they can't offer you anything at the moment
+ // Happens for all questers, even those with nothing to offer right now.
if (MLQuestSystem.Enabled && CanGiveMLQuest && from is PlayerMobile mobile)
{
// You need to mark your quest items so I don't take the wrong object. Then speak to me.
@@ -2367,7 +2491,7 @@ namespace Server.Mobiles
AIType.AI_Vendor => new VendorAI(this),
AIType.AI_Mage => new MageAI(this),
AIType.AI_Predator =>
- // m_AI = new PredatorAI(this);
+ //TODO Implement PredatorAI
new MeleeAI(this),
AIType.AI_Thief => new ThiefAI(this),
_ => null
@@ -2387,7 +2511,7 @@ namespace Server.Mobiles
public void RemoveFollowers()
{
- var master = m_ControlMaster ?? m_SummonMaster;
+ var master = _controlMaster ?? _summonMaster;
if (master != null)
{
master.Followers -= Math.Min(ControlSlots, master.Followers);
@@ -2401,7 +2525,7 @@ namespace Server.Mobiles
public void AddFollowers()
{
- var master = m_ControlMaster ?? m_SummonMaster;
+ var master = _controlMaster ?? _summonMaster;
if (master != null)
{
master.Followers += ControlSlots;
@@ -2447,7 +2571,7 @@ namespace Server.Mobiles
public virtual void OnGaveMeleeAttack(Mobile defender, int damage)
{
- var p = m_Paragon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison;
+ var p = _isParagon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison;
if (p != null && HitPoisonChance >= Utility.RandomDouble())
{
@@ -2476,17 +2600,13 @@ namespace Server.Mobiles
AIObject = null;
}
- if (m_DeleteTimer != null)
- {
- m_DeleteTimer.Stop();
- m_DeleteTimer = null;
- }
+ StopPendingDeleteTimer();
FocusMob = null;
if (IsAnimatedDead)
{
- AnimateDeadSpell.Unregister(m_SummonMaster, this);
+ AnimateDeadSpell.Unregister(_summonMaster, this);
}
if (Summoned && SummonMaster != null)
@@ -2505,13 +2625,6 @@ namespace Server.Mobiles
base.OnAfterDelete();
}
- /*
- * This function can be overridden.. so a "Strongest" mobile, can have a different definition depending
- * on who check for value
- * -Could add a FightMode.Preferred
- *
- */
-
public virtual double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly)
{
if (bPlayerOnly && !m.Player)
@@ -2527,8 +2640,7 @@ namespace Server.Mobiles
};
}
- // Turn, - for left, + for right
- // Basic for now, needs work
+ // Turn: negative = left, positive = right.
public virtual void Turn(int iTurnSteps)
{
var v = (int)Direction;
@@ -2545,7 +2657,7 @@ namespace Server.Mobiles
public bool IsHurt() => Hits != HitsMax;
- public double GetHomeDistance() => this.GetDistanceToSqrt(m_Home);
+ public double GetHomeDistance() => this.GetDistanceToSqrt(_home);
public virtual int GetTeamSize(int iRange)
{
@@ -2572,7 +2684,7 @@ namespace Server.Mobiles
aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true));
}
- var ct = m_ControlOrder;
+ var ct = _controlOrder;
if (AIObject != null)
{
@@ -2646,7 +2758,7 @@ namespace Server.Mobiles
AIObject?.GetContextMenuEntries(from, ref list);
}
- if (m_bTamable && !_controlled && from.Alive)
+ if (_tamable && !_controlled && from.Alive)
{
list.Add(new TameEntry(from.Female ? AllowFemaleTamer : AllowMaleTamer));
}
@@ -2697,7 +2809,7 @@ namespace Server.Mobiles
}
public override bool IsHarmfulCriminal(Mobile target) =>
- (!Controlled || target != m_ControlMaster) && (!Summoned || target != m_SummonMaster) &&
+ (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) &&
(target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) &&
(target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target);
@@ -2707,13 +2819,13 @@ namespace Server.Mobiles
if (Controlled || Summoned)
{
- if (m_ControlMaster?.Player == true)
+ if (_controlMaster?.Player == true)
{
- m_ControlMaster.CriminalAction(false);
+ _controlMaster.CriminalAction(false);
}
- else if (m_SummonMaster?.Player == true)
+ else if (_summonMaster?.Player == true)
{
- m_SummonMaster.CriminalAction(false);
+ _summonMaster.CriminalAction(false);
}
}
}
@@ -2722,7 +2834,7 @@ namespace Server.Mobiles
{
base.DoHarmful(target, indirect);
- if (target == this || target == m_ControlMaster || target == m_SummonMaster || !Controlled && !Summoned)
+ if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned)
{
return;
}
@@ -2772,12 +2884,11 @@ namespace Server.Mobiles
{
if (Combatant != null)
{
- return false; // in combat.. not idling
+ return false; // in combat, not idling
}
if (m_IdleReleaseTime > DateTime.MinValue)
{
- // idling...
if (Core.Now >= m_IdleReleaseTime)
{
m_IdleReleaseTime = DateTime.MinValue;
@@ -2789,7 +2900,7 @@ namespace Server.Mobiles
if (Utility.Random(100) < 95)
{
- return false; // not idling, but don't want to enter idle state
+ return false; // chose not to enter the idle state
}
var idleSeconds = Utility.RandomMinMax(NPCSpeeds.MinIdleSeconds, NPCSpeeds.MaxIdleSeconds);
@@ -2829,12 +2940,6 @@ namespace Server.Mobiles
return true; // entered idle state
}
- /*
- this way, due to the huge number of locations this will have to be changed
- Perhaps we can change this in the future when fixing game play is not the
- major issue.
- */
-
public virtual void CheckedAnimate(int action, int frameCount, int repeatCount, bool forward, bool repeat, int delay)
{
if (!Mounted)
@@ -2897,7 +3002,7 @@ namespace Server.Mobiles
SpeechType?.OnMovement(this, m, oldLocation);
- /* Begin notice sound */
+ // Notice sound
if ((!m.Hidden || m.AccessLevel == AccessLevel.Player) && m.Player && FightMode != FightMode.Aggressor &&
FightMode != FightMode.None && Combatant == null && !Controlled && !Summoned && !BardPacified &&
InRange(m.Location, 18) && !InRange(oldLocation, 18))
@@ -2909,7 +3014,6 @@ namespace Server.Mobiles
PlaySound(GetAngerSound());
}
- /* End notice sound */
if (MLQuestSystem.Enabled && CanShout && m is PlayerMobile mobile)
{
@@ -2983,7 +3087,7 @@ namespace Server.Mobiles
list.Add(TotalWeight == 1 ? 1072788 : 1072789, TotalWeight); // Weight: ~1_WEIGHT~ stones
}
- if (m_ControlOrder == OrderType.Guard)
+ if (_controlOrder == OrderType.Guard)
{
list.Add(1080078); // guarding
}
@@ -2995,7 +3099,7 @@ namespace Server.Mobiles
}
else if (Controlled && Commandable)
{
- // Intentional difference (showing ONLY bonded when bonded instead of bonded & tame)
+ // Deliberate: show only (bonded), never (bonded) and (tame) together.
if (IsBonded)
{
list.Add(1049608); // (bonded)
@@ -3057,7 +3161,7 @@ namespace Server.Mobiles
{
if (treasureLevel >= 0)
{
- if (m_Paragon && Paragon.ChestChance > Utility.RandomDouble())
+ if (_isParagon && Paragon.ChestChance > Utility.RandomDouble())
{
PackItem(new ParagonChest(Name, treasureLevel));
}
@@ -3067,7 +3171,7 @@ namespace Server.Mobiles
}
}
- if (m_Paragon && Paragon.ChocolateIngredientChance > Utility.RandomDouble())
+ if (_isParagon && Paragon.ChocolateIngredientChance > Utility.RandomDouble())
{
switch (Utility.Random(4))
{
@@ -3095,9 +3199,10 @@ namespace Server.Mobiles
}
}
- if (!Summoned && !NoKillAwards && !m_HasGeneratedLoot)
+ if (!Summoned && !NoKillAwards && !_hasGeneratedLoot)
{
- m_HasGeneratedLoot = true;
+ _hasGeneratedLoot = true;
+ this.MarkDirty();
GenerateLoot(false);
}
@@ -3289,7 +3394,7 @@ namespace Server.Mobiles
MondainsLegacy.GiveArtifactTo(mob);
}
}
- else if (m_Paragon)
+ else if (_isParagon)
{
if (Paragon.CheckArtifactChance(mob, this))
{
@@ -3298,9 +3403,6 @@ namespace Server.Mobiles
}
}
- [GeneratedEvent(nameof(CreatureDeathEvent))]
- public static partial void CreatureDeathEvent(BaseCreature bc);
-
public override void OnDeath(Container c)
{
if (IsBonded)
@@ -3323,7 +3425,6 @@ namespace Server.Mobiles
ProcessDelta();
SendIncomingPacket();
- // TODO: This can be done in Parallel if there are lots of them.
var aggressors = Aggressors;
for (var i = 0; i < aggressors.Count; ++i)
@@ -3363,7 +3464,7 @@ namespace Server.Mobiles
OwnerAbandonTime = DateTime.MinValue;
}
- CreatureDeathEvent(this);
+ CreatureEvents.CreatureDeathEvent(this);
CheckStatTimers();
return;
@@ -3397,7 +3498,6 @@ namespace Server.Mobiles
if (ds.m_Mobile == killer)
{
- // If the titles system gets feature flagged, it will be supported
titles.Add(ds.m_Mobile);
fame.Add(totalFame);
karma.Add(totalKarma);
@@ -3493,17 +3593,14 @@ namespace Server.Mobiles
c.Delete();
}
- CreatureDeathEvent(this);
+ CreatureEvents.CreatureDeathEvent(this);
}
- [GeneratedEvent(nameof(CreatureDeletedEvent))]
- public static partial void CreatureDeletedEvent(BaseCreature bc);
-
public override void OnDelete()
{
- CreatureDeletedEvent(this);
+ CreatureEvents.CreatureDeletedEvent(this);
- var m = m_ControlMaster;
+ var m = _controlMaster;
SetControlMaster(null);
SummonMaster = null;
@@ -3576,12 +3673,7 @@ namespace Server.Mobiles
ControlTarget = null;
ControlOrder = OrderType.Come;
-
- if (m_DeleteTimer != null)
- {
- m_DeleteTimer.Stop();
- m_DeleteTimer = null;
- }
+ StopPendingDeleteTimer();
}
Guild = null;
@@ -3774,7 +3866,7 @@ namespace Server.Mobiles
{
// *rummages through a corpse and takes an item*
PublicOverheadMessage(MessageType.Emote, 0x3B2, 1008086);
- // TODO: Instancing of Rummaged stuff.
+ //TODO Instance rummaged loot
return true;
}
}
@@ -3795,14 +3887,14 @@ namespace Server.Mobiles
return BardMaster;
}
- if (_controlled && m_ControlMaster != null)
+ if (_controlled && _controlMaster != null)
{
- return m_ControlMaster;
+ return _controlMaster;
}
- if (_summoned && m_SummonMaster != null)
+ if (_summoned && _summonMaster != null)
{
- return m_SummonMaster;
+ return _summonMaster;
}
return base.GetDamageMaster(damagee);
@@ -4074,19 +4166,13 @@ namespace Server.Mobiles
if (this is not BaseEscortable && !Summoned && !Deleted && !IsStabled)
{
StopDeleteTimer();
- m_DeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0));
- m_DeleteTimer.Start();
+ _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0));
+ _pendingDeleteTimer.Start();
+ this.MarkDirty();
}
}
- public void StopDeleteTimer()
- {
- if (m_DeleteTimer != null)
- {
- m_DeleteTimer.Stop();
- m_DeleteTimer = null;
- }
- }
+ public void StopDeleteTimer() => StopPendingDeleteTimer();
public void SpillAcid(int amount)
{
@@ -4120,11 +4206,7 @@ namespace Server.Mobiles
}
}
- /*
- Solen Style, override me for other mobiles/items:
- kappa+acidslime, grizzles+whatever, etc.
- */
-
+ // Solen-style acid; override for other harmful drops (kappa slime, etc.).
public virtual Item NewHarmfulItem() => new Acid(TimeSpan.FromSeconds(10), 30, 30);
public virtual void StopFlee()
@@ -4165,7 +4247,7 @@ namespace Server.Mobiles
public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m);
public virtual bool IsFriend(Mobile m) =>
- OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && m_Team == c.m_Team
+ OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team
&& (_summoned || _controlled) == (c._summoned || c._controlled);
public virtual Allegiance GetFactionAllegiance(Mobile mob)
@@ -4357,16 +4439,17 @@ namespace Server.Mobiles
if (Core.SE)
{
- m_Loyalty = MaxLoyalty;
+ _loyalty = MaxLoyalty;
+ this.MarkDirty();
}
- else if (m_Loyalty < MaxLoyalty)
+ else if (_loyalty < MaxLoyalty)
{
- // Calculate the loyalty increase
var loyaltyIncrease = Utility.CoinFlips(amount, MaxLoyaltyIncrease) * 10;
- if (loyaltyIncrease > 0) // Only update if there's an actual increase
+ if (loyaltyIncrease > 0)
{
- m_Loyalty = Math.Min(MaxLoyalty, m_Loyalty + loyaltyIncrease);
+ _loyalty = Math.Min(MaxLoyalty, _loyalty + loyaltyIncrease);
+ this.MarkDirty();
SayTo(from, 502060); // Your pet looks happier.
}
}
@@ -4382,7 +4465,7 @@ namespace Server.Mobiles
if (IsBondable && !IsBonded)
{
- var master = m_ControlMaster;
+ var master = _controlMaster;
if (master != null && master == from) // So friends can't start the bonding process
{
@@ -4569,7 +4652,6 @@ namespace Server.Mobiles
}
}
- /* Sanity check */
if (baseToSet > theirSkill.CapFixedPoint ||
m.Skills.Total - theirSkill.BaseFixedPoint + baseToSet > m.Skills.Cap)
{
@@ -4680,6 +4762,7 @@ namespace Server.Mobiles
{
_activeMoveSpeed = 0;
_passiveMoveSpeed = 0;
+ this.MarkDirty();
}
///
@@ -4697,6 +4780,8 @@ namespace Server.Mobiles
{
_passiveMoveSpeed *= scalar;
}
+
+ this.MarkDirty();
}
///
@@ -4726,6 +4811,8 @@ namespace Server.Mobiles
{
_passiveMoveSpeed = passiveMoveSpeed;
}
+
+ this.MarkDirty();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -4734,16 +4821,13 @@ namespace Server.Mobiles
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetCurrentSpeedToPassive() => CurrentSpeed = PassiveSpeed;
- public void SetDamage(int val)
- {
- m_DamageMin = val;
- m_DamageMax = val;
- }
+ public void SetDamage(int val) => SetDamage(val, val);
public void SetDamage(int min, int max)
{
- m_DamageMin = min;
- m_DamageMax = max;
+ _damageMin = min;
+ _damageMax = max;
+ this.MarkDirty();
}
public void SetHits(int val)
@@ -4877,31 +4961,32 @@ namespace Server.Mobiles
{
case ResistanceType.Physical:
{
- m_PhysicalResistance = val;
+ _physicalResistanceSeed = val;
break;
}
case ResistanceType.Fire:
{
- m_FireResistance = val;
+ _fireResistSeed = val;
break;
}
case ResistanceType.Cold:
{
- m_ColdResistance = val;
+ _coldResistSeed = val;
break;
}
case ResistanceType.Poison:
{
- m_PoisonResistance = val;
+ _poisonResistSeed = val;
break;
}
case ResistanceType.Energy:
{
- m_EnergyResistance = val;
+ _energyResistSeed = val;
break;
}
}
+ this.MarkDirty();
UpdateResistances();
}
@@ -5036,20 +5121,39 @@ namespace Server.Mobiles
// If this needs to be serialized, recommend creating a hash or registry id. Don't serialize strings.
public virtual SpeedLevel SpeedClass => SpeedLevel.None;
+ // Cached: the speed SaveFlags consult this on every save and elided load.
+ private NPCSpeeds.SpeedClassEntry _speedEntry;
+
+ private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(this);
+
+ // Never throws: the speed SaveFlags call this on every save and elided load. Without a
+ // table entry the serialized speeds stand; only the constructor refuses.
public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
- NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed);
+ var entry = SpeedEntry;
+
+ if (entry == null)
+ {
+ activeSpeed = _activeSpeed;
+ passiveSpeed = _passiveSpeed;
+ return;
+ }
+
+ activeSpeed = entry.ActiveSpeed;
+ passiveSpeed = entry.PassiveSpeed;
}
+ // Move speeds are optional (0 = inherit), so this tolerates an unloaded table.
public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
{
- NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed);
+ var entry = SpeedEntry;
+
+ activeMoveSpeed = entry?.ActiveMoveSpeed ?? 0;
+ passiveMoveSpeed = entry?.PassiveMoveSpeed ?? 0;
}
- // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds
- // still match what it would spawn with today was never hand-tuned: adopt today's
- // move values so existing worlds (and pets) pick up npc-speeds pacing without a
- // respawn. Tuned creatures keep movement inheriting their think clock.
+ // Pre-v22 saves have no movement clock: untuned creatures adopt the table's move
+ // values, hand-tuned ones keep inheriting.
internal void MigrateMoveSpeeds()
{
GetSpeeds(out var activeSpeed, out var passiveSpeed);
@@ -5098,7 +5202,7 @@ namespace Server.Mobiles
GenerateLoot();
- if (m_Paragon)
+ if (_isParagon)
{
if (Fame < 1250)
{
@@ -5498,8 +5602,6 @@ namespace Server.Mobiles
var onSelf = patient == this;
- // DoBeneficial( patient );
-
RevealingAction();
if (!onSelf)
@@ -5542,7 +5644,7 @@ namespace Server.Mobiles
{
patient.SendLocalizedMessage(1010059); // You have been cured of all poisons.
- CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); // TODO: Verify formula
+ CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); //TODO Verify formula
CheckSkill(SkillName.Anatomy, 0.0, 100.0);
}
}
@@ -5741,7 +5843,6 @@ namespace Server.Mobiles
using var toRelease = PooledRefQueue.Create();
- // added array for wild creatures in house regions to be removed
using var toRemove = PooledRefQueue.Create();
foreach (var m in World.Mobiles.Values)
@@ -5799,7 +5900,7 @@ namespace Server.Mobiles
}
}
- // added lines to check if a wild creature in a house region has to be removed or not
+ // Wild creatures squatting in houses are removed outright.
if (!c.Controlled && !c.IsStabled && (c.Region.IsPartOf() && c.CanBeDamaged() ||
c.RemoveIfUntamed && c.Spawner == null))
{
@@ -5821,12 +5922,13 @@ namespace Server.Mobiles
var c = toRelease.Dequeue();
c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master!
- c.Loyalty = BaseCreature.MaxLoyalty; // Wonderfully Happy
+ c.Loyalty = BaseCreature.MaxLoyalty;
c.IsBonded = false;
c.BondingBegin = DateTime.MinValue;
c.OwnerAbandonTime = DateTime.MinValue;
c.ControlTarget = null;
- // This will prevent no release of creatures left alone with AI disabled (and consequent bug of Followers)
+ // Release directly: a creature left alone with its AI disabled would
+ // otherwise never release and permanently hold its owner's follower slots.
c.AIObject.DoOrderRelease();
c.DropBackpack();
}
diff --git a/Projects/UOContent/Mobiles/CreatureEvents.cs b/Projects/UOContent/Mobiles/CreatureEvents.cs
new file mode 100644
index 000000000..848e9de2c
--- /dev/null
+++ b/Projects/UOContent/Mobiles/CreatureEvents.cs
@@ -0,0 +1,13 @@
+using ModernUO.CodeGeneratedEvents;
+
+namespace Server.Mobiles;
+
+// Hosts BaseCreature's generated events: two generators cannot both emit [GeneratedCode] on one type (CS0579).
+public static partial class CreatureEvents
+{
+ [GeneratedEvent(nameof(CreatureDeathEvent))]
+ public static partial void CreatureDeathEvent(BaseCreature bc);
+
+ [GeneratedEvent(nameof(CreatureDeletedEvent))]
+ public static partial void CreatureDeletedEvent(BaseCreature bc);
+}
diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs
index 178436fb9..fca41ec25 100644
--- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs
+++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs
@@ -154,7 +154,7 @@ namespace Server.Mobiles
public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m);
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
- [OnEvent(nameof(CreatureDeathEvent))]
+ [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
public static void StopEffect(Mobile m, bool message = false)
{
if (m_Table.Remove(m, out var timer))
diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs
index 8f5e908bb..44489ba6b 100644
--- a/Projects/UOContent/Mobiles/NPCSpeeds.cs
+++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs
@@ -26,32 +26,16 @@ public static class NPCSpeeds
public static int MinIdleSeconds { get; private set; }
public static int MaxIdleSeconds { get; private set; }
- public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed)
+ // Null when the table is unloaded (test fixtures). Immutable after Configure, so creatures cache it.
+ public static SpeedClassEntry FindEntry(BaseCreature bc)
{
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
!_speedsByType.TryGetValue(bc.GetType(), out sp))
{
- sp = _speedsByLevel[SpeedLevel.Medium];
+ _speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp);
}
- activeSpeed = sp.ActiveSpeed;
- passiveSpeed = sp.PassiveSpeed;
- }
-
- // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table.
- public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed)
- {
- if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
- !_speedsByType.TryGetValue(bc.GetType(), out sp) &&
- !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp))
- {
- activeMoveSpeed = 0;
- passiveMoveSpeed = 0;
- return;
- }
-
- activeMoveSpeed = sp.ActiveMoveSpeed;
- passiveMoveSpeed = sp.PassiveMoveSpeed;
+ return sp;
}
public static void RegisterSpeed(SpeedClassEntry entry)
diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs
index e37431ddd..e930af423 100644
--- a/Projects/UOContent/Skills/AntiMacroSystem.cs
+++ b/Projects/UOContent/Skills/AntiMacroSystem.cs
@@ -5,7 +5,6 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using ModernUO.CodeGeneratedEvents;
-using Server.Collections;
using Server.Json;
using Server.Mobiles;
diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs
index 487eda693..27ba888cf 100644
--- a/Projects/UOContent/Skills/DetectHidden.cs
+++ b/Projects/UOContent/Skills/DetectHidden.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using Server.Collections;
using Server.Engines.PartySystem;
using Server.Factions;
using Server.Guilds;
diff --git a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs
index c0f3c3ed0..578bdc482 100644
--- a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs
+++ b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs
@@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell
// shared timer from either the caster or the target key, so a single call per mobile is enough.
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
- [OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
- [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
+ [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
+ [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
public static void OnCurseEnds(Mobile m) => RemoveCurse(m);
private class ExpireTimer : Timer
diff --git a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs
index 585d37284..6a5d4850a 100644
--- a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs
+++ b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs
@@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving
Caster.Target = new SpellTarget(this, TargetFlags.Beneficial);
}
- [OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
+ [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
public static void OnDeathEvent(Mobile m)
{
diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md
index 6205ec2d4..f1df32d42 100644
--- a/dev-docs/content-patterns.md
+++ b/dev-docs/content-patterns.md
@@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs
(combat decisions, target acquisition, spell timing).
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per
step. Inherits the matching think value until overridden, so a creature configured with
- only think speeds behaves as one clock. Any value is legal — steps are scheduled
- independently of think ticks, so the two need not divide evenly.
+ only think speeds behaves as one clock. The properties read the raw override (`0` =
+ inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are
+ scheduled independently of think ticks, so the two need not divide evenly.
Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type
lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code:
From ab738d90af4cbaf4060942725e71e44e65fb1d60 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Sun, 6 Sep 2026 16:12:29 -0700
Subject: [PATCH 05/21] fix: pet release never finished, summon master follows
the pet, horse breeder and notoriety guards (#2613)
Pet and summon bugs found by the master-reference audit for #2592 (comments there have the full inventory). Independent of delta saves.
## Releasing a pet never finished
The Release order ran two half-releases that never met:
- The order handler cleared the master. That set `Controlled` to false, so `Obey` never ran again and the think-side `DoOrderRelease` (re-home, three-day delete timer, backpack drop) was dead code: a released pet kept its pack and never despawned.
- The loyalty drain called `DoOrderRelease` directly, so a pet whose loyalty hit zero got the countdown and dropped its pack but kept its master and its owner's follower slots, the opposite of the code comment's intent.
`DoOrderRelease` is now the whole release (targets, bonding, `SetControlMaster(null)`, re-home, delete or countdown, pack drop) and runs once, synchronously, from the handler or the loyalty drain. Summons still die on release, as before. Two tests pin both entry points: master cleared, follower slots returned, countdown running, home anchored.
This is the one place the `PetOrders` / `PetOrderHandlers` split bit; the wider audit of that duplication is a separate task.
## Summon master follows the pet
Transfer, stable claim, GM "obey" and Ball of Summoning copied `SummonMaster` only when `Summoned`, so a talisman summon (`Summoned` is false, `SummonMaster` set) kept its original summoner after a transfer: two different masters on one creature, with the original summoner's area spells still exempting it. They now mirror the summon master whenever it is set. Jail stabling cleared only `ControlMaster`, so a jailed talisman summon kept charging the summoner's follower slots; it now clears both, like the stable master and auto-stable already do.
## Small ones
- The faction horse breeder set `Controlled`/`ControlMaster` directly; it now goes through `SetControlMaster` and tells the buyer why it refused (1049607) instead of silently deleting the horse.
- `Notoriety` dereferenced `SummonMaster` on a summoned creature without a null check.
## Tests
UOContent.Tests 778 green (two new).
---
.../Tests/Mobiles/AI/PetOrderTests.cs | 32 +++++++++++++++++++
.../Factions/Gumps/HorseBreederGump.cs | 6 ++--
.../Special/Solen Items/BallOfSummoning.cs | 2 +-
Projects/UOContent/Misc/Notoriety.cs | 2 +-
.../UOContent/Mobiles/AI/BaseAI/OnSpeech.cs | 2 +-
.../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 9 +-----
.../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 15 +++++++++
.../Mobiles/AI/BaseAI/TransferItem.cs | 2 +-
Projects/UOContent/Mobiles/BaseCreature.cs | 7 ----
Projects/UOContent/Mobiles/PlayerMobile.cs | 2 +-
.../Mobiles/Vendors/NPC/AnimalTrainer.cs | 2 +-
.../Systems/JailSystem/JailSystem.cs | 1 +
12 files changed, 57 insertions(+), 25 deletions(-)
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
index e0fe22a39..e5d7760ed 100644
--- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
@@ -176,6 +176,38 @@ public class PetOrderTests : IDisposable
Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts
}
+ [Fact]
+ public void ReleaseOrder_ClearsTheMaster_AndStartsTheDeleteCountdown()
+ {
+ var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
+ pet.IsBonded = true;
+ var followers = master.Followers;
+
+ pet.ControlOrder = OrderType.Release;
+
+ Assert.False(pet.Controlled);
+ Assert.Null(pet.ControlMaster);
+ Assert.False(pet.IsBonded);
+ Assert.Equal(followers - pet.ControlSlots, master.Followers);
+ Assert.True(pet.PendingDeleteTimer?.Running);
+ Assert.Equal(pet.Location, pet.Home);
+ }
+
+ [Fact]
+ public void LoyaltyRelease_ClearsTheMaster_AndStartsTheDeleteCountdown()
+ {
+ var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
+ var followers = master.Followers;
+
+ // What the loyalty drain calls when loyalty reaches zero.
+ pet.AIObject.DoOrderRelease();
+
+ Assert.False(pet.Controlled);
+ Assert.Null(pet.ControlMaster);
+ Assert.Equal(followers - pet.ControlSlots, master.Followers);
+ Assert.True(pet.PendingDeleteTimer?.Running);
+ }
+
[Fact]
public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation()
{
diff --git a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs
index 7cd4edc92..8e8bf9308 100644
--- a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs
+++ b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs
@@ -61,7 +61,7 @@ public class HorseBreederGump : FactionGump
if (m_From.Followers + horse.ControlSlots > m_From.FollowersMax)
{
- // TODO: Message?
+ m_From.SendLocalizedMessage(1049607); // You have too many followers to control that creature.
horse.Delete();
}
else
@@ -79,9 +79,7 @@ public class HorseBreederGump : FactionGump
else if (pack.ConsumeTotal(typeof(Silver), FactionWarHorse.SilverPrice) &&
pack.ConsumeTotal(typeof(Gold), FactionWarHorse.GoldPrice))
{
- horse.Controlled = true;
- horse.ControlMaster = m_From;
-
+ horse.SetControlMaster(m_From);
horse.ControlOrder = OrderType.Follow;
horse.ControlTarget = m_From;
diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs
index 070a802b1..bf0953041 100644
--- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs
+++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs
@@ -228,7 +228,7 @@ public partial class BallOfSummoning : Item, TranslocationItem
{
pet.SetControlMaster(from);
- if (pet.Summoned)
+ if (pet.SummonMaster != null)
{
pet.SummonMaster = from;
}
diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs
index cb9b1ddcb..4416b09b5 100644
--- a/Projects/UOContent/Misc/Notoriety.cs
+++ b/Projects/UOContent/Misc/Notoriety.cs
@@ -228,7 +228,7 @@ namespace Server.Misc
}
if (bcTarg?.Controlled == true
- || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster.Player)
+ || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster?.Player == true)
{
return false; // Cannot harm other controlled mobiles from players
}
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs
index 8ffd39b42..ce9af5bb7 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs
@@ -448,7 +448,7 @@ public abstract partial class BaseAI
{
Mobile.SetControlMaster(e.Mobile);
- if (Mobile.Summoned)
+ if (Mobile.SummonMaster != null)
{
Mobile.SummonMaster = e.Mobile;
}
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs
index 862c78649..a132bc896 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs
@@ -261,15 +261,8 @@ public abstract partial class BaseAI
}
_commandIssuer?.RevealingAction();
- Mobile.ControlTarget = null;
- Mobile.FocusMob = null;
- Mobile.Warmode = false;
- Mobile.Combatant = null;
Mobile.PlaySound(Mobile.GetIdleSound());
- Mobile.BondingBegin = DateTime.MinValue;
- Mobile.OwnerAbandonTime = DateTime.MinValue;
- Mobile.IsBonded = false;
- Mobile.SetControlMaster(null);
+ DoOrderRelease();
_commandIssuer = null;
}
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
index e6c48850d..e09424328 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
@@ -13,6 +13,8 @@
* along with this program. If not, see . *
************************************************************************/
+using System;
+
namespace Server.Mobiles;
public abstract partial class BaseAI
@@ -457,10 +459,23 @@ public abstract partial class BaseAI
return best;
}
+ ///
+ /// The whole release: the master is cleared here, so this runs once, synchronously, from
+ /// the Release order handler or the loyalty drain, never from Obey.
+ ///
public virtual bool DoOrderRelease()
{
DebugSay("I have been released to the wild.");
+ Mobile.ControlTarget = null;
+ Mobile.FocusMob = null;
+ Mobile.Warmode = false;
+ Mobile.Combatant = null;
+ Mobile.BondingBegin = DateTime.MinValue;
+ Mobile.OwnerAbandonTime = DateTime.MinValue;
+ Mobile.IsBonded = false;
+ Mobile.SetControlMaster(null);
+
var spawner = Mobile.Spawner;
if (spawner != null)
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs
index 9efad5a5e..331df533c 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs
@@ -156,7 +156,7 @@ internal sealed partial class TransferItem : Item
private void TransferPetOwnership(Mobile from, Mobile to)
{
- if (_creature.Summoned)
+ if (_creature.SummonMaster != null)
{
_creature.SummonMaster = to;
}
diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs
index 5ea68cb13..f8dba7bf1 100644
--- a/Projects/UOContent/Mobiles/BaseCreature.cs
+++ b/Projects/UOContent/Mobiles/BaseCreature.cs
@@ -5923,14 +5923,7 @@ namespace Server.Mobiles
c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master!
c.Loyalty = BaseCreature.MaxLoyalty;
- c.IsBonded = false;
- c.BondingBegin = DateTime.MinValue;
- c.OwnerAbandonTime = DateTime.MinValue;
- c.ControlTarget = null;
- // Release directly: a creature left alone with its AI disabled would
- // otherwise never release and permanently hold its owner's follower slots.
c.AIObject.DoOrderRelease();
- c.DropBackpack();
}
while (toRemove.Count > 0)
diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs
index d0b1b2c7a..39be0e10a 100644
--- a/Projects/UOContent/Mobiles/PlayerMobile.cs
+++ b/Projects/UOContent/Mobiles/PlayerMobile.cs
@@ -3602,7 +3602,7 @@ namespace Server.Mobiles
{
pet.SetControlMaster(this);
- if (pet.Summoned)
+ if (pet.SummonMaster != null)
{
pet.SummonMaster = this;
}
diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs
index 847209869..b3fb4d721 100644
--- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs
+++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs
@@ -357,7 +357,7 @@ namespace Server.Mobiles
{
pet.SetControlMaster(from);
- if (pet.Summoned)
+ if (pet.SummonMaster != null)
{
pet.SummonMaster = from;
}
diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs
index 8802d0487..8da7319c9 100644
--- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs
+++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs
@@ -144,6 +144,7 @@ public class JailSystem : GenericPersistence
bc.Internalize();
bc.SetControlMaster(null);
+ bc.SummonMaster = null;
bc.IsStabled = true;
bc.StabledBy = from;
From 93e46a88b23c23cbac67f8a3ed3e8a1c951abb71 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Sun, 6 Sep 2026 21:32:12 -0700
Subject: [PATCH 06/21] fix: OPL revision hash collided on reordered and
repeated properties (#2615)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## The bug
`ObjectPropertyList.AddHash` folded each cliloc and a Marvin hash of each argument together with XOR, which is both commutative and self-inverse. Any value mixed in an even number of times cancelled outright, so two properties sharing one argument hashed identically no matter what that argument was:
```
_hash = 31659021
AddHash(1063752); AddHash(hash("10"))
AddHash(1063737); AddHash(hash("10")) // the argument cancels here
AddHash(1063740) // -> 32714560, for ANY argument
```
The 10% and 5% variants of the same item therefore produced the same revision. The client caches the tooltip by revision and only re-requests when it changes, so it kept rendering the stale percentage.
Same root cause, three more shapes:
| | old | new |
|---|---|---|
| Two properties sharing an argument | collides | distinct |
| Properties reordered | collides | distinct |
| Two properties trading arguments | collides | distinct |
| Argument-less property added twice | cancels to 0 | distinct |
## The fix
Hash the finished property block in `Terminate()` instead of accumulating per property. Those bytes — cliloc, length prefix and UTF-16 text, in emission order — are the exact content the client renders, so anything that changes the tooltip changes the hash. The length prefix also removes the concatenation ambiguity the old scheme had.
`AddHash` and both `string.GetHashCode` calls are gone.
## Why 26 bits, and why not 64
The client never recomputes the hash — it stores what we send in 0xD6 and compares it for equality against the 0xDC revision (`ObjectPropertiesListManager.IsRevisionEquals`). So the algorithm is ours to choose, but the width is not:
- Both packets carry a **4-byte** revision, so 64 bits is not available. A wider internal value would be worse than useless: the server would see a change and send a 0xDC whose truncated 32 bits are identical, and the client still wouldn't refresh.
- `Terminate` writes the bare hash into 0xD6 while `SendOPLInfo` writes `Hash` with bit 30 set. The client recovers one from the other by masking off `0x40000000`, which only holds while the hash stays below that bit. Hence 26 bits, unchanged from before.
- `Hash => 0x40000000 + _hash` keeps the revision non-zero; the client parks 0 as its "nothing cached" sentinel.
## Collision behaviour
Enumerated real small-OPL spaces and counted 26-bit collisions against the birthday expectation for a uniform hash:
| Scenario | block | n | collisions | expected |
|---|---|---|---|---|
| 1 cliloc, no arg (every cliloc 1.0M–3.2M) | 6 B | 2,200,000 | 35,591 | ~36,061 |
| 1 cliloc + numeric arg 0–199,999 | 8–12 B | 200,000 | 300 | ~298 |
| 2 clilocs, both with numeric args | 12–24 B | 202,500 | 288 | ~306 |
| 1 cliloc + 1-char arg | 8 B | 4,000,000 | 116,291 | ~119,209 |
Every case lands on the random-model line, and per-bit P(1) across all 26 bits is 0.4962–0.5024 — XXH3's short-input paths still avalanche fully, so a 6-byte block behaves like a uniform 26-bit draw. Masking the low 26 bits versus folding all 64 down was a wash (35,591 vs 35,558).
The metric that matters is narrower, since the client compares a revision only against the previous revision **for the same serial**: 2^-26 = 1.5e-8 per genuine tooltip change. Consecutive-value transitions (charges 50 -> 49) collided 0 times in 200,000.
Row 3 is the old scheme quantified: it collapsed 202,500 inputs into 100,954 distinct hashes, a 50% collision rate — systematic, not probabilistic.
One honest regression: in row 1 the old scheme had zero collisions, because for a single argument-less cliloc under 2^26 the hash was the identity function. An item whose whole tooltip is one argument-less cliloc changing to a different one goes from never colliding to 1.5e-8.
## Performance
Cheaper, not just correct — one xxHash3 pass replaces a Marvin hash per string property over those same bytes. A 12-property, 302-byte tooltip, steady state:
```
old (XOR + Marvin per property) 70.5 ns
xxHash3 (streaming, HashUtility) 27.0 ns
```
`XxHash3.HashToUInt64` one-shot is a further ~7ns faster and produces byte-identical output, but taking it would mean editing `HashUtility.ComputeHash64`, whose values are baked into save files via `AssemblyHandler.GetTypeHash`. Not worth it.
## Second commit: plant old-client property list
Found while tracing the `Hash` read sites. `PlantItem.OldClientPropertyList` called `InitializePropertyList` on every access instead of only when the list was null, and without a `Reset`. Every read appended another copy of every property to the same buffer. `SendOPLPacketTo` and `SendPropertiesTo` both go through the getter, so a pre-7.0.12 client looking at a plant grew the buffer without bound and moved the revision on reads alone.
Now builds once, matching `Item.PropertyList`. `InvalidateProperties` already `Reset`s before rebuilding.
## Testing
`Server.Tests` 869 passed, `UOContent.Tests` 779 passed.
New regression tests, each confirmed failing against the old code first:
- `RepeatedArgument_DoesNotCancelOut` — the reported case
- `PropertyOrder_ChangesHash`, `SwappedArguments_ChangeHash`, `DuplicateProperty_ChangesHash`
- `ShortNumericArguments_ConsecutiveValuesDiffer`, `SmallPropertyBlocks_StayWellDistributed` — short-input avalanche, bounded loosely enough to hold for any seed
- `Hash_StaysWithinTheRevisionMask`, `EmptyList_IsNonZeroAndDistinctFromPopulated` — wire constraints
- `PlantItemPropertyListTests.OldClientPropertyList_BuildsOnceAndIsStableAcrossReads`
---
.../ObjectPropertyListHashTests.cs | 147 ++++++++++++++++++
.../Server/PropertyList/ObjectPropertyList.cs | 28 ++--
.../Plants/PlantItemPropertyListTests.cs | 36 +++++
.../UOContent/Engines/Plants/PlantItem.cs | 10 +-
4 files changed, 205 insertions(+), 16 deletions(-)
create mode 100644 Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs
create mode 100644 Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs
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/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/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs b/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs
new file mode 100644
index 000000000..b8a4f9d4b
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs
@@ -0,0 +1,36 @@
+using Server.Engines.Plants;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class PlantItemPropertyListTests
+{
+ // The getter used to re-initialize without a Reset, appending another copy of every property
+ // per read. SendOPLPacketTo and SendPropertiesTo both go through it, so a pre-7.0.12 client
+ // looking at a plant grew the buffer without bound.
+ [Fact]
+ public void OldClientPropertyList_BuildsOnceAndIsStableAcrossReads()
+ {
+ var plant = new PlantItem();
+
+ try
+ {
+ var first = plant.OldClientPropertyList;
+ var length = first.Buffer.Length;
+ var hash = first.Hash;
+
+ var second = plant.OldClientPropertyList;
+ var third = plant.OldClientPropertyList;
+
+ Assert.Same(first, second);
+ Assert.Same(first, third);
+ Assert.Equal(length, third.Buffer.Length);
+ Assert.Equal(hash, third.Hash);
+ }
+ finally
+ {
+ plant.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs
index f829b0e20..5633db48c 100644
--- a/Projects/UOContent/Engines/Plants/PlantItem.cs
+++ b/Projects/UOContent/Engines/Plants/PlantItem.cs
@@ -73,7 +73,15 @@ public partial class PlantItem : Item, ISecurable
{
get
{
- InitializePropertyList(_oldClientPropertyList ??= new ObjectPropertyList(this));
+ // Build once, like Item.PropertyList. Initializing on every read appended another copy
+ // of every property to the same list. InvalidateProperties rebuilds it, Reset first.
+ if (_oldClientPropertyList == null)
+ {
+ var list = new ObjectPropertyList(this);
+ _oldClientPropertyList = list;
+ InitializePropertyList(list);
+ }
+
return _oldClientPropertyList;
}
}
From 114dbba6e25f0e97e8537e54025d7bfa87c03a39 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Mon, 7 Sep 2026 08:51:10 -0700
Subject: [PATCH 07/21] fix: a stop order silently cancelled a standing follow
order (#2616)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## The bug
Pet is set to follow. It gets attacked and defends itself (the order flips to `Attack`), or the player tells it to `come`. The player then says `stop`. The pet quietly stops following and starts idle-wandering — the player has to re-issue the order to get it back.
`ResolveStop` clears `ControlTarget`, since the transient order that is ending owns it, and then resumes the standing order. Nothing restores a target, so the resumed `Follow` has none. `DoOrderFollow` checks for a target before anything else, finds none on the very next think, says "I have no one to follow" and cancels the order to `None`:
```
follow standing order=Follow target=set persist=Follow cur=0.2 => move=0.3
attacked (defends) order=Attack target=set persist=Follow cur=0.2 => move=0.3
stop issued order=Follow target=null persist=Follow cur=0.2 => move=0.3
next think order=None target=null persist=Follow cur=0.4 => move=0.9
```
Two things go wrong at once:
- `PersistentOrder` still reads `Follow` while `ControlOrder` is `None`, so the pet never recovers on its own.
- The pace falls from the active clock to the passive one. A shard that tunes `SetMoveSpeed(active, passive)` sees a following pet drop from its active move speed to its passive one for no visible reason — which is how this surfaced.
Era-independent. Guard is unaffected: `DoOrderGuard` works off `ControlMaster`, not `ControlTarget`.
## The fix
A standing `Follow` does not always mean "follow the master" — a pet friend can point it elsewhere with `all follow me` / `*follow me` (0x163, 0x16C) or `*follow` plus a target pick. So `SetPersistentOrder` remembers the target the standing order was given, and `ResumePersistentOrder` restores it, falling back to the master only when that target is gone. The field is runtime-only, like `PersistentOrder` itself.
Doing this in `ResumePersistentOrder` rather than `ResolveStop` also covers the Friend/Unfriend/Transfer resumes, where `ControlTarget` points at a third party and the pet would otherwise have resumed its follow on *them*. It matches what `HandleInvalidControlTarget` already does before its own resume.
## Tests
`Stop_WhileAttacking_FallsBackToPersistentFollow` already existed and passed, because it asserted the resumed order without checking for a target or driving a think tick. Three tests added alongside it:
- `Stop_WhileAttacking_ResumedFollowTargetsTheMaster`
- `Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget` — a friend's follow order is not hijacked back to the owner
- `Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink` — the one that catches the cancellation
Each was confirmed to fail without the change (`Expected: Follow, Actual: None`; wrong target). Full suites green: 782 UOContent, 869 Server.
---
.../Tests/Mobiles/AI/PetOrderTests.cs | 53 +++++++++++++++++++
.../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 14 +++++
2 files changed, 67 insertions(+)
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
index e5d7760ed..fbc6a2f81 100644
--- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
@@ -67,6 +67,59 @@ public class PetOrderTests : IDisposable
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
}
+ [Fact]
+ public void Stop_WhileAttacking_ResumedFollowTargetsTheMaster()
+ {
+ var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
+ pet.ControlTarget = master;
+ pet.ControlOrder = OrderType.Follow; // persistent = Follow
+ pet.ControlOrder = OrderType.Attack; // transient
+ Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
+
+ pet.ControlOrder = OrderType.Stop;
+
+ Assert.Equal(OrderType.Follow, pet.ControlOrder);
+ Assert.Equal(master, pet.ControlTarget);
+ }
+
+ [Fact]
+ public void Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget()
+ {
+ var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
+ var friend = new PlayerMobile(World.NewMobile);
+ friend.DefaultMobileInit();
+ friend.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map);
+ _created.Add(friend);
+
+ var victim = new PetTestStub();
+ victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
+ _created.Add(victim);
+
+ pet.ControlTarget = friend; // a pet friend said "all follow me"
+ pet.ControlOrder = OrderType.Follow;
+ pet.ControlTarget = victim; // then "all kill" — the attack order takes the target
+ pet.ControlOrder = OrderType.Attack;
+
+ pet.ControlOrder = OrderType.Stop;
+
+ Assert.Equal(OrderType.Follow, pet.ControlOrder);
+ Assert.Equal(friend, pet.ControlTarget); // still the friend, not the owner
+ }
+
+ [Fact]
+ public void Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink()
+ {
+ var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
+ pet.ControlOrder = OrderType.Follow;
+ pet.ControlOrder = OrderType.Attack;
+ pet.ControlOrder = OrderType.Stop; // resumes Follow
+
+ pet.AIObject.Obey();
+
+ Assert.Equal(OrderType.Follow, pet.ControlOrder); // not dropped to idle
+ Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
+ }
+
[Fact]
public void Stop_WhileFollowing_CancelsToIdleNone()
{
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
index e09424328..3282493b8 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
@@ -28,16 +28,30 @@ public abstract partial class BaseAI
// never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged.
private bool _resolvingOrder;
+ // Who a standing Follow follows: usually the master, but a pet friend can point it
+ // elsewhere ("all follow me"). Runtime-only, like PersistentOrder.
+ private Mobile _persistentFollowTarget;
+
// The controlled-pet wander anchor (Home) is a pure function of the persistent command.
internal void SetPersistentOrder(OrderType order)
{
PersistentOrder = order;
+ _persistentFollowTarget = order == OrderType.Follow ? Mobile.ControlTarget ?? Mobile.ControlMaster : null;
Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location;
}
// Resume the persistent command without re-deriving the persistent order or anchor.
private void ResumePersistentOrder()
{
+ // The ending order owns ControlTarget and leaves it cleared or pointing elsewhere;
+ // without a target DoOrderFollow cancels itself to idle on the next think.
+ if (PersistentOrder == OrderType.Follow)
+ {
+ Mobile.ControlTarget = _persistentFollowTarget?.Deleted == false
+ ? _persistentFollowTarget
+ : Mobile.ControlMaster;
+ }
+
_resolvingOrder = true;
Mobile.ControlOrder = PersistentOrder;
_resolvingOrder = false;
From 4bad0cc9e6c2ac0d2ddf492adc667903f3ba9f71 Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Tue, 8 Sep 2026 19:52:43 -0700
Subject: [PATCH 08/21] refactor(spawners): expose BaseSpawner DTO helpers to
out-of-assembly subclasses (#2619)
## Summary
`BaseSpawner.Dto.cs` exposes `DtoName`, `DtoWalkingRange`, `DtoSpawnPositionMode`, `DtoMaxSpawnAttempts`, `DtoHomeRange` and `BoundsFromHomeRange` as `private protected`, which limits them to subclasses in this assembly. A spawner subclass in another assembly that overrides `ToDto()` to produce its own `SpawnerDto` subtype cannot build the DTO without duplicating that logic.
This widens them to `protected`. No behaviour change; nothing else in UOContent is affected.
## Tests
- `dotnet build` clean.
- An external spawner assembly builds and its test suite (419 tests) passes against this commit.
---
.../UOContent/Engines/Spawners/BaseSpawner.Dto.cs | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs
index 5c0917086..5f7aaf1d0 100644
--- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs
+++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs
@@ -53,7 +53,7 @@ public abstract partial class BaseSpawner
}
/// The square spawn bounds a homeRange radius represents (centered on the location).
- private protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange)
+ protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange)
{
int z;
int depth;
@@ -78,7 +78,7 @@ public abstract partial class BaseSpawner
);
}
- private protected string DtoName
+ protected string DtoName
{
get
{
@@ -91,17 +91,17 @@ public abstract partial class BaseSpawner
// MaxDelay/Team/SpawnLocationIsHome match their property and are referenced directly in ToDto.)
// Raw field; the public WalkingRange is computed (falls back to HomeRange).
- private protected int DtoWalkingRange => _walkingRange;
+ protected int DtoWalkingRange => _walkingRange;
// Abandoned is a transient runtime state, not persisted -> map to Automatic (omitted).
- private protected SpawnPositionMode DtoSpawnPositionMode =>
+ protected SpawnPositionMode DtoSpawnPositionMode =>
_spawnPositionMode == SpawnPositionMode.Abandoned ? SpawnPositionMode.Automatic : _spawnPositionMode;
// Runtime treats 0 as DefaultMaxSpawnAttempts -> map the default to 0 (omitted).
- private protected int DtoMaxSpawnAttempts => _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts;
+ protected int DtoMaxSpawnAttempts => _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts;
// The radius if SpawnBounds is exactly what it reconstructs (lossless square); otherwise -1.
- private protected int DtoHomeRange
+ protected int DtoHomeRange
{
get
{
From 2d38ed7c6a6743d23049ed6bda38baec9d22ddd3 Mon Sep 17 00:00:00 2001
From: Robert Dickey
Date: Wed, 9 Sep 2026 00:24:42 -0500
Subject: [PATCH 09/21] fix: send pre-SE bulk order cooldown message to the
player (#2620)
When a player selects Bulk Order Info while a cooldown is active on a pre-SE server, the vendor sends localized message 1049039 to itself instead of the requesting player. The player therefore receives no cooldown response.
Change the recipient from `vendor` to `from`, matching the SE branch. Cooldown durations and bulk-order eligibility remain unchanged.
Validation: This one-line fix compiled successfully in our customized 0.15.6.145 server build with zero warnings or errors. The current upstream main branch was not built locally.
---
Projects/UOContent/Mobiles/Vendors/BaseVendor.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
index 135dd5ca4..178186ee0 100644
--- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
+++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
@@ -1409,7 +1409,7 @@ namespace Server.Mobiles
else
{
// An offer may be available in about ~1_hours~ hours.
- vendor.SayTo(vendor, 1049039, $"{Math.Ceiling(totalSeconds / 3600):F0}");
+ vendor.SayTo(from, 1049039, $"{Math.Ceiling(totalSeconds / 3600):F0}");
}
vendor.SpeechHue = oldSpeechHue;
From 865a8cf2185829042f20b51e06523172caa2d378 Mon Sep 17 00:00:00 2001
From: Robert Dickey
Date: Thu, 10 Sep 2026 01:07:32 -0500
Subject: [PATCH 10/21] fix: sell Bushido and Ninjitsu books through Tokuno
scribes (#2622)
Tokuno scribes currently load only `SBScribe`, so the existing Zento scribe does not offer the Book of Bushido or Book of Ninjitsu. Add the existing `SBSamurai` and `SBNinja` inventories when `Core.SE && IsTokunoVendor`.
This reuses the existing book prices and default stock quantities. It does not change NPC placement, trainer behavior, or inventories outside Tokuno.
### Sources and reasoning
- The [official UO Ninjitsu guide](https://uo.com/wiki/ultima-online-wiki/skills/ninjitsu/) identifies the scribe at Rokuon Cultural Center in Zento as a seller of the Book of Ninjitsu.
- [UOGuide: Bushido](https://www.uoguide.com/Bushido), documenting standard UO, explicitly lists Tokuno Islands scribe NPCs as a source for the Book of Bushido. This supports adding both book inventories to Tokuno scribes.
- Documentation distinction: the [official UO Bushido guide](https://uo.com/wiki/ultima-online-wiki/skills/bushido/) describes the Zento seller as a samurai trainer, rather than a scribe. The Bushido scribe change follows the explicit UOGuide description. These references do not independently establish the exact historical introduction date or original prices.
### Validation
- Built the change in a customized ModernUO 0.15.6.145 deployment: Release, linux-x64, zero warnings or errors.
- In-game with Mondain's Legacy enabled: both books appeared on the existing Zento scribe, and a player successfully purchased both.
- Reviewed the expansion/location guard for pre-SE and non-Tokuno behavior; those cases were not gameplay-tested. This exact patch has not been built against current upstream main.
---
Projects/UOContent/Mobiles/Vendors/BaseVendor.cs | 2 ++
Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs | 6 ++++++
2 files changed, 8 insertions(+)
diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
index 178186ee0..a6467746b 100644
--- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
+++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
@@ -121,6 +121,8 @@ namespace Server.Mobiles
public virtual bool IsTokunoVendor => Map == Map.Tokuno;
+ public virtual bool IsTerMurVendor => Map == Map.TerMur;
+
public virtual VendorShoeType ShoeType => VendorShoeType.Shoes;
public DateTime LastRestock { get; set; }
diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs
index 0c072141c..27430a302 100644
--- a/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs
+++ b/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs
@@ -29,6 +29,12 @@ namespace Server.Mobiles
public override void InitSBInfo()
{
m_SBInfos.Add(new SBScribe());
+
+ if (Core.SE && IsTokunoVendor || Core.SA && IsTerMurVendor)
+ {
+ m_SBInfos.Add(new SBSamurai());
+ m_SBInfos.Add(new SBNinja());
+ }
}
public override void InitOutfit()
From 535a0989968258b92404fbd80a7581258af3093b Mon Sep 17 00:00:00 2001
From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com>
Date: Thu, 10 Sep 2026 19:01:59 -0700
Subject: [PATCH 11/21] refactor: compile where/sort/distinct and Advanced
Search through expression trees (#2625)
## Summary
`where`, `sort by`, `distinct` and the Advanced Search property test now compile through expression trees instead of the hand-rolled IL in `Emitter.cs`. The emitter and its three `Reflection.Emit` compilers were RunUO-era code from before expression trees existed; they were the only way to avoid per-object reflection at the time, and they are not any more.
Net: ~1,900 lines of IL bookkeeping deleted, one comparison engine instead of two, faster per object, cheaper to compile, collectible, and `Nullable` properties work.
## Why
- **Bugs hid in the IL.** Equality on a type with value semantics but no `IComparable` (`TextDefinition`) was a raw `ceq`, so `where Message = 1060847` never matched. A chained binding (`Message.Number`) dereferenced every link unguarded, so the first swept object with a null intermediate killed the sweep with an NRE. A constant narrower or unsigned than `int` threw before compiling. A struct with no `CompareTo` reached `ceq` on two unboxed values, which is invalid IL. Every dynamic assembly was `Run`, so each `[global where` grew the process for good.
- **Advanced Search had its own engine.** Per entity and per leaf it re-split the expression, scanned the runtime type's properties by name, read the value by reflection, re-parsed the right-hand side and dispatched on type through ~300 lines of `CompareValues`. Same job, second implementation, second set of bugs.
## What changed
- `ICondition.Compile(MethodEmitter)` becomes `ICondition.Build(ParameterExpression)` returning an `Expression`. `ConditionalCompiler` assembles a `Func