ModernUO/Projects/UOContent
Kamron Batman 93e46a88b2
fix: OPL revision hash collided on reordered and repeated properties (#2615)
## The bug

`ObjectPropertyList.AddHash` folded each cliloc and a Marvin hash of each argument together with XOR, which is both commutative and self-inverse. Any value mixed in an even number of times cancelled outright, so two properties sharing one argument hashed identically no matter what that argument was:

```
_hash = 31659021
AddHash(1063752); AddHash(hash("10"))
AddHash(1063737); AddHash(hash("10"))   // the argument cancels here
AddHash(1063740)                        // -> 32714560, for ANY argument
```

The 10% and 5% variants of the same item therefore produced the same revision. The client caches the tooltip by revision and only re-requests when it changes, so it kept rendering the stale percentage.

Same root cause, three more shapes:

| | old | new |
|---|---|---|
| Two properties sharing an argument | collides | distinct |
| Properties reordered | collides | distinct |
| Two properties trading arguments | collides | distinct |
| Argument-less property added twice | cancels to 0 | distinct |

## The fix

Hash the finished property block in `Terminate()` instead of accumulating per property. Those bytes — cliloc, length prefix and UTF-16 text, in emission order — are the exact content the client renders, so anything that changes the tooltip changes the hash. The length prefix also removes the concatenation ambiguity the old scheme had.

`AddHash` and both `string.GetHashCode` calls are gone.

## Why 26 bits, and why not 64

The client never recomputes the hash — it stores what we send in 0xD6 and compares it for equality against the 0xDC revision (`ObjectPropertiesListManager.IsRevisionEquals`). So the algorithm is ours to choose, but the width is not:

- Both packets carry a **4-byte** revision, so 64 bits is not available. A wider internal value would be worse than useless: the server would see a change and send a 0xDC whose truncated 32 bits are identical, and the client still wouldn't refresh.
- `Terminate` writes the bare hash into 0xD6 while `SendOPLInfo` writes `Hash` with bit 30 set. The client recovers one from the other by masking off `0x40000000`, which only holds while the hash stays below that bit. Hence 26 bits, unchanged from before.
- `Hash => 0x40000000 + _hash` keeps the revision non-zero; the client parks 0 as its "nothing cached" sentinel.

## Collision behaviour

Enumerated real small-OPL spaces and counted 26-bit collisions against the birthday expectation for a uniform hash:

| Scenario | block | n | collisions | expected |
|---|---|---|---|---|
| 1 cliloc, no arg (every cliloc 1.0M–3.2M) | 6 B | 2,200,000 | 35,591 | ~36,061 |
| 1 cliloc + numeric arg 0–199,999 | 8–12 B | 200,000 | 300 | ~298 |
| 2 clilocs, both with numeric args | 12–24 B | 202,500 | 288 | ~306 |
| 1 cliloc + 1-char arg | 8 B | 4,000,000 | 116,291 | ~119,209 |

Every case lands on the random-model line, and per-bit P(1) across all 26 bits is 0.4962–0.5024 — XXH3's short-input paths still avalanche fully, so a 6-byte block behaves like a uniform 26-bit draw. Masking the low 26 bits versus folding all 64 down was a wash (35,591 vs 35,558).

The metric that matters is narrower, since the client compares a revision only against the previous revision **for the same serial**: 2^-26 = 1.5e-8 per genuine tooltip change. Consecutive-value transitions (charges 50 -> 49) collided 0 times in 200,000.

Row 3 is the old scheme quantified: it collapsed 202,500 inputs into 100,954 distinct hashes, a 50% collision rate — systematic, not probabilistic.

One honest regression: in row 1 the old scheme had zero collisions, because for a single argument-less cliloc under 2^26 the hash was the identity function. An item whose whole tooltip is one argument-less cliloc changing to a different one goes from never colliding to 1.5e-8.

## Performance

Cheaper, not just correct — one xxHash3 pass replaces a Marvin hash per string property over those same bytes. A 12-property, 302-byte tooltip, steady state:

```
old (XOR + Marvin per property)   70.5 ns
xxHash3 (streaming, HashUtility)  27.0 ns
```

`XxHash3.HashToUInt64` one-shot is a further ~7ns faster and produces byte-identical output, but taking it would mean editing `HashUtility.ComputeHash64`, whose values are baked into save files via `AssemblyHandler.GetTypeHash`. Not worth it.

## Second commit: plant old-client property list

Found while tracing the `Hash` read sites. `PlantItem.OldClientPropertyList` called `InitializePropertyList` on every access instead of only when the list was null, and without a `Reset`. Every read appended another copy of every property to the same buffer. `SendOPLPacketTo` and `SendPropertiesTo` both go through the getter, so a pre-7.0.12 client looking at a plant grew the buffer without bound and moved the revision on reads alone.

Now builds once, matching `Item.PropertyList`. `InvalidateProperties` already `Reset`s before rebuilding.

## Testing

`Server.Tests` 869 passed, `UOContent.Tests` 779 passed.

New regression tests, each confirmed failing against the old code first:

- `RepeatedArgument_DoesNotCancelOut` — the reported case
- `PropertyOrder_ChangesHash`, `SwappedArguments_ChangeHash`, `DuplicateProperty_ChangesHash`
- `ShortNumericArguments_ConsecutiveValuesDiffer`, `SmallPropertyBlocks_StayWellDistributed` — short-input avalanche, bounded loosely enough to hold for any seed
- `Hash_StaysWithinTheRevisionMask`, `EmptyList_IsNonZeroAndDistinctFromPopulated` — wire constraints
- `PlantItemPropertyListTests.OldClientPropertyList_BuildsOnceAndIsStableAcrossReads`
2026-09-06 21:32:12 -07:00
..
Accounting perf(login): run password hashing on a parked worker thread (#2566) 2026-08-09 00:13:34 -07:00
Assistants chore: Use var everywhere (#2294) 2025-12-27 16:47:28 -08:00
Commands fix: Removes side effects from locked down items, decaying houses, bracelet of binding, and builds (#2608) 2026-09-06 09:39:00 -07:00
Compression feat: Implements new robust/pluggable backup/archive system. (#2388) 2026-03-22 19:51:20 -07:00
Configuration fix: Streamlines insurance. Insurance only executes when enabled. (#2550) 2026-07-26 09:47:49 -07:00
Console feat: Adds console commands (#1714) 2024-03-30 09:40:43 -07:00
Context Menus fix: Moves ContextMenu out of core, streamlines code, fixes bugs (#1873) 2024-07-20 21:33:23 -07:00
Engines fix: OPL revision hash collided on reordered and repeated properties (#2615) 2026-09-06 21:32:12 -07:00
Gumps fix: harden idle-sleep scheduling against bad config and misattributed saves (#2567) 2026-08-09 22:05:18 -07:00
Holiday Stuff perf: Migrate Veteran Reward gumps to DynamicGump/StaticGump (#2415) 2026-04-25 20:09:27 -07:00
Items fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613) 2026-09-06 16:12:29 -07:00
Migrations refactor: Changes BaseCreature to the SerializationGenerator (delta save requirement) (#2611) 2026-09-06 14:11:51 -07:00
Misc fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613) 2026-09-06 16:12:29 -07:00
Mobiles fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613) 2026-09-06 16:12:29 -07:00
Multis fix: Removes side effects from locked down items, decaying houses, bracelet of binding, and builds (#2608) 2026-09-06 09:39:00 -07:00
Network fix: Fixes tick count wrap-around in movement throttle, and eliminates more allocations in NetState (#2603) 2026-09-01 20:25:20 -07:00
Regions fix(regions): correct end Z coordinate assignment in InitRectangles (#2597) 2026-08-27 06:51:42 -07:00
Skills refactor: Changes BaseCreature to the SerializationGenerator (delta save requirement) (#2611) 2026-09-06 14:11:51 -07:00
Special Systems feat: Add zero-alloc interpolation handler to ValueStringBuilder, replace all StringBuilder usage (#2387) 2026-03-22 14:23:44 -07:00
Spells refactor: Changes BaseCreature to the SerializationGenerator (delta save requirement) (#2611) 2026-09-06 14:11:51 -07:00
Systems/JailSystem fix: pet release never finished, summon master follows the pet, horse breeder and notoriety guards (#2613) 2026-09-06 16:12:29 -07:00
Targets chore: Use var everywhere (#2294) 2025-12-27 16:47:28 -08:00
Text fix: Bumps deps. Updates copyrights (#2353) 2026-03-05 19:36:54 -08:00
Utilities perf(login): run password hashing on a parked worker thread (#2566) 2026-08-09 00:13:34 -07:00
World Saves perf(messages): mechanical interpolation cleanups (#2436) 2026-05-03 18:26:49 -07:00
Module.cs fix: Fixes sending packets and sidesteps a major issue with stackalloc and PGO in .NET 8 (#1607) 2023-11-21 12:18:20 -08:00
UOContent.csproj fix: Fixes dirty-tracking gaps in generated content: setters, sub-object owners, BaseVendor (#2609) 2026-09-06 09:43:44 -07:00