fix: Hair and facial hair "teleporting" when mobile dies several times (#1901)

### Summary

* Added World.NewVirtual for creating virtual serial numbers
* Reserved range 0x7EEEEEEE to 0x7FFFFFFF for virtual serials
* Hair and Facial hair (for mobiles) now use virtual serials instead of FakeSerial() functions
* Consolidated virtual hair to a single `VirtualHairInfo` class.

Corpse hair and facial hair now persists across save/load and hair and facial hair no longer teleport to newest corpse.
This commit is contained in:
mdodkins 2024-08-08 04:10:23 +01:00 • committed by GitHub
parent 883d873c1c
commit 91e37fb8d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 548 additions and 357 deletions

View file

@ -504,13 +504,16 @@ namespace Server.Tests.Network
var eq = beheld.Items; var eq = beheld.Items;
var count = eq.Count; var count = eq.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
if (beheld.HairItemID > 0)
if (hair != null)
{ {
count++; count++;
} }
if (beheld.FacialHairItemID > 0) if (facialHair != null)
{ {
count++; count++;
} }
@ -570,19 +573,19 @@ namespace Server.Tests.Network
} }
} }
if (beheld.HairItemID > 0) if (hair?.ItemId > 0)
{ {
if (m_DupedLayers![(int)Layer.Hair] != m_Version) if (m_DupedLayers![(int)Layer.Hair] != m_Version)
{ {
m_DupedLayers[(int)Layer.Hair] = m_Version; m_DupedLayers[(int)Layer.Hair] = m_Version;
hue = beheld.HairHue; hue = hair.Hue;
if (beheld.SolidHueOverride >= 0) if (beheld.SolidHueOverride >= 0)
{ {
hue = beheld.SolidHueOverride; hue = beheld.SolidHueOverride;
} }
var itemID = beheld.HairItemID & itemIdMask; var itemID = hair.ItemId & itemIdMask;
var writeHue = newPacket || hue != 0; var writeHue = newPacket || hue != 0;
if (!newPacket && writeHue) if (!newPacket && writeHue)
@ -590,7 +593,7 @@ namespace Server.Tests.Network
itemID |= 0x8000; itemID |= 0x8000;
} }
Stream.Write(HairInfo.FakeSerial(beheld.Serial)); Stream.Write(hair.VirtualSerial);
Stream.Write((ushort)itemID); Stream.Write((ushort)itemID);
Stream.Write((byte)Layer.Hair); Stream.Write((byte)Layer.Hair);
@ -601,19 +604,19 @@ namespace Server.Tests.Network
} }
} }
if (beheld.FacialHairItemID > 0) if (facialHair?.ItemId > 0)
{ {
if (m_DupedLayers![(int)Layer.FacialHair] != m_Version) if (m_DupedLayers![(int)Layer.FacialHair] != m_Version)
{ {
m_DupedLayers[(int)Layer.FacialHair] = m_Version; m_DupedLayers[(int)Layer.FacialHair] = m_Version;
hue = beheld.FacialHairHue; hue = facialHair.Hue;
if (beheld.SolidHueOverride >= 0) if (beheld.SolidHueOverride >= 0)
{ {
hue = beheld.SolidHueOverride; hue = beheld.SolidHueOverride;
} }
var itemID = beheld.FacialHairItemID & itemIdMask; var itemID = facialHair.ItemId & itemIdMask;
var writeHue = newPacket || hue != 0; var writeHue = newPacket || hue != 0;
if (!newPacket && writeHue) if (!newPacket && writeHue)
@ -621,7 +624,7 @@ namespace Server.Tests.Network
itemID |= 0x8000; itemID |= 0x8000;
} }
Stream.Write(FacialHairInfo.FakeSerial(beheld.Serial)); Stream.Write(facialHair.VirtualSerial);
Stream.Write((ushort)itemID); Stream.Write((ushort)itemID);
Stream.Write((byte)Layer.FacialHair); Stream.Write((byte)Layer.FacialHair);

View file

@ -17,7 +17,7 @@ namespace Server.Tests
var expected = new HairEquipUpdate(m).Compile(); var expected = new HairEquipUpdate(m).Compile();
var ns = PacketTestUtilities.CreateTestNetState(); var ns = PacketTestUtilities.CreateTestNetState();
ns.SendHairEquipUpdatePacket(m, HairInfo.FakeSerial(m.Serial), m.HairItemID, m.HairHue, Layer.Hair); ns.SendHairEquipUpdatePacket(m, (uint)m.Hair.VirtualSerial, m.Hair.ItemId, m.Hair.Hue, Layer.Hair);
var result = ns.SendPipe.Reader.AvailableToRead(); var result = ns.SendPipe.Reader.AvailableToRead();
AssertThat.Equal(result, expected); AssertThat.Equal(result, expected);
@ -32,7 +32,7 @@ namespace Server.Tests
var expected = new RemoveHair(m).Compile(); var expected = new RemoveHair(m).Compile();
var ns = PacketTestUtilities.CreateTestNetState(); var ns = PacketTestUtilities.CreateTestNetState();
ns.SendRemoveHairPacket(HairInfo.FakeSerial(m.Serial)); ns.SendRemoveHairPacket((uint) m.Hair.VirtualSerial);
var result = ns.SendPipe.Reader.AvailableToRead(); var result = ns.SendPipe.Reader.AvailableToRead();
AssertThat.Equal(result, expected); AssertThat.Equal(result, expected);

View file

@ -7,7 +7,7 @@ namespace Server.Network
{ {
var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.HairHue; var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.HairHue;
Stream.Write(HairInfo.FakeSerial(parent.Serial)); Stream.Write(parent.Hair.VirtualSerial);
Stream.Write((short)parent.HairItemID); Stream.Write((short)parent.HairItemID);
Stream.Write((byte)0); Stream.Write((byte)0);
Stream.Write((byte)Layer.Hair); Stream.Write((byte)Layer.Hair);
@ -23,7 +23,7 @@ namespace Server.Network
{ {
var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.FacialHairHue; var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.FacialHairHue;
Stream.Write(FacialHairInfo.FakeSerial(parent.Serial)); Stream.Write(parent.FacialHair.VirtualSerial);
Stream.Write((short)parent.FacialHairItemID); Stream.Write((short)parent.FacialHairItemID);
Stream.Write((byte)0); Stream.Write((byte)0);
Stream.Write((byte)Layer.FacialHair); Stream.Write((byte)Layer.FacialHair);
@ -37,7 +37,7 @@ namespace Server.Network
public RemoveHair(Mobile parent) public RemoveHair(Mobile parent)
: base(0x1D, 5) : base(0x1D, 5)
{ {
Stream.Write(HairInfo.FakeSerial(parent.Serial)); Stream.Write(parent.Hair.VirtualSerial);
} }
} }
@ -46,7 +46,7 @@ namespace Server.Network
public RemoveFacialHair(Mobile parent) public RemoveFacialHair(Mobile parent)
: base(0x1D, 5) : base(0x1D, 5)
{ {
Stream.Write(FacialHairInfo.FakeSerial(parent.Serial)); Stream.Write(parent.FacialHair.VirtualSerial);
} }
} }
} }

