ModernUO/dev-docs/claude-skills/modernuo-property-lists.md
Kamron Batman b8d3fec59a
fix(opl): refuse property list invalidation raised from inside GetProperties (#2555)
## The bug

Any property getter reached from `GetProperties` that calls `InvalidateProperties` takes the tooltip build down with it:

```
System.ArgumentNullException: Value cannot be null. (Parameter 'array')
   at Server.ObjectPropertyList.AppendStringDirect(String value)
   at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list)
```

`InvalidateProperties` rebuilds **in place** — `Reset()`, then `GetProperties()` again on the same instance — and `Reset()` does two destructive things to a build already in flight:

1. **It returns the pooled interpolation buffer.** The compiler rents it in the handler ctor and returns it in the closing `Add`, so *every hole is evaluated while it is live*:

```csharp
var handler = new InterpolatedStringHandler(1, 2, list); // InitializeInterpolation() RENTS
handler.AppendFormatted(pl.Rank.Title);                  // <-- getter runs HERE
handler.AppendLiteral("\t");
handler.AppendFormatted(faction.Definition.PropName);
list.Add(1060776, ref handler);                          // consumes span, RETURNS
```

```
GetProperties(list)
├─ InitializeInterpolation()  -> _arrayToReturnToPool = Rent(256)     buffer LIVE
├─ « hole 1: pl.Rank.Title »
│  └─ PlayerState.Rank.get   (lazy recompute)
│     └─ Invalidate() -> InvalidateProperties() -> m_PropertyList.Reset()
│        └─ Dispose(): Return(buf); _arrayToReturnToPool = null        buffer GONE
└─ handler.AppendFormatted("Knight")
   └─ _arrayToReturnToPool.AsSpan(_pos..)
      └─ ArgumentNullException (Parameter 'array')
```

It surfaces as `ArgumentNullException` rather than `NullReferenceException` because the `Range` overload of `AsSpan` must read `array.Length`, so the BCL null-checks and names the parameter `array`.

2. **It rewinds the packet cursor**, so properties already written are overwritten by the nested pass — a silently corrupted tooltip even where the buffer survives.

## The fix: refuse, don't recover

There is no correct recovery, and retrying the build would only hide the defect. A nested invalidation now logs an error with a stack trace, **throws in `DEBUG`** so it gets found and fixed, and in `RELEASE` returns without touching the list — a possibly stale tooltip, but no crash, no corrupted packet, and nothing leaked back to the pool. Getters that genuinely must invalidate should defer:

```csharp
Timer.DelayCall(InvalidateProperties);
```

The guard flag lives on the `ObjectPropertyList`, not the entity: it is that list's own lifecycle, it costs nothing (both `Item` and `ObjectPropertyList` absorb it in existing padding, and the list is allocated lazily), and it stays correct when builds for different entities nest.

Base instance sizes are unchanged from `main`: Item 128 B, Mobile 792 B, ObjectPropertyList 72 B, PlayerMobile 1216 B.

`PropertyList` also publishes the list into `m_PropertyList` **before** building it rather than assigning through `??=` afterwards, so a nested `InvalidateProperties` sees the build in progress instead of recursing into a second throwaway list whose work is discarded.

`ObjectPropertyList` re-rents its scratch buffer instead of spanning a null array, so a stray `Reset()` from any other caller degrades rather than aborting `GetProperties`.

## Factions `PlayerState`: maintained, not lazily computed

The getter that surfaced this is now a plain field read — the whole `if (m_InvalidateRank)` block and the flag itself are gone:

```csharp
public RankDefinition Rank => m_Rank;
```

`UpdateRank()` recomputes at each point an input actually changes:

| Site | Why |
|---|---|
| `RankIndex` setter | this player's index changed |
| end of `KillPoints` setter | two paths write `m_RankIndex` directly, bypassing the setter; runs once the swap bookkeeping and `ZeroRankOffset` have settled |
| `Faction.AddMember` | *after* the insert — the member count is not settled during the ctor |
| `FactionState` load | once ordering and `ZeroRankOffset` are final |

Supporting fixes this forced out:

- **Both ctors seed the lowest rank.** Nothing recomputes on read any more, so `Rank` has to be usable immediately — including for members that never get a `RankIndex` assigned, which is *every member with no kill points*. Without this, `Rank.Title` NREs.
- **`Rank` always resolves.** Ranks are ordered by `Required` descending ending at `0`, so a *negative* percent (`RankIndex` out of sync with `ZeroRankOffset`) matched nothing and left `m_Rank` null. It no longer divides by a zero `ZeroRankOffset` either.
- **A pre-existing staleness bug.** The `KillPoints` setter writes `m_RankIndex` directly in two places, so the cached rank was never refreshed when a player crossed zero kill points.

All six readers of `Rank` were checked; none relied on the old side effect.

One behaviour change worth flagging: rank refreshes are now **eager** where they used to be lazy, so a `KillPoints` change invalidates each swapped player as it happens. The swap loops break as soon as ordering is satisfied — typically 0–2 swaps — but it is on the path that runs on every faction kill.

## Documentation

The rule is written down so it is enforceable rather than folklore:

- **CLAUDE.md** audit rule 19
- **`dev-docs/property-lists.md`** — new "Never Invalidate From Inside `GetProperties`" section with the failing/passing pattern
- **`dev-docs/claude-skills/modernuo-property-lists.md`** — key rule + anti-pattern
- **`dev-docs/claude-skills/modernuo-code-audit.md`** — rule 19, ERROR severity

## Tests

- `ObjectPropertyListReentrancyTests` — `Reset()` and `Dispose()` re-entered mid-hole (both red against `main` with the exact exception above), nesting behaviour, and the new contract: `DEBUG` throws, `RELEASE` survives, and the build is never retried into a loop.
- `FactionRankTests` — `Rank` is populated before anything reads it, tracks `RankIndex` without a read, is stable across reads, and still resolves when `RankIndex` is out of sync with `ZeroRankOffset`. Red-verified: removing the ctor seed fails the first one.

793/793 `Server.Tests` and 608/608 `UOContent.Tests` pass.

## Noted, not addressed here

`~ObjectPropertyList()` returns the rented array to `STArrayPool<char>.Shared` from the **finalizer thread**, and that pool is single-threaded by design. Left alone as a separate concern.
2026-07-28 21:29:03 -07:00

11 KiB

name description
modernuo-property-lists Trigger when implementing GetProperties(), working with IPropertyList/ObjectPropertyList, or customizing item tooltips.

ModernUO Property Lists (Tooltips)

When This Activates

  • Implementing GetProperties() override
  • Working with IPropertyList or ObjectPropertyList
  • Customizing item or mobile tooltips
  • Using [InvalidateProperties] attribute
  • Adding cliloc-based text to items

Key Rules

  1. Always call base.GetProperties(list) first in overrides
  2. Use cliloc numbers when possible (int IDs that map to localized strings)
  3. String interpolation works with IPropertyList -- use $"..." syntax
  4. [InvalidateProperties] on [SerializableField] auto-refreshes tooltip on change
  5. Call InvalidateProperties() manually when non-serialized state changes tooltip
  6. Never invalidate from inside GetProperties -- every property a GetProperties override reads must be a pure read. InvalidateProperties() rebuilds in place (Reset() + rebuild), so a getter with that side effect tears down the list mid-build: it returns the pooled interpolation buffer under an in-flight $"..." handler (ArgumentNullException, parameter "array") and rewinds the packet cursor. The engine refuses and logs an error; DEBUG throws. Defer instead: Timer.DelayCall(InvalidateProperties)

IPropertyList Interface

public interface IPropertyList
{
    void Add(int number);                     // Cliloc number only
    void Add(int number, string argument);    // Cliloc with ~1_val~ arg
    void Add(ReadOnlySpan<char> argument);    // Raw text, no string alloc (uses passthrough cliloc)
    void Add(int number, ReadOnlySpan<char> argument);
    void AddChunked(ReadOnlySpan<char> text); // Newline-joined text, split across properties
    OplTextBlock TextBlock();                 // Builder that flushes via AddChunked on dispose
    void Add(int number, int value);          // Cliloc with int arg
    void AddLocalized(int value);             // Cliloc number as value
    void AddLocalized(int number, int value); // Cliloc wrapper for cliloc

    // String interpolation overloads
    void Add(ref InterpolatedStringHandler handler);
    void Add(int number, ref InterpolatedStringHandler handler);
}

No Add(string) overload — pass a span (text.AsSpan()) or, preferably, an interpolated $"..." literal so the handler formats straight into the pooled buffer.

Patterns

Basic GetProperties Override

public override void GetProperties(IPropertyList list)
{
    base.GetProperties(list);  // ALWAYS call base first

    list.Add(1060741, $"{_charges}");          // "charges: ~1_val~"
    list.Add($"{"Quality: "}{_quality}");          // Raw string
    list.Add(1060637, $"{_uses}\t{_maxUses}"); // "~1_val~ / ~2_val~"
}

Cliloc Arguments Format

Cliloc strings use ~1_val~, ~2_val~, etc. as placeholders. Arguments are tab-separated:

// Cliloc 1060637 = "~1_val~ / ~2_val~"
list.Add(1060637, $"{current}\t{max}");

// Cliloc 1072241 = "Contents: ~1_ITEMS~/~2_MAXITEMS~ items, ~3_WEIGHT~/~4_MAXWEIGHT~ stones"
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");

// Cliloc 1042971 = "~1_val~" (generic single argument)
list.Add(1042971, $"{"Custom text here"}");

String Literals Must Be Holes (CRITICAL)

The interpolated string handler distinguishes literals (bare text between {} holes) from holes (values inside {}). Literals are delimiters. Holes are arguments. This matters because the property list system is also used for web rendering, which must tell arguments apart from delimiters.

String constants must always be wrapped as holes: {"..."}

// BAD — "Chances" becomes a literal/delimiter, not an argument
list.Add(1060658, $"Chances\t{_charges}");

// GOOD — "Chances" is a hole → argument ~1_val~
list.Add(1060658, $"{"Chances"}\t{_charges}");

Real examples (Teleporter.cs):

list.Add(1060658, $"{"Map"}\t{_mapDest}");       // "~1_val~: ~2_val~"
list.Add(1060659, $"{"Coords"}\t{_pointDest}");
list.Add(1060661, $"{"Range"}\t{_range}");

Rule: Only \t (argument separator) should be bare literal text. Everything else — including string constants — must be inside {} holes.

No .ToString() Inside Holes

IPropertyList's handler formats values directly via ISpanFormattable.TryFormat — no intermediate string allocation per hole. An explicit .ToString() defeats this:

// BAD — .ToString() allocates a string the handler then re-buffers
list.Add(1060658, $"{"Charges"}\t{_charges.ToString()}");

// GOOD — handler formats _charges directly with no intermediate string
list.Add(1060658, $"{"Charges"}\t{_charges}");

Same applies to .String() (TextDefinition), .GetValue(), etc. The full list of interpolation anti-patterns (ternaries, switch expressions, pre-built locals, string.Format, concat in hole, LINQ in hole) applies equally to IPropertyList.Add($"..."). See dev-docs/string-handling.md § "Interpolation Anti-Patterns" or dev-docs/claude-skills/modernuo-string-handling.md.

Cliloc as Argument (Use :# Format Specifier)

When an argument is itself a cliloc number, use the :# format specifier — not a "#number" string:

// BAD — "#1060000" is a string, web renderers will display it literally
list.Add(1050039, $"{m_Amount}\t{"#1060000"}");

// GOOD — :# tells the handler this is a cliloc number to resolve
list.Add(1050039, $"{m_Amount}\t{1060000:#}");

The :# format lets the handler (and other consumers like web renderers) know the value is a cliloc reference to resolve, not a raw number. Also available via list.AddLocalized(number, clilocValue).

Looking Up Cliloc Text

If you don't know what arguments a cliloc number expects, you can read the cliloc.enu binary file. Loading logic is in Projects/Server/Localization/Localization.csLoadClilocs(string lang, string file). Ask the user where their cliloc.enu file is (typically in the UO client data directory).

Auto-Refresh with [InvalidateProperties]

[SerializableField(0)]
[InvalidateProperties]  // Auto-calls InvalidateProperties() when Charges changes
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;

Manual Refresh

public void UseCharge()
{
    _charges--;
    InvalidateProperties();  // Manually trigger tooltip refresh
    this.MarkDirty();
}

Conditional Properties

public override void GetProperties(IPropertyList list)
{
    base.GetProperties(list);

    if (_charges > 0)
        list.Add(1060741, $"{_charges}");

    if (_owner != null)
        list.Add($"{"Owned by: "}{_owner.Name}");

    if (Core.AOS)  // Era-conditional properties
        list.Add(1061170, $"{_imbueLevel}");  // "animal " ~1_val~
}

Mobile Properties

public override void GetProperties(IPropertyList list)
{
    base.GetProperties(list);

    if (Core.AOS && Faction != null)
    {
        list.Add(1060776, $"{Rank.Title}\t{Faction.Definition.PropName}");
    }

    if (DisplayChampionTitle)
    {
        var titleLabel = ChampionTitleSystem.GetChampionTitleLabel(this);
        if (titleLabel > 0)
            list.Add(titleLabel);
    }
}

Multi-Line Free Text (AddChunked / OplTextBlock)

For variable-length, free-form (non-cliloc) tooltip text — e.g. a consolidated attribute dump or staged ID text — never emit it as one property. The legacy 2D client copies each OPL property into a fixed ~512-char buffer; a longer property smashes the client heap and crashes it. ObjectPropertyList.MaxArgumentLength (504) is the safe per-property cap.

Two safe APIs split \n-joined text across multiple passthrough-cliloc properties (each ≤ cap):

// Already have a '\n'-joined string? Use the IPropertyList primitive directly:
list.AddChunked(_description);

// Building lines conditionally? Use the OplTextBlock builder (flushes via AddChunked on dispose):
using var block = list.TextBlock();         // IPropertyList.TextBlock()
block.Add($"Lower Parry Cap {cap}%");       // zero-alloc interpolated overload
block.Add("Cannot be repaired".AsSpan());   // plain span, no string alloc
  • Lines join with \n; empty lines are skipped; no lines → nothing emitted.
  • block.Add($"...") is a real [InterpolatedStringHandler] — same hole anti-patterns apply (no .ToString(), ternaries, concat).
  • OplTextBlock is a ref struct for the single-threaded build pass — always using, never store/await across it.

Common Cliloc Numbers

Number Text Usage
1042971 ~1_val~ Generic single argument
1060741 charges: ~1_val~ Charge count
1060637 ~1_val~ / ~2_val~ Current/max values
1060658 ~1_val~: ~2_val~ Key: value pair
1050044 ~1_ITEMS~ items, ~2_WEIGHT~ stones Container contents
1072241 Contents: ~1~/~2~ items, ~3~/~4~ stones ML container
1060776 ~1_val~, ~2_val~ Two comma-separated values
1061170 animal lore ~1_val~ Taming info
1053099 damage ~1_val~ - ~2_val~ Damage range

ObjectPropertyList Internals

  • Packet ID: 0xD6
  • Hash-based change detection -- only sends if content actually changed
  • InvalidateProperties() rebuilds the list and compares hash
  • Uses STArrayPool<char> for string building (zero GC)
  • Global toggle: ObjectPropertyList.Enabled

Anti-Patterns

  • Forgetting base.GetProperties(list): Loses default name/weight display
  • Not using cliloc: Raw strings don't get localized
  • Excessive rebuilds: Don't call InvalidateProperties() in tight loops
  • Assuming tooltip support: Check ObjectPropertyList.Enabled if needed
  • One giant Add() for multi-line text: A property over ~512 chars crashes the legacy 2D client. Use AddChunked/OplTextBlock for variable-length free text
  • Side-effecting property getters: A getter reached from GetProperties that calls InvalidateProperties() (directly or via a helper like Invalidate()) re-enters the build and is refused — error logged, DEBUG throws. Lazy recomputation in a getter is fine; the notification is not. Invalidate where the value changes, or Timer.DelayCall(InvalidateProperties)

Real Examples

  • Item properties: Projects/Server/Items/Item.cs (AddNameProperties, GetProperties)
  • Mobile properties: Projects/UOContent/Mobiles/PlayerMobile.cs (GetProperties)
  • Container properties: Projects/Server/Items/Container.cs (era-conditional display)
  • Interface: Projects/Server/PropertyList/IPropertyList.cs
  • Implementation: Projects/Server/PropertyList/ObjectPropertyList.cs

See Also

  • dev-docs/property-lists.md - Complete property list documentation
  • dev-docs/claude-skills/modernuo-serialization.md - [InvalidateProperties] on fields
  • dev-docs/claude-skills/modernuo-era-expansion.md - Era-conditional properties