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

@ -504,16 +504,15 @@ public sealed class MobileIncoming : Packet
var eq = beheld.Items;
var count = eq.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
var hairItemId = beheld.HairItemID;
var facialHairItemId = beheld.FacialHairItemID;
if (hair != null)
if (hairItemId > 0)
{
count++;
}
if (facialHair != null)
if (facialHairItemId > 0)
{
count++;
}
@ -573,19 +572,19 @@ public sealed class MobileIncoming : Packet
}
}
if (hair?.ItemId > 0)
if (hairItemId > 0)
{
if (m_DupedLayers![(int)Layer.Hair] != m_Version)
{
m_DupedLayers[(int)Layer.Hair] = m_Version;
hue = hair.Hue;
hue = beheld.HairHue;
if (beheld.SolidHueOverride >= 0)
{
hue = beheld.SolidHueOverride;
}
var itemID = hair.ItemId & itemIdMask;
var itemID = hairItemId & itemIdMask;
var writeHue = newPacket || hue != 0;
if (!newPacket && writeHue)
@ -593,7 +592,7 @@ public sealed class MobileIncoming : Packet
itemID |= 0x8000;
}
Stream.Write(hair.VirtualSerial);
Stream.Write(beheld.HairSerial);
Stream.Write((ushort)itemID);
Stream.Write((byte)Layer.Hair);
@ -604,19 +603,19 @@ public sealed class MobileIncoming : Packet
}
}
if (facialHair?.ItemId > 0)
if (facialHairItemId > 0)
{
if (m_DupedLayers![(int)Layer.FacialHair] != m_Version)
{
m_DupedLayers[(int)Layer.FacialHair] = m_Version;
hue = facialHair.Hue;
hue = beheld.FacialHairHue;
if (beheld.SolidHueOverride >= 0)
{
hue = beheld.SolidHueOverride;
}
var itemID = facialHair.ItemId & itemIdMask;
var itemID = facialHairItemId & itemIdMask;
var writeHue = newPacket || hue != 0;
if (!newPacket && writeHue)
@ -624,7 +623,7 @@ public sealed class MobileIncoming : Packet
itemID |= 0x8000;
}
Stream.Write(facialHair.VirtualSerial);
Stream.Write(beheld.FacialHairSerial);
Stream.Write((ushort)itemID);
Stream.Write((byte)Layer.FacialHair);

View file

@ -18,7 +18,7 @@ public class VirtualHairPacketTests
var expected = new HairEquipUpdate(m).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendHairEquipUpdatePacket(m, (uint)m.Hair.VirtualSerial, m.Hair.ItemId, m.Hair.Hue, Layer.Hair);
ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
@ -33,9 +33,47 @@ public class VirtualHairPacketTests
var expected = new RemoveHair(m).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendRemoveHairPacket((uint) m.Hair.VirtualSerial);
ns.SendRemoveHairPacket((uint)m.HairSerial);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void RemoveHairUsesEquippedSerial()
{
var m = new Mobile((Serial)0x1025u);
m.DefaultMobileInit();
m.HairItemID = 0x2000;
m.HairHue = 0x1000;
var equippedSerial = m.HairSerial;
Assert.NotEqual(Serial.Zero, equippedSerial);
m.HairItemID = 0; // remove
// Serial must survive removal so the client can remove the correct entity.
Assert.Equal(equippedSerial, m.HairSerial);
// Hue is cleared with the item id (matches the legacy object-nulling behavior).
Assert.Equal(0, m.HairHue);
}
[Fact]
public void RemoveFacialHairUsesEquippedSerial()
{
var m = new Mobile((Serial)0x1026u);
m.DefaultMobileInit();
m.FacialHairItemID = 0x2040;
m.FacialHairHue = 0x1000;
var equippedSerial = m.FacialHairSerial;
Assert.NotEqual(Serial.Zero, equippedSerial);
m.FacialHairItemID = 0; // remove
// Serial must survive removal so the client can remove the correct entity.
Assert.Equal(equippedSerial, m.FacialHairSerial);
// Hue is cleared with the item id (matches the legacy object-nulling behavior).
Assert.Equal(0, m.FacialHairHue);
}
}

View file