View file

@ -0,0 +1,24 @@
using Xunit;
namespace Server.Tests;
[Collection("Sequential Tests")]
public class VirtualSerialTests : IClassFixture<ServerFixture>
{
[Fact]
public void TestNewVirtualGetsAndRollover()
{
// Acquire virtual serials until we hit the max
Serial lastSerial = World.NewVirtual;
do
{
Serial virtualSerial = World.NewVirtual;
Assert.Equal(lastSerial + 1, virtualSerial);
lastSerial = (Serial) (uint) virtualSerial;
} while (lastSerial != World.MaxVirtualSerial);
// Next one should be MaxItemSerial (the first VirtualSerial) due to rollover
var nextSerial = World.NewVirtual;
Assert.Equal(nextSerial, (Serial)World.ResetVirtualSerial);
}
}

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Buffers; using System.Buffers;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using ModernUO.Serialization;
using Server.Network; using Server.Network;
namespace Server; namespace Server;
@ -65,83 +66,33 @@ public static class OutgoingVirtualHairPackets
} }
} }
public abstract class BaseHairInfo [SerializationGenerator(0, false)]
public partial class VirtualHairInfo
{ {
protected BaseHairInfo(int itemid, int hue = 0) [SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _itemId;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _hue;
public VirtualHairInfo() : this(0)
{ {
ItemID = itemid;
Hue = hue;
} }
protected BaseHairInfo(IGenericReader reader) public VirtualHairInfo(int itemid, int hue = 0)
{ {
var version = reader.ReadInt(); _itemId = itemid;
_hue = hue;
switch (version) VirtualSerial = World.NewVirtual;
{
case 0:
{
ItemID = reader.ReadInt();
Hue = reader.ReadInt();
break;
}
}
} }
[CommandProperty(AccessLevel.GameMaster)] [AfterDeserialization]
public int ItemID { get; set; } private void AfterDeserialization()
[CommandProperty(AccessLevel.GameMaster)]
public int Hue { get; set; }
public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(0); // version VirtualSerial = World.NewVirtual;
writer.Write(ItemID);
writer.Write(Hue);
} }
}
public Serial VirtualSerial { get; private set; }
public class HairInfo : BaseHairInfo
{
public HairInfo(int itemid)
: base(itemid)
{
}
public HairInfo(int itemid, int hue)
: base(itemid, hue)
{
}
public HairInfo(IGenericReader reader)
: base(reader)
{
}
// TODO: Can we make this higher for newer clients?
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint FakeSerial(Serial m) => 0x7FFFFFFF - 0x400 - m.Value * 4;
}
public class FacialHairInfo : BaseHairInfo
{
public FacialHairInfo(int itemid)
: base(itemid)
{
}
public FacialHairInfo(int itemid, int hue)
: base(itemid, hue)
{
}
public FacialHairInfo(IGenericReader reader)
: base(reader)
{
}
// TODO: Can we make this higher for newer clients?
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint FakeSerial(Serial m) => 0x7FFFFFFF - 0x400 - 1 - m.Value* 4;
} }

View file

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

View file

