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.
This commit is contained in:
Kamron Batman 2026-09-06 09:43:44 -07:00 • committed by GitHub
parent a2c232f4a8
commit 84153fba58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 223 additions and 214 deletions

View file

@ -21,7 +21,11 @@ namespace Server.Mobiles
public int DevourGoal
{
get => IsParagon ? _devourGoal + 25 : _devourGoal;
set => _devourGoal = value;
set
{
_devourGoal = value;
this.MarkDirty();
}
}
[Constructible]

View file

@ -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)]

View file

@ -199,7 +199,7 @@ namespace Server.Mobiles
VisibilityList = new List<Mobile>();
PermaFlags = new List<Mobile>();
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<Mobile>();
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;

View file

@ -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<GuardedRegion>()?.CheckVendorAccess(this, from) != false ||
Region != from.Region && from.Region.GetRegion<GuardedRegion>()?.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<ContextMenuEntry> list)
{
if (from.Alive && IsActiveVendor)

View file

@ -188,7 +188,7 @@ public partial class PlayerVendor : Mobile
for (var i = 0; i < count; i++)
{
var item = reader.ReadEntity<Item>();
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();

View file

@ -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 ?? "";