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

@ -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;
}