## 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.
256 lines
11 KiB
Markdown
256 lines
11 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
|
|
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
|
|
|
|
```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
|
|
- **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
|