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.
131 lines
No EOL
3 KiB
JSON
Generated
131 lines
No EOL
3 KiB
JSON
Generated
{
|
|
"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.Items.LegacyHairInfo",
|
|
"rule": "RawSerializableMigrationRule",
|
|
"ruleArguments": [
|
|
"",
|
|
"@CanBeNull"
|
|
]
|
|
},
|
|
{
|
|
"name": "FacialHair",
|
|
"type": "Server.Items.LegacyHairInfo",
|
|
"rule": "RawSerializableMigrationRule",
|
|
"ruleArguments": [
|
|
"",
|
|
"@CanBeNull"
|
|
]
|
|
}
|
|
]
|
|
} |