ModernUO/dev-docs/claude-skills/modernuo-property-lists.md
Kamron Batman d7668df5ee
feat(opl): OplTextBlock multi-line tooltip builder + AddChunked (#2507)
## 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.
2026-07-02 19:41:36 -07:00

249 lines
9.9 KiB
Markdown

---
name: modernuo-property-lists
description: >
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
## IPropertyList Interface
```csharp
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
```csharp
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:
```csharp
// 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: `{"..."}`**
```csharp
// 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`):
```csharp
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:
```csharp
// 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:
```csharp
// 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]
```csharp
[SerializableField(0)]
[InvalidateProperties] // Auto-calls InvalidateProperties() when Charges changes
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
```
### Manual Refresh
```csharp
public void UseCharge()
{
_charges--;
InvalidateProperties(); // Manually trigger tooltip refresh
this.MarkDirty();
}
```
### Conditional Properties
```csharp
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
```csharp
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):
```csharp
// 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
## 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