@ -7,7 +7,7 @@ public sealed class HairEquipUpdate : Packet
{
var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.HairHue;
Stream.Write(parent.Hair.VirtualSerial);
Stream.Write(parent.HairSerial);
Stream.Write((short)parent.HairItemID);
Stream.Write((byte)0);
Stream.Write((byte)Layer.Hair);
@ -23,7 +23,7 @@ public sealed class FacialHairEquipUpdate : Packet
{
var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.FacialHairHue;
Stream.Write(parent.FacialHair.VirtualSerial);
Stream.Write(parent.FacialHairSerial);
Stream.Write((short)parent.FacialHairItemID);
Stream.Write((byte)0);
Stream.Write((byte)Layer.FacialHair);
@ -37,7 +37,7 @@ public sealed class RemoveHair : Packet
public RemoveHair(Mobile parent)
: base(0x1D, 5)
{
Stream.Write(parent.Hair.VirtualSerial);
Stream.Write(parent.HairSerial);
}
}
@ -46,6 +46,6 @@ public sealed class RemoveFacialHair : Packet
public RemoveFacialHair(Mobile parent)
: base(0x1D, 5)
{
Stream.Write(parent.FacialHair.VirtualSerial);
Stream.Write(parent.FacialHairSerial);
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Buffers;
using ModernUO.Serialization;
using Server.Network;
namespace Server;
@ -64,34 +63,3 @@ public static class OutgoingVirtualHairPackets
writer.Write(hairSerial);
}
}
[SerializationGenerator(0, false)]
public partial class VirtualHairInfo
{
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _itemId;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _hue;
public VirtualHairInfo() : this(0)
{
}
public VirtualHairInfo(int itemid, int hue = 0)
{
_itemId = itemid;
_hue = hue;
VirtualSerial = World.NewVirtual;
}
[AfterDeserialization]
private void AfterDeserialization()
{
VirtualSerial = World.NewVirtual;
}
public Serial VirtualSerial { get; private set; }
}

View file

@ -1,22 +0,0 @@
{
"version": 0,
"type": "Server.VirtualHairInfo",
"properties": [
{
"name": "ItemId",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Hue",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
}
]
}

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;

View file

@ -674,7 +674,7 @@ public static class OutgoingMobilePackets
itemID |= 0x8000;
}
writer.Write(beheld.Hair.VirtualSerial);
writer.Write(beheld.HairSerial);
writer.Write((ushort)itemID);
writer.Write((byte)Layer.Hair);
@ -697,7 +697,7 @@ public static class OutgoingMobilePackets
itemID |= 0x8000;
}
writer.Write(beheld.FacialHair.VirtualSerial);
writer.Write(beheld.FacialHairSerial);
writer.Write((ushort)itemID);
writer.Write((byte)Layer.FacialHair);

View file

@ -0,0 +1,59 @@
using System;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
// Verifies the migration read type LegacyHairInfo consumes the exact legacy on-disk
// VirtualHairInfo block the way the generated Corpse migration does:
// [bool present] then, if present, [int version][int itemId][int hue].
// Dropping/over-reading these bytes would trip the loader's exact-length validation,
// so full consumption (no leftover bytes) is the load-bearing assertion.
public class CorpseHairMigrationTests
{
private static byte[] WriteLegacyHairTail(bool present, int itemId, int hue)
{
var writer = new BufferWriter(true);
writer.Write(present);
if (present)
{
writer.Write(0); // legacy VirtualHairInfo serialization version
writer.Write(itemId);
writer.Write(hue);
}
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
return buffer;
}
[Theory]
[InlineData(0x203B, 1102)]
[InlineData(0x2049, 0)]
public void LegacyHairTail_Present_RoundTrips(int itemId, int hue)
{
var buffer = WriteLegacyHairTail(true, itemId, hue);
var reader = new BufferReader(buffer);
Assert.True(reader.ReadBool());
var hair = new LegacyHairInfo();
hair.Deserialize(reader);
Assert.Equal(itemId, hair.ItemId);
Assert.Equal(hue, hair.Hue);
// The entire block must be consumed - the loader validates exact byte length.
Assert.Equal(buffer.Length, reader.Position);
}
[Fact]
public void LegacyHairTail_Absent_ConsumesOnlyPresenceBool()
{
var buffer = WriteLegacyHairTail(false, 0, 0);
var reader = new BufferReader(buffer);
Assert.False(reader.ReadBool());
// Absent case: nothing else to read, and the presence bool was the only byte.
Assert.Equal(buffer.Length, reader.Position);
}
}

