perf: keep damage entries in an inline intrusive list (#2605)

## Summary

`Mobile.DamageEntries` was a `List<DamageEntry>` allocated for every mobile, including the ~99% that never take damage. It is now an inline `ValueLinkList<DamageEntry>` (24 bytes in the `Mobile` object, no separate allocation) ordered least recent → most recent.

- `DamageEntry` implements `IValueLinkListNode<DamageEntry>`.
- `RegisterDamage` moves the entry to the tail in O(1) instead of `Remove` + `Add` on a list.
- Expired entries are always a head prefix, so pruning walks from the head and stops at the first live entry. The `DamageEntries` getter prunes on access.
- `DamageEntries` is exposed as `ref readonly`; enumerate with `foreach` or `.ByDescending()`. Mutation goes through `RegisterDamage` / `ClearDamageEntries`.
- `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` take `in ValueLinkList<DamageEntry>`; all callers compile unchanged. Files that `foreach` over `DamageEntries` need `using Server.Collections;` for the enumerator extension.
- RunUO migration docs (`dev-docs/runuo-migration-docs/09`, `11`) and the `migrate-items-mobiles` skill document the change.

Saves one object and 16 bytes per mobile (~8 MB and 500k gen2 objects on a 500k world). Second of three PRs from the lazy per-mobile collections design (first: #2604). Branched from `main`; the two diffs touch disjoint hunks of `Mobile.cs`.

## Breaking change

- `Mobile.DamageEntries` is no longer a `List<DamageEntry>`. Indexing, `.Clear()`, `.Add()`, `.Remove()` no longer compile; use `foreach`, `.ByDescending()`, `.Count`, `ClearDamageEntries()`, and `RegisterDamage`. Calling a `ValueLinkList` mutator on the `ref readonly` property compiles but operates on a copy while still unlinking the real nodes; do not.
- `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` signatures changed to `(in ValueLinkList<DamageEntry>, …)`.

Save format is untouched: damage entries are not serialized.

## Behavior

Recency order, `allowSelf`, tie-breaking in `FindMostTotal`/`FindLeastTotal` (most recent wins), `Responsible` accounting, and loot-rights ordering are unchanged and covered by the new `DamageEntryTests` and `LootingRightsTests`.

## Testing

- `dotnet build -c Release` clean.
- New `DamageEntryTests` and `LootingRightsTests` plus full `Server.Tests` and `UOContent.Tests`.
This commit is contained in:
Kamron Batman 2026-09-01 23:25:14 -07:00 committed by GitHub
parent 708a354337
commit e52d54b7da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 603 additions and 103 deletions

View file

@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using Server.Collections;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class DamageEntryTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
private static List<Mobile> Damagers(Mobile victim)
{
var result = new List<Mobile>();
foreach (var de in victim.DamageEntries)
{
result.Add(de.Damager);
}
return result;
}
[Fact]
public void FreshMobile_HasNoEntries()
{
var m = new TestMobile();
try
{
Assert.Equal(0, m.DamageEntries.Count);
Assert.Null(m.FindMostRecentDamageEntry(true));
Assert.Null(m.FindLeastRecentDamageEntry(true));
Assert.Null(m.FindMostTotalDamageEntry(true));
Assert.Null(m.FindLeastTotalDamageEntry(true));
Assert.Null(m.FindDamageEntryFor(m));
}
finally
{
m.Delete();
}
}
[Fact]
public void RegisterDamage_OrdersLeastRecentToMostRecent()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(20, b);
victim.RegisterDamage(5, a); // a becomes most recent again
Assert.Equal(2, victim.DamageEntries.Count);
Assert.Equal(new[] { b, a }, Damagers(victim));
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
Assert.Same(a, victim.FindMostRecentDamager(true));
Assert.Same(b, victim.FindLeastRecentDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FindRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, victim); // self is most recent
Assert.Same(victim, victim.FindMostRecentDamager(true));
Assert.Same(a, victim.FindMostRecentDamager(false));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindLeastRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
victim.RegisterDamage(10, a);
Assert.Same(victim, victim.FindLeastRecentDamager(true));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindTotal_PicksByDamage_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(30, b); // ties a; b is more recent
victim.RegisterDamage(1, c);
Assert.Same(b, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void FindLeastTotal_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(5, b);
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
Assert.Same(a, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void Prune_RemovesExpiredPrefix_KeepsOrder()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
victim.RegisterDamage(10, b); // a is now expired, b is live
Assert.Equal(new[] { b }, Damagers(victim));
Assert.Null(victim.FindDamageEntryFor(a));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void Prune_AllExpired_EmptiesList()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, b);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
Assert.Equal(0, victim.DamageEntries.Count);
Assert.Null(victim.FindMostRecentDamageEntry(true));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void ClearDamageEntries_UnlinksEveryNode()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
var ea = victim.RegisterDamage(10, a);
var eb = victim.RegisterDamage(10, b);
victim.ClearDamageEntries();
Assert.Equal(0, victim.DamageEntries.Count);
Assert.False(ea.OnLinkList);
Assert.False(eb.OnLinkList);
Assert.Null(ea.Next);
Assert.Null(ea.Previous);
Assert.Null(eb.Next);
Assert.Null(eb.Previous);
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FullHitPoints_ClearsEntries()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
victim.Hits = 10;
victim.RegisterDamage(10, a);
Assert.Equal(1, victim.DamageEntries.Count);
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
victim.Hits = victim.HitsMax;
Assert.Equal(0, victim.DamageEntries.Count);
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void RegisterDamage_AccumulatesResponsibleMaster()
{
var victim = new TestMobile();
var master = new TestMobile();
var pet = new PetMobile { Master = master };
try
{
victim.RegisterDamage(10, pet);
var entry = victim.RegisterDamage(5, pet);
Assert.Same(pet, entry.Damager);
Assert.Equal(15, entry.DamageGiven);
Assert.NotNull(entry.Responsible);
Assert.Single(entry.Responsible);
Assert.Same(master, entry.Responsible[0].Damager);
Assert.Equal(15, entry.Responsible[0].DamageGiven);
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
}
}
}

View file

@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
public class DamageEntry
public class DamageEntry : IValueLinkListNode<DamageEntry>
{
public DamageEntry(Mobile damager) => Damager = damager;
@ -57,6 +57,11 @@ public class DamageEntry
public List<DamageEntry> Responsible { get; set; }
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
// Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list.
public DamageEntry Next { get; set; }
public DamageEntry Previous { get; set; }
public bool OnLinkList { get; set; }
}
[Flags]
@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>();
NextSkillTime = Core.TickCount;
DamageEntries = new List<DamageEntry>();
}
// Sectors
@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public static VisibleDamageType VisibleDamageType { get; set; }
public List<DamageEntry> DamageEntries { get; private set; }
private ValueLinkList<DamageEntry> _damageEntries;
/// <summary>
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
/// while still unlinking the real nodes — it silently corrupts the list.
/// </summary>
public ref readonly ValueLinkList<DamageEntry> DamageEntries
{
get
{
PruneExpiredDamageEntries();
return ref _damageEntries;
}
}
[CommandProperty(AccessLevel.GameMaster)]
public Mobile LastKiller { get; set; }
@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors[i].CanReportMurder = false;
}
if (DamageEntries.Count > 0)
{
DamageEntries.Clear(); // reset damage entries on full HP
}
ClearDamageEntries(); // reset damage entries on full HP
}
else if (CanRegenHits)
{
@ -5745,24 +5762,54 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
}
}
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
private void PruneExpiredDamageEntries()
{
#if DEBUG
for (var node = _damageEntries._first; node != null; node = node.Next)
{
Debug.Assert(
node.Next == null || node.Next.LastDamage >= node.LastDamage,
"Damage entries must be ordered by LastDamage ascending."
);
}
#endif
var first = _damageEntries._first;
if (first?.HasExpired != true)
{
return;
}
var firstLive = first.Next;
while (firstLive?.HasExpired == true)
{
firstLive = firstLive.Next;
}
if (firstLive == null)
{
_damageEntries.RemoveAll();
}
else
{
_damageEntries.RemoveAllBefore(firstLive);
}
}
public void ClearDamageEntries() => _damageEntries.RemoveAll();
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
{
for (var i = DamageEntries.Count - 1; i >= 0; --i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (allowSelf || de.Damager != this)
if (allowSelf || de.Damager != this)
{
return de;
}
@ -5775,21 +5822,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
{
for (var i = 0; i < DamageEntries.Count; ++i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._first; de != null; de = de.Next)
{
if (i < 0)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
--i;
}
else if (allowSelf || de.Damager != this)
if (allowSelf || de.Damager != this)
{
return de;
}
@ -5800,24 +5837,17 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
// Walks most recent first with a strict comparison so the most recent entry wins ties,
// matching the previous reverse-indexed loop.
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
{
PruneExpiredDamageEntries();
DamageEntry mostTotal = null;
for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
{
mostTotal = de;
}
@ -5830,46 +5860,28 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
{
DamageEntry mostTotal = null;
PruneExpiredDamageEntries();
for (var i = DamageEntries.Count - 1; i >= 0; --i)
DamageEntry leastTotal = null;
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
{
mostTotal = de;
leastTotal = de;
}
}
return mostTotal;
return leastTotal;
}
public DamageEntry FindDamageEntryFor(Mobile m)
{
for (var i = DamageEntries.Count - 1; i >= 0; --i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (de.Damager == m)
if (de.Damager == m)
{
return de;
}
@ -5887,8 +5899,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
de.DamageGiven += amount;
de.LastDamage = Core.Now;
DamageEntries.Remove(de);
DamageEntries.Add(de);
// Move to the tail so the list stays in LastDamage order.
if (de.OnLinkList)
{
_damageEntries.Remove(de);
}
_damageEntries.AddLast(de);
var master = from.GetDamageMaster(this);
@ -7814,7 +7831,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
AutoPageNotify = true;
Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>();
DamageEntries = new List<DamageEntry>();
NextSkillTime = Core.TickCount;
}

View file

@ -0,0 +1,146 @@
using System.Collections.Generic;
using Server.Mobiles;
using Xunit;
namespace Server.Tests;
/// <summary>
/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the
/// returned stores are sorted by damage descending, the first (least recent) damager takes the
/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited
/// to its damage master rather than to the pet.
/// </summary>
[Collection("Sequential UOContent Tests")]
public class LootingRightsTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
// GetLootingRights only ever credits mobiles flagged as players.
private static TestMobile NewPlayer() => new() { Player = true };
private static DamageStore FindStore(List<DamageStore> rights, Mobile m)
{
for (var i = 0; i < rights.Count; i++)
{
if (rights[i].m_Mobile == m)
{
return rights[i];
}
}
return null;
}
[Fact]
public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager"
// hitsMax < 200 puts the bar at topDamage / 2.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
Assert.Equal(2, rights.Count);
// Sorted by damage descending.
Assert.True(rights[0].m_Damage >= rights[1].m_Damage);
Assert.Same(first, rights[0].m_Mobile);
Assert.Same(second, rights[1].m_Mobile);
// The first damager - the least recent entry - gets the 1.25x bonus; nobody else does.
Assert.Equal(125, rights[0].m_Damage);
Assert.Equal(40, rights[1].m_Damage);
// topDamage 125 / 2 = 62, so 40 is below the bar.
Assert.True(rights[0].m_HasRight);
Assert.False(rights[1].m_HasRight);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void HitsMaxBand_MovesTheRightsThreshold()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second);
// hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200);
Assert.Equal(2, rights.Count);
Assert.True(rights[0].m_HasRight);
Assert.True(rights[1].m_HasRight);
Assert.Same(second, rights[1].m_Mobile);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void PetDamage_CreditsTheMaster_NotThePet()
{
var victim = new TestMobile();
var master = NewPlayer();
var pet = new PetMobile { Master = master };
var wild = new TestMobile(); // no damage master, and not a player
try
{
victim.RegisterDamage(50, pet);
victim.RegisterDamage(20, wild);
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
// The master is credited through the entry's Responsible sub-entry, and is the only one.
Assert.Single(rights);
var masterStore = FindStore(rights, master);
Assert.NotNull(masterStore);
Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus
Assert.True(masterStore.m_HasRight);
// The pet's own damage was fully handed to the master, so it earns no store.
Assert.Null(FindStore(rights, pet));
// A non-player damager earns nothing even when its damage was never reassigned.
Assert.Null(FindStore(rights, wild));
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
wild.Delete();
}
}
}

View file

@ -18,6 +18,7 @@ using System.Net;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.Serialization;
using Server.Collections;
using Server.Engines.Virtues;
using Server.Gumps;
using Server.Items;
@ -1181,11 +1182,6 @@ public partial class ChampionSpawn : Item
foreach (var de in m.DamageEntries)
{
if (de.HasExpired)
{
continue;
}
var damager = de.Damager;
var master = damager.GetDamageMaster(m);

View file

@ -3123,14 +3123,12 @@ namespace Server.Mobiles
return base.OnBeforeDeath();
}
public int ComputeBonusDamage(List<DamageEntry> list, Mobile m)
public int ComputeBonusDamage(in ValueLinkList<DamageEntry> list, Mobile m)
{
var bonus = 0;
for (var i = list.Count - 1; i >= 0; --i)
foreach (var de in list.ByDescending())
{
var de = list[i];
if (de.Damager == m || de.Damager is not BaseCreature bc)
{
continue;
@ -3167,26 +3165,15 @@ namespace Server.Mobiles
Combatant is PlayerMobile ||
Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile;
public static List<DamageStore> GetLootingRights(List<DamageEntry> damageEntries, int hitsMax)
// Iterates most recent first, matching the previous reverse-indexed loop. The list is
// already pruned of expired entries by the Mobile.DamageEntries getter.
public static List<DamageStore> GetLootingRights(in ValueLinkList<DamageEntry> damageEntries, int hitsMax)
{
var rights = new List<DamageStore>();
DamageStore firstDamager = null;
for (var i = damageEntries.Count - 1; i >= 0; --i)
foreach (var de in damageEntries.ByDescending())
{
if (i >= damageEntries.Count)
{
continue;
}
var de = damageEntries[i];
if (de.HasExpired)
{
damageEntries.RemoveAt(i);
continue;
}
var damage = de.DamageGiven;
var respList = de.Responsible;

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Collections;
using Server.Engines.CannedEvil;
using Server.Engines.Virtues;
using Server.Items;

View file

@ -31,6 +31,7 @@ description: >
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;`
- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement
- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition
- `DamageEntries` is an inline `ref readonly ValueLinkList<DamageEntry>`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries
## Anti-Patterns
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)

View file

@ -517,6 +517,45 @@ periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The
acquired target comes from the normal FightMode-ranked scan, not from whichever mobile
happened to move. See `content-patterns.md` § Target Acquisition.
## Damage Entries: Inline `ValueLinkList`, Not `List<DamageEntry>`
RunUO's `Mobile.DamageEntries` was a `List<DamageEntry>` allocated for every mobile.
ModernUO keeps damage entries in an inline `ValueLinkList<DamageEntry>` struct held by
the mobile itself, ordered least recent → most recent, so a mobile that never takes
damage owns no list object and `RegisterDamage` relinks in O(1). The property is
`ref readonly`; expired entries are pruned when it is read.
```csharp
// RunUO
for (var i = m.DamageEntries.Count - 1; i >= 0; --i)
{
var de = m.DamageEntries[i]; // indexer
...
}
m.DamageEntries.Clear();
var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // List<DamageEntry>
// ModernUO — needs `using Server.Collections;` for the enumerator extensions
foreach (var de in m.DamageEntries.ByDescending()) // most recent first
{
...
}
foreach (var de in m.DamageEntries) // least recent first
{
...
}
m.ClearDamageEntries();
var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // in ValueLinkList<DamageEntry>
```
What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and
passing the property where a `List<DamageEntry>` is expected. `.Count`,
`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and
`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList`
link fields; never set them yourself, and never call a `ValueLinkList` mutator on the
`ref readonly` property — it compiles against a copy and corrupts the node's link state.
Mutate only through `RegisterDamage` and `ClearDamageEntries`.
## Item Name Changes
```csharp

View file

@ -134,6 +134,9 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same |
| `PathFollower.Follow(run, range)` | `Follow(range)` | Same |
| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior |
| `m.DamageEntries` (`List<DamageEntry>`) | `m.DamageEntries` (`ref readonly ValueLinkList<DamageEntry>`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` |
| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | |
| `GetLootingRights(List<DamageEntry>, int)` | `GetLootingRights(in ValueLinkList<DamageEntry>, int)` | Callers passing `m.DamageEntries` compile unchanged |
## Networking