@ -187,7 +187,7 @@ public delegate bool AllowBeneficialHandler(Mobile from, Mobile target);
public delegate bool AllowHarmfulHandler(Mobile from, Mobile target); public delegate bool AllowHarmfulHandler(Mobile from, Mobile target);
public delegate Container CreateCorpseHandler( public delegate Container CreateCorpseHandler(
Mobile from, HairInfo hair, FacialHairInfo facialhair, List<Item> initialContent, List<Item> equippedItems Mobile from, List<Item> initialContent, List<Item> equippedItems
); );
public delegate int AOSStatusHandler(Mobile from, int index); public delegate int AOSStatusHandler(Mobile from, int index);
@ -273,7 +273,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
private TimerExecutionToken _expireAggrTimerToken; private TimerExecutionToken _expireAggrTimerToken;
private TimerExecutionToken _expireCombatantTimerToken; private TimerExecutionToken _expireCombatantTimerToken;
private TimerExecutionToken _expireCriminalTimerToken; private TimerExecutionToken _expireCriminalTimerToken;
private FacialHairInfo m_FacialHair; private VirtualHairInfo _facialHair;
public VirtualHairInfo FacialHair => _facialHair ??= new VirtualHairInfo(FacialHairItemID, FacialHairHue);
private int m_Fame, m_Karma; private int m_Fame, m_Karma;
private bool m_Female, m_Warmode, m_Hidden, m_Blessed, m_Flying; private bool m_Female, m_Warmode, m_Hidden, m_Blessed, m_Flying;
private int m_Followers, m_FollowersMax; private int m_Followers, m_FollowersMax;
@ -282,7 +284,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
private BaseGuild m_Guild; private BaseGuild m_Guild;
private string m_GuildTitle; private string m_GuildTitle;
private HairInfo m_Hair; private VirtualHairInfo _hair;
public VirtualHairInfo Hair => _hair ??= new VirtualHairInfo(HairItemID, HairHue);
private int m_Hits, m_Stam, m_Mana; private int m_Hits, m_Stam, m_Mana;
private Item m_Holding; private Item m_Holding;
@ -2165,59 +2169,62 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int HairItemID public int HairItemID
{ {
get => m_Hair?.ItemID ?? 0; get => _hair?.ItemId ?? 0;
set set
{ {
if (m_Hair == null && value > 0) if (_hair == null && value > 0)
{ {
m_Hair = new HairInfo(value); _hair = new VirtualHairInfo(value);
} }
else if (value <= 0) else if (value <= 0)
{ {
m_Hair = null; _hair = null;
} }
else if (m_Hair != null) else if (_hair != null)
{ {
m_Hair.ItemID = value; _hair.ItemId = value;
} }
Delta(MobileDelta.Hair); Delta(MobileDelta.Hair);
this.MarkDirty();
} }
} }
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int FacialHairItemID public int FacialHairItemID
{ {
get => m_FacialHair?.ItemID ?? 0; get => _facialHair?.ItemId ?? 0;
set set
{ {
if (m_FacialHair == null && value > 0) if (_facialHair == null && value > 0)
{ {
m_FacialHair = new FacialHairInfo(value); _facialHair = new VirtualHairInfo(value);
} }
else if (value <= 0) else if (value <= 0)
{ {
m_FacialHair = null; _facialHair = null;
} }
else if (m_FacialHair != null) else if (_facialHair != null)
{ {
m_FacialHair.ItemID = value; _facialHair.ItemId = value;
} }
Delta(MobileDelta.FacialHair); Delta(MobileDelta.FacialHair);
this.MarkDirty();
} }
} }
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int HairHue public int HairHue
{ {
get => m_Hair?.Hue ?? 0; get => _hair?.Hue ?? 0;
set set
{ {
if (m_Hair != null) if (_hair != null)
{ {
m_Hair.Hue = value; _hair.Hue = value;
Delta(MobileDelta.Hair); Delta(MobileDelta.Hair);
this.MarkDirty();
} }
} }
} }
@ -2225,13 +2232,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int FacialHairHue public int FacialHairHue
{ {
get => m_FacialHair?.Hue ?? 0; get => _facialHair?.Hue ?? 0;
set set
{ {
if (m_FacialHair != null) if (_facialHair != null)
{ {
m_FacialHair.Hue = value; _facialHair.Hue = value;
Delta(MobileDelta.FacialHair); Delta(MobileDelta.FacialHair);
this.MarkDirty();
} }
} }
} }
@ -2280,12 +2288,12 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
byte hairflag = 0x00; byte hairflag = 0x00;
if (m_Hair != null) if (_hair != null)
{ {
hairflag |= 0x01; hairflag |= 0x01;
} }
if (m_FacialHair != null) if (_facialHair != null)
{ {
hairflag |= 0x02; hairflag |= 0x02;
} }
@ -2294,12 +2302,12 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
if ((hairflag & 0x01) != 0) if ((hairflag & 0x01) != 0)
{ {
m_Hair?.Serialize(writer); _hair?.Serialize(writer);
} }
if ((hairflag & 0x02) != 0) if ((hairflag & 0x02) != 0)
{ {
m_FacialHair?.Serialize(writer); _facialHair?.Serialize(writer);
} }
writer.Write(Race); writer.Write(Race);
@ -2434,8 +2442,8 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_Map?.OnLeave(this); m_Map?.OnLeave(this);
m_Map = null; m_Map = null;
m_Hair = null; _hair = null;
m_FacialHair = null; _facialHair = null;
m_MountItem = null; m_MountItem = null;
World.RemoveEntity(this); World.RemoveEntity(this);
@ -2708,14 +2716,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
sendFacialHair = true; sendFacialHair = true;
} }
var hairSerial = HairInfo.FakeSerial(Serial); var hairSerial = Hair.VirtualSerial;
var hairLength = removeHair var hairLength = removeHair
? OutgoingVirtualHairPackets.RemovePacketLength ? OutgoingVirtualHairPackets.RemovePacketLength
: OutgoingVirtualHairPackets.EquipUpdatePacketLength; : OutgoingVirtualHairPackets.EquipUpdatePacketLength;
Span<byte> hairPacket = stackalloc byte[hairLength].InitializePacket(); Span<byte> hairPacket = stackalloc byte[hairLength].InitializePacket();
var facialHairSerial = FacialHairInfo.FakeSerial(Serial); var facialHairSerial = FacialHair.VirtualSerial;
var facialHairLength = removeFacialHair var facialHairLength = removeFacialHair
? OutgoingVirtualHairPackets.RemovePacketLength ? OutgoingVirtualHairPackets.RemovePacketLength
: OutgoingVirtualHairPackets.EquipUpdatePacketLength; : OutgoingVirtualHairPackets.EquipUpdatePacketLength;
@ -2806,14 +2814,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{ {
if (removeHair) if (removeHair)
{ {
OutgoingVirtualHairPackets.CreateRemoveHairPacket(hairPacket, hairSerial); OutgoingVirtualHairPackets.CreateRemoveHairPacket(hairPacket, (uint)hairSerial);
} }
else else
{ {
OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket( OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket(
hairPacket, hairPacket,
this, this,
hairSerial, (uint)hairSerial,
HairItemID, HairItemID,
HairHue, HairHue,
Layer.Hair Layer.Hair
@ -2827,14 +2835,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{ {
if (removeFacialHair) if (removeFacialHair)
{ {
OutgoingVirtualHairPackets.CreateRemoveHairPacket(facialHairPacket, facialHairSerial); OutgoingVirtualHairPackets.CreateRemoveHairPacket(facialHairPacket, (uint)facialHairSerial);
} }
else else
{ {
OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket( OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket(
facialHairPacket, facialHairPacket,
this, this,
facialHairSerial, (uint)facialHairSerial,
FacialHairItemID, FacialHairItemID,
FacialHairHue, FacialHairHue,
Layer.FacialHair Layer.FacialHair
@ -2943,14 +2951,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{ {
if (removeHair) if (removeHair)
{ {
OutgoingVirtualHairPackets.CreateRemoveHairPacket(hairPacket, hairSerial); OutgoingVirtualHairPackets.CreateRemoveHairPacket(hairPacket, (uint)hairSerial);
} }
else else
{ {
OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket( OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket(
hairPacket, hairPacket,
this, this,
hairSerial, (uint)hairSerial,
HairItemID, HairItemID,
HairHue, HairHue,
Layer.Hair Layer.Hair
@ -2964,14 +2972,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{ {
if (removeFacialHair) if (removeFacialHair)
{ {
OutgoingVirtualHairPackets.CreateRemoveHairPacket(facialHairPacket, facialHairSerial); OutgoingVirtualHairPackets.CreateRemoveHairPacket(facialHairPacket, (uint)facialHairSerial);
} }
else else
{ {
OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket( OutgoingVirtualHairPackets.CreateHairEquipUpdatePacket(
facialHairPacket, facialHairPacket,
this, this,
facialHairSerial, (uint)facialHairSerial,
FacialHairItemID, FacialHairItemID,
FacialHairHue, FacialHairHue,
Layer.FacialHair Layer.FacialHair
@ -4764,19 +4772,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
} }
} }
HairInfo hair = null; var c = CreateCorpseHandler?.Invoke(this, content, equip);
if (m_Hair != null)
{
hair = new HairInfo(m_Hair.ItemID, m_Hair.Hue);
}
FacialHairInfo facialhair = null;
if (m_FacialHair != null)
{
facialhair = new FacialHairInfo(m_FacialHair.ItemID, m_FacialHair.Hue);
}
var c = CreateCorpseHandler?.Invoke(this, hair, facialhair, content, equip);
if (m_Map != null) if (m_Map != null)
{ {
@ -6092,12 +6088,14 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
if ((hairflag & 0x01) != 0) if ((hairflag & 0x01) != 0)
{ {
m_Hair = new HairInfo(reader); _hair = new VirtualHairInfo();
_hair.Deserialize(reader);
} }
if ((hairflag & 0x02) != 0) if ((hairflag & 0x02) != 0)
{ {
m_FacialHair = new FacialHairInfo(reader); _facialHair = new VirtualHairInfo();
_facialHair.Deserialize(reader);
} }
goto case 29; goto case 29;

View file

@ -672,7 +672,7 @@ public static class OutgoingMobilePackets
itemID |= 0x8000; itemID |= 0x8000;
} }
writer.Write(HairInfo.FakeSerial(beheld.Serial)); writer.Write(beheld.Hair.VirtualSerial);
writer.Write((ushort)itemID); writer.Write((ushort)itemID);
writer.Write((byte)Layer.Hair); writer.Write((byte)Layer.Hair);
@ -695,7 +695,7 @@ public static class OutgoingMobilePackets
itemID |= 0x8000; itemID |= 0x8000;
} }
writer.Write(FacialHairInfo.FakeSerial(beheld.Serial)); writer.Write(beheld.FacialHair.VirtualSerial);
writer.Write((ushort)itemID); writer.Write((ushort)itemID);
writer.Write((byte)Layer.FacialHair); writer.Write((byte)Layer.FacialHair);

View file

@ -53,13 +53,37 @@ public static class World
public const bool DirtyTrackingEnabled = false; public const bool DirtyTrackingEnabled = false;
public const uint ItemOffset = 0x40000000; public const uint ItemOffset = 0x40000000;
public const uint MaxItemSerial = 0x7FFFFFFF; public const uint MaxItemSerial = 0x7EEEEEEE;
public const uint MaxMobileSerial = ItemOffset - 1; public const uint MaxMobileSerial = ItemOffset - 1;
public const uint ResetVirtualSerial = MaxItemSerial;
public const uint MaxVirtualSerial = 0x7FFFFFFF;
private static uint _nextVirtualSerial = ResetVirtualSerial;
public static Serial NewMobile => _mobilePersistence.NewEntity; public static Serial NewMobile => _mobilePersistence.NewEntity;
public static Serial NewItem => _itemPersistence.NewEntity; public static Serial NewItem => _itemPersistence.NewEntity;
public static Serial NewGuild => _guildPersistence.NewEntity; public static Serial NewGuild => _guildPersistence.NewEntity;
// Virtual things don't persist across saves
public static Serial NewVirtual
{
get
{
#if THREADGUARD
if (Thread.CurrentThread != Core.Thread)
{
logger.Error(
"Attempted to get a new virtual serial from the wrong thread!\n{StackTrace}",
new StackTrace()
);
}
#endif
var value = _nextVirtualSerial > MaxVirtualSerial ? ResetVirtualSerial : _nextVirtualSerial;
_nextVirtualSerial = value + 1;
return (Serial)value;
}
}
public static Dictionary<Serial, Item> Items => _itemPersistence.EntitiesBySerial; public static Dictionary<Serial, Item> Items => _itemPersistence.EntitiesBySerial;
public static Dictionary<Serial, Mobile> Mobiles => _mobilePersistence.EntitiesBySerial; public static Dictionary<Serial, Mobile> Mobiles => _mobilePersistence.EntitiesBySerial;
public static Dictionary<Serial, BaseGuild> Guilds => _guildPersistence.EntitiesBySerial; public static Dictionary<Serial, BaseGuild> Guilds => _guildPersistence.EntitiesBySerial;

View file

@ -7,48 +7,51 @@ public sealed class CorpseEquip : Packet
{ {
public CorpseEquip(Mobile beholder, Corpse beheld) : base(0x89) public CorpseEquip(Mobile beholder, Corpse beheld) : base(0x89)
{ {
var list = beheld.EquipItems; var list = beheld.EquipItems;
var count = list.Count; var count = list.Count;
if (beheld.Hair?.ItemID > 0) var hair = beheld.Hair;
{ var facialHair = beheld.FacialHair;
count++;
}
if (beheld.FacialHair?.ItemID > 0) if (hair != null)
{ {
count++; count++;
}
EnsureCapacity(8 + count * 5);
Stream.Write(beheld.Serial);
for (var i = 0; i < list.Count; ++i)
{
var item = list[i];
if (!item.Deleted && beholder.CanSee(item) && item.Parent == beheld)
{
Stream.Write((byte)(item.Layer + 1));
Stream.Write(item.Serial);
}
}
if (beheld.Hair?.ItemID > 0)
{
Stream.Write((byte)(Layer.Hair + 1));
Stream.Write(HairInfo.FakeSerial(beheld.Owner.Serial) - 2);
}
if (beheld.FacialHair?.ItemID > 0)
{
Stream.Write((byte)(Layer.FacialHair + 1));
Stream.Write(FacialHairInfo.FakeSerial(beheld.Owner.Serial) - 2);
}
Stream.Write((byte)Layer.Invalid);
} }
if (facialHair != null)
{
count++;
}
EnsureCapacity(8 + count * 5);
Stream.Write(beheld.Serial);
for (var i = 0; i < list.Count; ++i)
{
var item = list[i];
if (!item.Deleted && beholder.CanSee(item) && item.Parent == beheld)
{
Stream.Write((byte)(item.Layer + 1));
Stream.Write(item.Serial);
}
}
if (hair?.ItemId > 0)
{
Stream.Write((byte)(Layer.Hair + 1));
Stream.Write(hair.VirtualSerial);
}
if (facialHair?.ItemId > 0)
{
Stream.Write((byte)(Layer.FacialHair + 1));
Stream.Write(facialHair.VirtualSerial);
}
Stream.Write((byte)Layer.Invalid);
}
} }
public sealed class CorpseContent : Packet public sealed class CorpseContent : Packet
@ -56,77 +59,79 @@ public sealed class CorpseContent : Packet
public CorpseContent(Mobile beholder, Corpse beheld) public CorpseContent(Mobile beholder, Corpse beheld)
: base(0x3C) : base(0x3C)
{ {
var items = beheld.EquipItems; var items = beheld.EquipItems;
var count = items.Count; var count = items.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
if (beheld.Hair?.ItemID > 0) if (hair != null)
{ {
count++; count++;
}
if (beheld.FacialHair?.ItemID > 0)
{
count++;
}
EnsureCapacity(5 + count * 19);
var pos = Stream.Position;
var written = 0;
Stream.Write((ushort)0);
for (var i = 0; i < items.Count; ++i)
{
var child = items[i];
if (!child.Deleted && child.Parent == beheld && beholder.CanSee(child))
{
Stream.Write(child.Serial);
Stream.Write((ushort)child.ItemID);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)child.Amount);
Stream.Write((short)child.X);
Stream.Write((short)child.Y);
Stream.Write(beheld.Serial);
Stream.Write((ushort)child.Hue);
++written;
}
}
if (beheld.Hair?.ItemID > 0)
{
Stream.Write(HairInfo.FakeSerial(beheld.Owner.Serial) - 2);
Stream.Write((ushort)beheld.Hair.ItemID);
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)beheld.Hair.Hue);
++written;
}
if (beheld.FacialHair?.ItemID > 0)
{
Stream.Write(FacialHairInfo.FakeSerial(beheld.Owner.Serial) - 2);
Stream.Write((ushort)beheld.FacialHair.ItemID);
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)beheld.FacialHair.Hue);
++written;
}
Stream.Seek(pos, SeekOrigin.Begin);
Stream.Write((ushort)written);
} }
if (facialHair != null)
{
count++;
}
EnsureCapacity(5 + count * 19);
var pos = Stream.Position;
var written = 0;
Stream.Write((ushort)0);
for (var i = 0; i < items.Count; ++i)
{
var child = items[i];
if (!child.Deleted && child.Parent == beheld && beholder.CanSee(child))
{
Stream.Write(child.Serial);
Stream.Write((ushort)child.ItemID);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)child.Amount);
Stream.Write((short)child.X);
Stream.Write((short)child.Y);
Stream.Write(beheld.Serial);
Stream.Write((ushort)child.Hue);
++written;
}
}
if (hair?.ItemId > 0)
{
Stream.Write(hair.VirtualSerial);
Stream.Write((ushort)hair.ItemId);
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);
++written;
}
if (facialHair?.ItemId > 0)
{
Stream.Write(facialHair.VirtualSerial);
Stream.Write((ushort)facialHair.ItemId);
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);
++written;
}
Stream.Seek(pos, SeekOrigin.Begin);
Stream.Write((ushort)written);
}
} }
public sealed class CorpseContent6017 : Packet public sealed class CorpseContent6017 : Packet
@ -134,78 +139,80 @@ public sealed class CorpseContent6017 : Packet
public CorpseContent6017(Mobile beholder, Corpse beheld) public CorpseContent6017(Mobile beholder, Corpse beheld)
: base(0x3C) : base(0x3C)
{ {
var items = beheld.EquipItems; var items = beheld.EquipItems;
var count = items.Count; var count = items.Count;
var hair = beheld.Hair;
var facialHair = beheld.FacialHair;
if (beheld.Hair?.ItemID > 0) if (hair != null)
{ {
count++; count++;
}
if (beheld.FacialHair?.ItemID > 0)
{
count++;
}
EnsureCapacity(5 + count * 20);
var pos = Stream.Position;
var written = 0;
Stream.Write((ushort)0);
for (var i = 0; i < items.Count; ++i)
{
var child = items[i];
if (!child.Deleted && child.Parent == beheld && beholder.CanSee(child))
{
Stream.Write(child.Serial);
Stream.Write((ushort)child.ItemID);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)child.Amount);
Stream.Write((short)child.X);
Stream.Write((short)child.Y);
Stream.Write((byte)0); // Grid Location?
Stream.Write(beheld.Serial);
Stream.Write((ushort)child.Hue);
++written;
}
}
if (beheld.Hair?.ItemID > 0)
{
Stream.Write(HairInfo.FakeSerial(beheld.Owner.Serial) - 2);
Stream.Write((ushort)beheld.Hair.ItemID);
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)beheld.Hair.Hue);
++written;
}
if (beheld.FacialHair?.ItemID > 0)
{
Stream.Write(FacialHairInfo.FakeSerial(beheld.Owner.Serial) - 2);
Stream.Write((ushort)beheld.FacialHair.ItemID);
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)beheld.FacialHair.Hue);
++written;
}
Stream.Seek(pos, SeekOrigin.Begin);
Stream.Write((ushort)written);
} }
}
if (facialHair != null)
{
count++;
}
EnsureCapacity(5 + count * 20);
var pos = Stream.Position;
var written = 0;
Stream.Write((ushort)0);
for (var i = 0; i < items.Count; ++i)
{
var child = items[i];
if (!child.Deleted && child.Parent == beheld && beholder.CanSee(child))
{
Stream.Write(child.Serial);
Stream.Write((ushort)child.ItemID);
Stream.Write((byte)0); // signed, itemID offset
Stream.Write((ushort)child.Amount);
Stream.Write((short)child.X);
Stream.Write((short)child.Y);
Stream.Write((byte)0); // Grid Location?
Stream.Write(beheld.Serial);
Stream.Write((ushort)child.Hue);
++written;
}
}
if (hair?.ItemId > 0)
{
Stream.Write(hair.VirtualSerial);
Stream.Write((ushort)hair.ItemId);
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);
++written;
}
if (facialHair?.ItemId > 0)
{
Stream.Write(facialHair.VirtualSerial);
Stream.Write((ushort)facialHair.ItemId);
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);
++written;
}
Stream.Seek(pos, SeekOrigin.Begin);
Stream.Write((ushort)written);
}
}