View file

@ -10,15 +10,13 @@ public sealed class CorpseEquip : Packet
var list = beheld.EquipItems;
var count = list.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
if (hair != null)
if (beheld.HairItemId > 0)
{
count++;
}
if (facialHair != null)
if (beheld.FacialHairItemId > 0)
{
count++;
}
@ -38,16 +36,16 @@ public sealed class CorpseEquip : Packet
}
}
if (hair?.ItemId > 0)
if (beheld.HairItemId > 0)
{
Stream.Write((byte)(Layer.Hair + 1));
Stream.Write(hair.VirtualSerial);
Stream.Write(beheld.HairSerial);
}
if (facialHair?.ItemId > 0)
if (beheld.FacialHairItemId > 0)
{
Stream.Write((byte)(Layer.FacialHair + 1));
Stream.Write(facialHair.VirtualSerial);
Stream.Write(beheld.FacialHairSerial);
}
Stream.Write((byte)Layer.Invalid);
@ -61,15 +59,13 @@ public sealed class CorpseContent : Packet
{
var items = beheld.EquipItems;
var count = items.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
if (hair != null)
if (beheld.HairItemId > 0)
{
count++;
}
if (facialHair != null)
if (beheld.FacialHairItemId > 0)
{
count++;
}
@ -101,30 +97,30 @@ public sealed class CorpseContent : Packet
}
}
if (hair?.ItemId > 0)
if (beheld.HairItemId > 0)
{
Stream.Write(hair.VirtualSerial);
Stream.Write((ushort)hair.ItemId);
Stream.Write(beheld.HairSerial);
Stream.Write((ushort)beheld.HairItemId);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)1);
Stream.Write((short)0);
Stream.Write((short)0);
Stream.Write(beheld.Serial);
Stream.Write((ushort)hair.Hue);
Stream.Write((ushort)beheld.HairHue);
++written;
}
if (facialHair?.ItemId > 0)
if (beheld.FacialHairItemId > 0)
{
Stream.Write(facialHair.VirtualSerial);
Stream.Write((ushort)facialHair.ItemId);
Stream.Write(beheld.FacialHairSerial);
Stream.Write((ushort)beheld.FacialHairItemId);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)1);
Stream.Write((short)0);
Stream.Write((short)0);
Stream.Write(beheld.Serial);
Stream.Write((ushort)facialHair.Hue);
Stream.Write((ushort)beheld.FacialHairHue);
++written;
}
@ -141,15 +137,13 @@ public sealed class CorpseContent6017 : Packet
{
var items = beheld.EquipItems;
var count = items.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
if (hair != null)
if (beheld.HairItemId > 0)
{
count++;
}
if (facialHair != null)
if (beheld.FacialHairItemId > 0)
{
count++;
}
@ -182,32 +176,32 @@ public sealed class CorpseContent6017 : Packet
}
}
if (hair?.ItemId > 0)
if (beheld.HairItemId > 0)
{
Stream.Write(hair.VirtualSerial);
Stream.Write((ushort)hair.ItemId);
Stream.Write(beheld.HairSerial);
Stream.Write((ushort)beheld.HairItemId);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)1);
Stream.Write((short)0);
Stream.Write((short)0);
Stream.Write((byte)0); // Grid Location?
Stream.Write(beheld.Serial);
Stream.Write((ushort)hair.Hue);
Stream.Write((ushort)beheld.HairHue);
++written;
}
if (facialHair?.ItemId > 0)
if (beheld.FacialHairItemId > 0)
{
Stream.Write(facialHair.VirtualSerial);
Stream.Write((ushort)facialHair.ItemId);
Stream.Write(beheld.FacialHairSerial);
Stream.Write((ushort)beheld.FacialHairItemId);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)1);
Stream.Write((short)0);
Stream.Write((short)0);
Stream.Write((byte)0); // Grid Location?
Stream.Write(beheld.Serial);
Stream.Write((ushort)facialHair.Hue);
Stream.Write((ushort)beheld.FacialHairHue);
++written;
}

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": [
""
]
}
]
}