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

@ -272,8 +272,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
private TimerExecutionToken _expireAggrTimerToken;
private TimerExecutionToken _expireCombatantTimerToken;
private TimerExecutionToken _expireCriminalTimerToken;
private VirtualHairInfo _facialHair;
public VirtualHairInfo FacialHair => _facialHair ??= new VirtualHairInfo(FacialHairItemID, FacialHairHue);
private int _facialHairItemId;
private int _facialHairHue;
private Serial _facialHairSerial;
private int m_Fame, m_Karma;
private bool m_Female, m_Warmode, m_Hidden, m_Blessed, m_Flying;
@ -283,8 +284,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
private BaseGuild m_Guild;
private string m_GuildTitle;
private VirtualHairInfo _hair;
public VirtualHairInfo Hair => _hair ??= new VirtualHairInfo(HairItemID, HairHue);
private int _hairItemId;
private int _hairHue;
private Serial _hairSerial;
private int m_Hits, m_Stam, m_Mana;
@ -2181,20 +2183,16 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
[CommandProperty(AccessLevel.GameMaster)]
public int HairItemID
{
get => _hair?.ItemId ?? 0;
get => _hairItemId;
set
{
if (_hair == null && value > 0)
// An item id of 0 is the "no hair" state; clear the hue with it, but keep
// _hairSerial so a pending removal packet references the equipped serial.
_hairItemId = value < 0 ? 0 : value;
if (_hairItemId == 0)
{
_hair = new VirtualHairInfo(value);
}
else if (value <= 0)
{
_hair = null;
}
else if (_hair != null)
{
_hair.ItemId = value;
_hairHue = 0;
}
Delta(MobileDelta.Hair);
@ -2205,20 +2203,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
[CommandProperty(AccessLevel.GameMaster)]
public int FacialHairItemID
{
get => _facialHair?.ItemId ?? 0;
get => _facialHairItemId;
set
{
if (_facialHair == null && value > 0)
_facialHairItemId = value < 0 ? 0 : value;
if (_facialHairItemId == 0)
{
_facialHair = new VirtualHairInfo(value);
}
else if (value <= 0)
{
_facialHair = null;
}
else if (_facialHair != null)
{
_facialHair.ItemId = value;
_facialHairHue = 0;
}
Delta(MobileDelta.FacialHair);
@ -2229,12 +2221,12 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
[CommandProperty(AccessLevel.GameMaster)]
public int HairHue
{
get => _hair?.Hue ?? 0;
get => _hairHue;
set
{
if (_hair != null)
if (_hairItemId > 0)
{
_hair.Hue = value;
_hairHue = value;
Delta(MobileDelta.Hair);
this.MarkDirty();
}
@ -2244,18 +2236,44 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
[CommandProperty(AccessLevel.GameMaster)]
public int FacialHairHue
{
get => _facialHair?.Hue ?? 0;
get => _facialHairHue;
set
{
if (_facialHair != null)
if (_facialHairItemId > 0)
{
_facialHair.Hue = value;
_facialHairHue = value;
Delta(MobileDelta.FacialHair);
this.MarkDirty();
}
}
}
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;
}
}
public Item ShieldArmor => FindItemOnLayer(Layer.TwoHanded);
public Item NeckArmor => FindItemOnLayer(Layer.Neck);
@ -2296,7 +2314,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual void Serialize(IGenericWriter writer)
{
writer.Write(36); // version
writer.Write(37); // version
writer.WriteDeltaTime(LastStrGain);
writer.WriteDeltaTime(LastIntGain);
@ -2304,12 +2322,12 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
byte hairflag = 0x00;
if (_hair != null)
if (_hairItemId > 0)
{
hairflag |= 0x01;
}
if (_facialHair != null)
if (_facialHairItemId > 0)
{
hairflag |= 0x02;
}
@ -2318,12 +2336,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
if ((hairflag & 0x01) != 0)
{
_hair?.Serialize(writer);
writer.Write(_hairItemId);
writer.Write(_hairHue);
}
if ((hairflag & 0x02) != 0)
{
_facialHair?.Serialize(writer);
writer.Write(_facialHairItemId);
writer.Write(_facialHairHue);
}
writer.Write(Race);
@ -2458,8 +2478,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_Map?.OnLeave(this);
m_Map = null;
_hair = null;
_facialHair = null;
m_MountItem = null;
World.RemoveEntity(this);
@ -2734,14 +2752,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
sendFacialHair = true;
}
var hairSerial = Hair.VirtualSerial;
var hairSerial = HairSerial;
var hairLength = removeHair
? OutgoingVirtualHairPackets.RemovePacketLength
: OutgoingVirtualHairPackets.EquipUpdatePacketLength;
var hairPacket = stackalloc byte[hairLength].InitializePacket();
var facialHairSerial = FacialHair.VirtualSerial;
var facialHairSerial = FacialHairSerial;
var facialHairLength = removeFacialHair
? OutgoingVirtualHairPackets.RemovePacketLength
: OutgoingVirtualHairPackets.EquipUpdatePacketLength;
@ -6111,6 +6129,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
switch (version)
{
case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object)
case 36: // Moved virtues to VirtueSystem
case 35: // Moved short term murders to PlayerMurderSystem
case 34: // Moved Stabled to PlayerMobile
@ -6126,18 +6145,30 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
}
case 30:
{
// Before v37 each hair was a VirtualHairInfo whose Serialize wrote a
// leading version int ahead of the item id and hue.
var hairflag = reader.ReadByte();
if ((hairflag & 0x01) != 0)
{
_hair = new VirtualHairInfo();
_hair.Deserialize(reader);
if (version < 37)
{
reader.ReadInt(); // legacy VirtualHairInfo version
}
_hairItemId = reader.ReadInt();
_hairHue = reader.ReadInt();
}
if ((hairflag & 0x02) != 0)
{
_facialHair = new VirtualHairInfo();
_facialHair.Deserialize(reader);
if (version < 37)
{
reader.ReadInt(); // legacy VirtualHairInfo version
}
_facialHairItemId = reader.ReadInt();
_facialHairHue = reader.ReadInt();
}
goto case 29;