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:
parent
978f314f0e
commit
a8acfa31f8
18 changed files with 525 additions and 210 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue