## At a glance
```csharp
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
// Accumulate any number of free-text lines. On dispose the block flushes via
// AddChunked, splitting across as many OPL properties as needed so none can
// overflow the legacy 2D client's per-property buffer (which would crash it).
using var block = list.TextBlock();
if (luck > 0)
{
block.Add($"Luck Bonus: +{luck}%"); // zero-alloc interpolation
}
block.Add("Cannot be repaired".AsSpan()); // plain text, no string allocation
// Already holding a '\n'-joined string? Skip the builder and chunk directly:
// list.AddChunked(description);
}
```
## What
Adds a safe path for emitting **variable-length, free-form (non-cliloc) tooltip text**:
- **`ObjectPropertyList.Add(ReadOnlySpan<char>)`** overloads — append raw text with no string allocation. Makes the old single-arg `Add(string)` redundant (a `string` binds to the span overload implicitly), so it's dropped.
- **`AddChunked(ReadOnlySpan<char>)`** on `IPropertyList` — splits newline-joined text at `\n` boundaries across as many passthrough-cliloc properties as needed, so no single property exceeds the cap.
- **`OplTextBlock`** (`ref struct`) + **`IPropertyList.TextBlock()`** — an ergonomic builder that accumulates `\n`-joined lines (with a zero-alloc interpolated `Add($"...")` overload) and flushes via `AddChunked` on dispose. Usage: `using var block = list.TextBlock();`.
- **`MaxArgumentLength` (504)** — per-property cap with a hard backstop that clamps + logs anything that slips through.
## Why
The legacy 2D client copies each OPL property's text into a fixed ~512-char (1024-byte) buffer. A single property longer than that smashes an adjacent world object's vtable on the client heap and crashes the client. `AddChunked`/`OplTextBlock` keep multi-line content safely under the cap instead of risking one oversized `Add`.
## Docs
- `dev-docs/property-lists.md` — new "Multi-Line Free Text" deep-dive section; corrected the stale `IPropertyList` listing.
- `dev-docs/claude-skills/modernuo-property-lists.md` — condensed pattern + anti-pattern.
## Tests
9 tests pass (`OplTextBlockTests`, `ObjectPropertyListSpanAddTests`): line joining, empty-line skipping, no-line no-op, zero-alloc interpolation, and long-content chunking staying under `MaxArgumentLength`. Full `UOContent` build is green, confirming dropping `Add(string)` breaks no call sites.
9.9 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
IPropertyListorObjectPropertyList - Customizing item or mobile tooltips
- Using
[InvalidateProperties]attribute - Adding cliloc-based text to items
Key Rules
- Always call
base.GetProperties(list)first in overrides - Use cliloc numbers when possible (int IDs that map to localized strings)
- String interpolation works with
IPropertyList-- use$"..."syntax [InvalidateProperties]on[SerializableField]auto-refreshes tooltip on change- Call
InvalidateProperties()manually when non-serialized state changes tooltip
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.cs → LoadClilocs(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).OplTextBlockis aref structfor the single-threaded build pass — alwaysusing, 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.Enabledif needed - One giant
Add()for multi-line text: A property over ~512 chars crashes the legacy 2D client. UseAddChunked/OplTextBlockfor variable-length free text
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 documentationdev-docs/claude-skills/modernuo-serialization.md- [InvalidateProperties] on fieldsdev-docs/claude-skills/modernuo-era-expansion.md- Era-conditional properties