View file

@ -51,17 +51,17 @@ public partial class SchmendrickApprenticeCorpse : Corpse
new Spellbook() new Spellbook()
]; ];
private static HairInfo GetHair() private static VirtualHairInfo GetHair()
{ {
_hairHue = Race.Human.RandomHairHue(); _hairHue = Race.Human.RandomHairHue();
return new HairInfo(Race.Human.RandomHair(false), _hairHue); return new VirtualHairInfo(Race.Human.RandomHair(false), _hairHue);
} }
private static FacialHairInfo GetFacialHair() private static VirtualHairInfo GetFacialHair()
{ {
_hairHue = Race.Human.RandomHairHue(); _hairHue = Race.Human.RandomHairHue();
return new FacialHairInfo(Race.Human.RandomFacialHair(false), _hairHue); return new VirtualHairInfo(Race.Human.RandomFacialHair(false), _hairHue);
} }
public override void AddNameProperty(IPropertyList list) public override void AddNameProperty(IPropertyList list)

View file

@ -84,7 +84,7 @@ public partial class MilitiaFighter : BaseCreature
[SerializationGenerator(0, false)] [SerializationGenerator(0, false)]
public partial class MilitiaFighterCorpse : Corpse public partial class MilitiaFighterCorpse : Corpse
{ {
public MilitiaFighterCorpse(Mobile owner, HairInfo hair, FacialHairInfo facialhair, List<Item> equipItems) : base( public MilitiaFighterCorpse(Mobile owner, VirtualHairInfo hair, VirtualHairInfo facialhair, List<Item> equipItems) : base(
owner, owner,
hair, hair,
facialhair, facialhair,

View file

@ -64,7 +64,7 @@ public enum CorpseFlag
SelfLooted = 0x00000080 SelfLooted = 0x00000080
} }
[SerializationGenerator(13, false)] [SerializationGenerator(14, false)]
public partial class Corpse : Container, ICarvable public partial class Corpse : Container, ICarvable
{ {
public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0);
@ -132,6 +132,16 @@ public partial class Corpse : Container, ICarvable
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private List<Item> _equipItems; private List<Item> _equipItems;
[CanBeNull]
[SerializableField(14, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private VirtualHairInfo _hair;
[CanBeNull]
[SerializableField(15, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private VirtualHairInfo _facialHair;
// Why was this public? // Why was this public?
// public override bool IsPublicContainer => true; // public override bool IsPublicContainer => true;
@ -139,7 +149,7 @@ public partial class Corpse : Container, ICarvable
{ {
} }
public Corpse(Mobile owner, HairInfo hair, FacialHairInfo facialhair, List<Item> equipItems) public Corpse(Mobile owner, VirtualHairInfo hair, VirtualHairInfo facialHair, List<Item> equipItems)
: base(0x2006) : base(0x2006)
{ {
// To suppress console warnings, stackable must be true // To suppress console warnings, stackable must be true
@ -163,8 +173,15 @@ public partial class Corpse : Container, ICarvable
_kills = owner.Kills; _kills = owner.Kills;
SetFlag(CorpseFlag.Criminal, owner.Criminal); SetFlag(CorpseFlag.Criminal, owner.Criminal);
Hair = hair; if (hair?.ItemId > 0)
FacialHair = facialhair; {
_hair = new VirtualHairInfo(hair.ItemId, hair.Hue);
}
if (facialHair?.ItemId > 0)
{
_facialHair = new VirtualHairInfo(facialHair.ItemId, facialHair.Hue);
}
// This corpse does not turn to bones if: the owner is not a player // This corpse does not turn to bones if: the owner is not a player
SetFlag(CorpseFlag.NoBones, !owner.Player); SetFlag(CorpseFlag.NoBones, !owner.Player);
@ -237,6 +254,26 @@ public partial class Corpse : Container, ICarvable
DevourCorpse(); DevourCorpse();
} }
// Added corpse hair and corpse facial hair
private void MigrateFrom(V13Content 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;
_kills = content.Kills;
_equipItems = content.EquipItems;
}
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime; public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime;
@ -248,10 +285,6 @@ public partial class Corpse : Container, ICarvable
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public string Forensicist { get; set; } public string Forensicist { get; set; }
public HairInfo Hair { get; }
public FacialHairInfo FacialHair { get; }
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public bool IsBones => GetFlag(CorpseFlag.IsBones); public bool IsBones => GetFlag(CorpseFlag.IsBones);
@ -492,14 +525,11 @@ public partial class Corpse : Container, ICarvable
Mobile.CreateCorpseHandler += Mobile_CreateCorpseHandler; Mobile.CreateCorpseHandler += Mobile_CreateCorpseHandler;
} }
public static Container Mobile_CreateCorpseHandler( public static Container Mobile_CreateCorpseHandler(Mobile owner, List<Item> initialContent, List<Item> equipItems)
Mobile owner, HairInfo hair, FacialHairInfo facialhair,
List<Item> initialContent, List<Item> equipItems
)
{ {
var c = owner is MilitiaFighter var c = owner is MilitiaFighter
? new MilitiaFighterCorpse(owner, hair, facialhair, equipItems) ? new MilitiaFighterCorpse(owner, owner.Hair, owner.FacialHair, equipItems)
: new Corpse(owner, hair, facialhair, equipItems); : new Corpse(owner, owner.Hair, owner.FacialHair, equipItems);
owner.Corpse = c; owner.Corpse = c;

View file

@ -49,16 +49,16 @@ public static class CorpsePackets
if (beheld.Owner != null) if (beheld.Owner != null)
{ {
if (beheld.Hair?.ItemID > 0) if (beheld.Hair?.ItemId > 0)
{ {
writer.Write((byte)(Layer.Hair + 1)); writer.Write((byte)(Layer.Hair + 1));
writer.Write(HairInfo.FakeSerial(beheld.Owner.Serial) - 2); writer.Write(beheld.Hair.VirtualSerial);
} }
if (beheld.FacialHair?.ItemID > 0) if (beheld.FacialHair?.ItemId > 0)
{ {
writer.Write((byte)(Layer.FacialHair + 1)); writer.Write((byte)(Layer.FacialHair + 1));
writer.Write(FacialHairInfo.FakeSerial(beheld.Owner.Serial) - 2); writer.Write(beheld.FacialHair.VirtualSerial);
} }
} }
@ -76,15 +76,16 @@ public static class CorpsePackets
} }
var list = beheld.EquipItems; var list = beheld.EquipItems;
var hairItemID = beheld.Hair?.ItemID ?? 0; var hair = beheld.Hair;
var facialHairItemID = beheld.FacialHair?.ItemID ?? 0; var facialHair = beheld.FacialHair;
var count = list.Count; var count = list.Count;
if (hairItemID > 0) if (hair != null)
{ {
count++; count++;
} }
if (facialHairItemID > 0) if (facialHair != null)
{ {
count++; count++;
} }
@ -120,10 +121,10 @@ public static class CorpsePackets
if (beheld.Owner != null) if (beheld.Owner != null)
{ {
if (hairItemID > 0) if (hair?.ItemId > 0)
{ {
writer.Write(HairInfo.FakeSerial(beheld.Owner.Serial) - 2); writer.Write(hair.VirtualSerial);
writer.Write((ushort)hairItemID); writer.Write((ushort)hair.ItemId);
writer.Write((byte)0); // signed, itemID offset writer.Write((byte)0); // signed, itemID offset
writer.Write((ushort)1); writer.Write((ushort)1);
writer.Write(0); // X/Y writer.Write(0); // X/Y
@ -132,15 +133,15 @@ public static class CorpsePackets
writer.Write((byte)0); // Grid Location? writer.Write((byte)0); // Grid Location?
} }
writer.Write(beheld.Serial); writer.Write(beheld.Serial);
writer.Write((ushort)beheld.Hair!.Hue); writer.Write((ushort)hair.Hue);
++written; ++written;
} }
if (facialHairItemID > 0) if (facialHair?.ItemId > 0)
{ {
writer.Write(FacialHairInfo.FakeSerial(beheld.Owner.Serial) - 2); writer.Write(facialHair.VirtualSerial);
writer.Write((ushort)facialHairItemID); writer.Write((ushort)facialHair.ItemId);
writer.Write((byte)0); // signed, itemID offset writer.Write((byte)0); // signed, itemID offset
writer.Write((ushort)1); writer.Write((ushort)1);
writer.Write(0); // X/Y writer.Write(0); // X/Y
@ -149,7 +150,7 @@ public static class CorpsePackets
writer.Write((byte)0); // Grid Location? writer.Write((byte)0); // Grid Location?
} }
writer.Write(beheld.Serial); writer.Write(beheld.Serial);
writer.Write((ushort)beheld.FacialHair!.Hue); writer.Write((ushort)facialHair.Hue);
++written; ++written;
} }

View file

@ -0,0 +1,131 @@
{
"version": 14,
"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": "Kills",
"type": "int",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "EquipItems",
"type": "System.Collections.Generic.List\u003CServer.Item\u003E",
"rule": "ListMigrationRule",
"ruleArguments": [
"Server.Item",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "Hair",
"type": "Server.VirtualHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",
"@CanBeNull"
]
},
{
"name": "FacialHair",
"type": "Server.VirtualHairInfo",
"rule": "RawSerializableMigrationRule",
"ruleArguments": [
"",
"@CanBeNull"
]
}
]
}