refactor: decompose mobile/corpse hair, delete VirtualHairInfo, fix removal serial (#2462) (#2463)

Fixes #2462

## Summary
Removes the per-object `VirtualHairInfo` heap wrapper for mobile/corpse hair. Hair is now stored **inline** on `Mobile` and `Corpse` as `int _hairItemId` / `int _hairHue` plus a lazily-allocated, **non-serialized** ephemeral `Serial _hairSerial` (in the high virtual-serial range) — and likewise for facial hair. The `VirtualHairInfo` class is deleted, with a **lossless** save migration.

This delivers three things:
1. **Fixes a hair-removal bug.** `Delta(MobileDelta.Hair)` is deferred (it enqueues; `ProcessDeltaQueue` runs later in the tick). The old `HairItemID = 0` setter nulled `_hair` *immediately*, so by the time `ProcessDelta` built the remove packet the equipped virtual serial was already gone — the old `??=` code then re-materialized a **fresh** serial (≠ the equipped one), so clients never removed the right entity, and it left a phantom ItemId-0 object behind. The serial now lives on the entity and **persists across removal**, so remove packets carry the correct serial.
2. **Lightens the entity.** No heap hair object; bald mobiles allocate nothing (the serial is minted lazily only when hair is present). This was the original reason `HairItemID`/`HairHue` exist.
3. **Removes `VirtualHairInfo` entirely**, keeping the high-range virtual serial behavior.

## How
- **Mobile** (manual serialization): inline `_hairItemId/_hairHue/_hairSerial` (+facial); lazy `HairSerial`/`FacialHairSerial`; `ProcessDelta` reads those. Serialization **v36 → v37** — the v30-v37 deserialize is unified, reading the legacy per-hair `VirtualHairInfo` version int only when `version < 37`. Setting item id to 0 clears the hue (matching the old object-nulling) while retaining the serial.
- **Corpse** (codegen serialization): decomposed to `[SerializableField] int _hairItemId/_hairHue` (+facial) + ephemeral serial; **v16 → v17** with `MigrateFrom(V16Content)`.
- **Lossless migration:** the loader validates exact byte length, and the old corpse hair is a presence-bool-gated block, so a tiny **migration-only** `LegacyHairInfo` reader (no runtime role) consumes the legacy `[bool][int ver][int itemId][int hue]` bytes. Frozen `Corpse.v14/v15/v16.json` are retyped to it; `v17.json` describes the new int fields.
- All consumers updated to discrete accessors: `OutgoingMobilePackets`, `CorpsePackets`, corpse subclasses (`MilitiaFighterCorpse`, `SchmendrickApprenticeCorpse`), and the packet test mirrors.
- `VirtualHair.cs` renamed to `OutgoingVirtualHairPackets.cs` (the only type left in it after `VirtualHairInfo` was removed).

## Test Plan
- [x] Full solution build: **0 warnings, 0 errors** (`TreatWarningsAsErrors`).
- [x] `Server.Tests`: **708 passed** (incl. new `RemoveHairUsesEquippedSerial` / `RemoveFacialHairUsesEquippedSerial` proving the serial survives removal + hue clears).
- [x] `UOContent.Tests` corpse/hair: **6 passed** (incl. `CorpseHairMigrationTests` asserting the legacy hair bytes are consumed exactly — the loader's length invariant).
- [x] Generated migration code inspected: V14/V15/V16 readers consume the legacy block byte-for-byte; serial never written to disk.

## Upgrade notes
- Old Mobile (v30–v36) and Corpse (v13–v16) saves load losslessly.
- Minor cosmetic-only change: `SchmendrickApprenticeCorpse` hair/facial-hair RNG draws shift order within each pair (same draw count); irrelevant for a quest NPC corpse.
This commit is contained in:
Kamron Batman 2026-06-06 14:33:28 -07:00 • committed by GitHub
parent 978f314f0e
commit a8acfa31f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 525 additions and 210 deletions

View file

@ -10,13 +10,18 @@ namespace Server.Engines.Quests.Haven;
[SerializationGenerator(0, false)]
public partial class SchmendrickApprenticeCorpse : Corpse
{
private static int _hairHue;
[SerializableField(0, setter: "private")]
private Lantern _lantern;
[Constructible]
public SchmendrickApprenticeCorpse() : base(GetOwner(), GetHair(), GetFacialHair(), GetEquipment())
public SchmendrickApprenticeCorpse() : base(
GetOwner(),
Race.Human.RandomHair(false),
Race.Human.RandomHairHue(),
Race.Human.RandomFacialHair(false),
Race.Human.RandomHairHue(),
GetEquipment()
)
{
Direction = Direction.West;
@ -53,19 +58,6 @@ public partial class SchmendrickApprenticeCorpse : Corpse
new Spellbook()
];
private static VirtualHairInfo GetHair()
{
_hairHue = Race.Human.RandomHairHue();
return new VirtualHairInfo(Race.Human.RandomHair(false), _hairHue);
}
private static VirtualHairInfo GetFacialHair()
{
_hairHue = Race.Human.RandomHairHue();
return new VirtualHairInfo(Race.Human.RandomFacialHair(false), _hairHue);
}
public override void AddNameProperty(IPropertyList list)
{
if (ItemID == 0x2006) // Corpse form

View file

@ -84,10 +84,14 @@ public partial class MilitiaFighter : BaseCreature
[SerializationGenerator(0, false)]
public partial class MilitiaFighterCorpse : Corpse
{
public MilitiaFighterCorpse(Mobile owner, VirtualHairInfo hair, VirtualHairInfo facialhair, List<Item> equipItems) : base(
public MilitiaFighterCorpse(
Mobile owner, int hairItemId, int hairHue, int facialHairItemId, int facialHairHue, List<Item> equipItems
) : base(
owner,
hair,
facialhair,
hairItemId,
hairHue,
facialHairItemId,
facialHairHue,
equipItems
)
{

View file

@ -86,7 +86,7 @@ public enum CorpseFlag
OwnerWasAnimatedDead = 0x00000800
}
[SerializationGenerator(16, false)]
[SerializationGenerator(17, false)]
public partial class Corpse : Container, ICarvable
{
public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0);
@ -150,24 +150,61 @@ public partial class Corpse : Container, ICarvable
[SerializedCommandProperty(AccessLevel.GameMaster)]
private List<Item> _equipItems;
[CanBeNull]
[SerializableField(13, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private VirtualHairInfo _hair;
private int _hairItemId;
[CanBeNull]
[SerializableField(14, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private VirtualHairInfo _facialHair;
private int _hairHue;
[SerializableField(15, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _facialHairItemId;
[SerializableField(16, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _facialHairHue;
private Serial _hairSerial;
private Serial _facialHairSerial;
public Serial HairSerial
{
get
{
if (_hairItemId > 0 && _hairSerial == Serial.Zero)
{
_hairSerial = World.NewVirtual;
}
return _hairSerial;
}
}
public Serial FacialHairSerial
{
get
{
if (_facialHairItemId > 0 && _facialHairSerial == Serial.Zero)
{
_facialHairSerial = World.NewVirtual;
}
return _facialHairSerial;
}
}
// Why was this public?
// public override bool IsPublicContainer => true;
public Corpse(Mobile owner, List<Item> equipItems) : this(owner, null, null, equipItems)
public Corpse(Mobile owner, List<Item> equipItems) : this(owner, 0, 0, 0, 0, equipItems)
{
}
public Corpse(Mobile owner, VirtualHairInfo hair, VirtualHairInfo facialHair, List<Item> equipItems)
public Corpse(
Mobile owner, int hairItemId, int hairHue, int facialHairItemId, int facialHairHue, List<Item> equipItems
)
: base(0x2006)
{
// To suppress console warnings, stackable must be true
@ -202,14 +239,16 @@ public partial class Corpse : Container, ICarvable
SetFlag(CorpseFlag.OwnerWasAnimatedDead, ownerBaseCreature.IsAnimatedDead);
}
if (hair?.ItemId > 0)
if (hairItemId > 0)
{
_hair = new VirtualHairInfo(hair.ItemId, hair.Hue);
_hairItemId = hairItemId;
_hairHue = hairHue;
}
if (facialHair?.ItemId > 0)
if (facialHairItemId > 0)
{
_facialHair = new VirtualHairInfo(facialHair.ItemId, facialHair.Hue);
_facialHairItemId = facialHairItemId;
_facialHairHue = facialHairHue;
}
// This corpse does not turn to bones if: the owner is not a player
@ -279,6 +318,36 @@ public partial class Corpse : Container, ICarvable
DevourCorpse();
}
// Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue)
private void MigrateFrom(V16Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Folded Murderer bool field into CorpseFlag.Murderer
private void MigrateFrom(V15Content content)
{
@ -300,8 +369,17 @@ public partial class Corpse : Container, ICarvable
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
_hair = content.Hair;
_facialHair = content.FacialHair;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Replaced int Kills snapshot with bool Murderer snapshot
@ -325,8 +403,17 @@ public partial class Corpse : Container, ICarvable
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
_hair = content.Hair;
_facialHair = content.FacialHair;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Added corpse hair and corpse facial hair
@ -634,8 +721,22 @@ public partial class Corpse : Container, ICarvable
public static Container Mobile_CreateCorpseHandler(Mobile owner, List<Item> initialContent, List<Item> equipItems)
{
var c = owner is MilitiaFighter
? new MilitiaFighterCorpse(owner, owner.Hair, owner.FacialHair, equipItems)
: new Corpse(owner, owner.Hair, owner.FacialHair, equipItems);
? new MilitiaFighterCorpse(
owner,
owner.HairItemID,
owner.HairHue,
owner.FacialHairItemID,
owner.FacialHairHue,
equipItems
)
: new Corpse(
owner,
owner.HairItemID,
owner.HairHue,
owner.FacialHairItemID,
owner.FacialHairHue,
equipItems
);
owner.Corpse = c;

View file

@ -49,16 +49,16 @@ public static class CorpsePackets
if (beheld.Owner != null)
{
if (beheld.Hair?.ItemId > 0)
if (beheld.HairItemId > 0)
{
writer.Write((byte)(Layer.Hair + 1));
writer.Write(beheld.Hair.VirtualSerial);
writer.Write(beheld.HairSerial);
}
if (beheld.FacialHair?.ItemId > 0)
if (beheld.FacialHairItemId > 0)
{
writer.Write((byte)(Layer.FacialHair + 1));
writer.Write(beheld.FacialHair.VirtualSerial);
writer.Write(beheld.FacialHairSerial);
}
}
@ -76,16 +76,14 @@ public static class CorpsePackets
}
var list = beheld.EquipItems;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
var count = list.Count;
if (hair != null)
if (beheld.HairItemId > 0)
{
count++;
}
if (facialHair != null)
if (beheld.FacialHairItemId > 0)
{
count++;
}
@ -121,10 +119,10 @@ public static class CorpsePackets
if (beheld.Owner != null)
{
if (hair?.ItemId > 0)
if (beheld.HairItemId > 0)
{
writer.Write(hair.VirtualSerial);
writer.Write((ushort)hair.ItemId);
writer.Write(beheld.HairSerial);
writer.Write((ushort)beheld.HairItemId);
writer.Write((byte)0); // signed, itemID offset
writer.Write((ushort)1);
writer.Write(0); // X/Y
@ -133,15 +131,15 @@ public static class CorpsePackets
writer.Write((byte)0); // Grid Location?
}
writer.Write(beheld.Serial);
writer.Write((ushort)hair.Hue);
writer.Write((ushort)beheld.HairHue);
++written;
}
if (facialHair?.ItemId > 0)
if (beheld.FacialHairItemId > 0)
{
writer.Write(facialHair.VirtualSerial);
writer.Write((ushort)facialHair.ItemId);
writer.Write(beheld.FacialHairSerial);
writer.Write((ushort)beheld.FacialHairItemId);
writer.Write((byte)0); // signed, itemID offset
writer.Write((ushort)1);
writer.Write(0); // X/Y
@ -150,7 +148,7 @@ public static class CorpsePackets
writer.Write((byte)0); // Grid Location?
}
writer.Write(beheld.Serial);
writer.Write((ushort)facialHair.Hue);
writer.Write((ushort)beheld.FacialHairHue);
++written;
}

View file

@ -0,0 +1,16 @@
namespace Server.Items;
// Reads the legacy on-disk VirtualHairInfo block ([int version][int itemId][int hue]).
// Used ONLY by Corpse save migration (V14/V15/V16). It has no runtime role.
public sealed class LegacyHairInfo
{
public int ItemId { get; private set; }
public int Hue { get; private set; }
public void Deserialize(IGenericReader reader)
{
reader.ReadInt(); // legacy serialization version
ItemId = reader.ReadInt();
Hue = reader.ReadInt();
}
}

View file

@ -111,7 +111,7 @@
},
{
"name": "Hair",
"type": "Server.VirtualHairInfo",
"type": "Server.Items.LegacyHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",
@ -120,7 +120,7 @@
},
{
"name": "FacialHair",
"type": "Server.VirtualHairInfo",
"type": "Server.Items.LegacyHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",

View file

@ -111,7 +111,7 @@
},
{
"name": "Hair",
"type": "Server.VirtualHairInfo",
"type": "Server.Items.LegacyHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",
@ -120,7 +120,7 @@
},
{
"name": "FacialHair",
"type": "Server.VirtualHairInfo",
"type": "Server.Items.LegacyHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",

View file

@ -103,7 +103,7 @@
},
{
"name": "Hair",
"type": "Server.VirtualHairInfo",
"type": "Server.Items.LegacyHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",
@ -112,7 +112,7 @@
},
{
"name": "FacialHair",
"type": "Server.VirtualHairInfo",
"type": "Server.Items.LegacyHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",

View file

@ -0,0 +1,137 @@
{
"version": 17,
"type": "Server.Items.Corpse",
"properties": [
{
"name": "RestoreEquip",
"type": "System.Collections.Generic.List\u003CServer.Item\u003E",
"rule": "ListMigrationRule",
"ruleArguments": [
"Server.Item",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "Flags",
"type": "Server.Items.CorpseFlag",
"rule": "EnumMigrationRule"
},
{
"name": "TimeOfDeath",
"type": "System.DateTime",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"DeltaTime"
]
},
{
"name": "RestoreTable",
"type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E",
"rule": "DictionaryMigrationRule",
"ruleArguments": [
"Server.Item",
"SerializableInterfaceMigrationRule",
"0",
"Server.Point3D",
"PrimitiveUOTypeMigrationRule",
"1",
"Point3D"
]
},
{
"name": "DecayTimer",
"type": "Server.Timer",
"rule": "TimerMigrationRule",
"ruleArguments": [
"@TimerDrift"
]
},
{
"name": "Looters",
"type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E",
"rule": "HashSetMigrationRule",
"ruleArguments": [
"Server.Mobile",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "Killer",
"type": "Server.Mobile",
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "Aggressors",
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
"rule": "ListMigrationRule",
"ruleArguments": [
"Server.Mobile",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "Owner",
"type": "Server.Mobile",
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "CorpseName",
"type": "string",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "AccessLevel",
"type": "Server.AccessLevel",
"rule": "EnumMigrationRule"
},
{
"name": "Guild",
"type": "Server.Guilds.Guild",
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "EquipItems",
"type": "System.Collections.Generic.List\u003CServer.Item\u003E",
"rule": "ListMigrationRule",
"ruleArguments": [
"Server.Item",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "HairItemId",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "HairHue",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "FacialHairItemId",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "FacialHairHue",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
}
]
}