diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePackets.cs index d09d2f80b..a9c2fd5be 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePackets.cs @@ -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); diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs index d02bb9e2a..3a105d1a4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs @@ -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); + } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs index 8b18abdd1..135a6e698 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs @@ -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); } } diff --git a/Projects/Server/Items/VirtualHair.cs b/Projects/Server/Items/OutgoingVirtualHairPackets.cs similarity index 71% rename from Projects/Server/Items/VirtualHair.cs rename to Projects/Server/Items/OutgoingVirtualHairPackets.cs index 95f43d3f2..ba764764d 100644 --- a/Projects/Server/Items/VirtualHair.cs +++ b/Projects/Server/Items/OutgoingVirtualHairPackets.cs @@ -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; } -} diff --git a/Projects/Server/Migrations/Server.VirtualHairInfo.v0.json b/Projects/Server/Migrations/Server.VirtualHairInfo.v0.json deleted file mode 100644 index 1eaf27a0d..000000000 --- a/Projects/Server/Migrations/Server.VirtualHairInfo.v0.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 0, - "type": "Server.VirtualHairInfo", - "properties": [ - { - "name": "ItemId", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Hue", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index e425dcf70..632dbcda9 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -272,8 +272,9 @@ public partial class Mobile : IHued, IComparable, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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; diff --git a/Projects/Server/Network/Packets/OutgoingMobilePackets.cs b/Projects/Server/Network/Packets/OutgoingMobilePackets.cs index 14ba76a52..082016f12 100644 --- a/Projects/Server/Network/Packets/OutgoingMobilePackets.cs +++ b/Projects/Server/Network/Packets/OutgoingMobilePackets.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs new file mode 100644 index 000000000..3538ca931 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs @@ -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); + } +} diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/Packets.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/Packets.cs index 6dd973e58..4049707a5 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/Packets.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/Packets.cs @@ -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; } diff --git a/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Items/SchmendrickApprenticeCorpse.cs b/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Items/SchmendrickApprenticeCorpse.cs index 20b213646..a81c3dddd 100644 --- a/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Items/SchmendrickApprenticeCorpse.cs +++ b/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Items/SchmendrickApprenticeCorpse.cs @@ -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 diff --git a/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Mobiles/MilitiaFighter.cs b/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Mobiles/MilitiaFighter.cs index af38fbb09..a95eaf7f5 100644 --- a/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Mobiles/MilitiaFighter.cs +++ b/Projects/UOContent/Engines/Quests/Uzeraan Turmoil/Mobiles/MilitiaFighter.cs @@ -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 equipItems) : base( + public MilitiaFighterCorpse( + Mobile owner, int hairItemId, int hairHue, int facialHairItemId, int facialHairHue, List equipItems + ) : base( owner, - hair, - facialhair, + hairItemId, + hairHue, + facialHairItemId, + facialHairHue, equipItems ) { diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index e014f058d..1ee04170c 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -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 _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 equipItems) : this(owner, null, null, equipItems) + public Corpse(Mobile owner, List equipItems) : this(owner, 0, 0, 0, 0, equipItems) { } - public Corpse(Mobile owner, VirtualHairInfo hair, VirtualHairInfo facialHair, List equipItems) + public Corpse( + Mobile owner, int hairItemId, int hairHue, int facialHairItemId, int facialHairHue, List 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 initialContent, List 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; diff --git a/Projects/UOContent/Items/Misc/Corpses/CorpsePackets.cs b/Projects/UOContent/Items/Misc/Corpses/CorpsePackets.cs index 90a9c5419..4a629320d 100644 --- a/Projects/UOContent/Items/Misc/Corpses/CorpsePackets.cs +++ b/Projects/UOContent/Items/Misc/Corpses/CorpsePackets.cs @@ -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; } diff --git a/Projects/UOContent/Items/Misc/Corpses/LegacyHairInfo.cs b/Projects/UOContent/Items/Misc/Corpses/LegacyHairInfo.cs new file mode 100644 index 000000000..631f9b719 --- /dev/null +++ b/Projects/UOContent/Items/Misc/Corpses/LegacyHairInfo.cs @@ -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(); + } +} diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v14.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v14.json index f10f8c563..8ecb490ef 100644 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v14.json +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v14.json @@ -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": [ "", diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v15.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v15.json index 411c19bd2..7b8195999 100644 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v15.json +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v15.json @@ -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": [ "", diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json index 3efb84682..25aa6b747 100644 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json @@ -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": [ "", diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v17.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v17.json new file mode 100644 index 000000000..5dab0cd37 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v17.json @@ -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": [ + "" + ] + } + ] +} \ No newline at end of file