diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs new file mode 100644 index 000000000..408c53386 --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; +using Server; +using Xunit; + +namespace Server.Tests; + +public class ObjectPropertyListSpanAddTests +{ + // Decodes a terminated OPL buffer into (cliloc, argument) entries. + // The OPL packet is big-endian (SpanWriter default), so use BinaryPrimitives. + private static (int cliloc, string arg)[] Decode(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var entries = new List<(int, string)>(); + var pos = 15; // header is 15 bytes + while (true) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + var arg = Encoding.Unicode.GetString(buffer, pos, byteLen); + pos += byteLen; + entries.Add((cliloc, arg)); + } + + return entries.ToArray(); + } + + [Fact] + public void SpanAdd_ProducesSameBytesAsStringArgument() + { + // Add(int, string) routes through the interpolation InternalAdd; Add(int, ReadOnlySpan) + // through the span InternalAdd. Both must produce identical bytes and hash. + var fromString = new ObjectPropertyList(null); + fromString.Add(1070722, "Hello World"); + + var fromSpan = new ObjectPropertyList(null); + fromSpan.Add(1070722, "Hello World".AsSpan()); + + Assert.Equal(Decode(fromString), Decode(fromSpan)); + Assert.Equal(fromString.Hash, fromSpan.Hash); + } + + [Fact] + public void SpanAdd_WithNumber_EmitsClilocAndArgument() + { + var opl = new ObjectPropertyList(null); + opl.Add(1070722, "Custom".AsSpan()); + + var entries = Decode(opl); + Assert.Single(entries); + Assert.Equal((1070722, "Custom"), entries[0]); + } + + [Fact] + public void Add_TruncatesArgumentOverMaxLength() + { + var oversized = new string('x', ObjectPropertyList.MaxArgumentLength + 50); + + var opl = new ObjectPropertyList(null); + opl.Add(1070722, oversized.AsSpan()); + + var entries = Decode(opl); + Assert.Single(entries); + Assert.Equal(ObjectPropertyList.MaxArgumentLength, entries[0].arg.Length); + } + + [Fact] + public void AddChunked_SplitsAtNewlinesSoNoEntryExceedsCap() + { + // 10 lines x 100 chars joined by '\n' (~1009 chars), well over the cap. + var lines = new string[10]; + for (var i = 0; i < lines.Length; i++) + { + lines[i] = new string((char)('a' + i), 100); + } + + var text = string.Join("\n", lines); + + var opl = new ObjectPropertyList(null); + opl.AddChunked(text.AsSpan()); + + var entries = Decode(opl); + Assert.True(entries.Length > 1, "expected multiple chunks"); + foreach (var (_, arg) in entries) + { + Assert.True(arg.Length <= ObjectPropertyList.MaxArgumentLength); + } + + // AddChunked breaks only at '\n' (dropping that '\n'), so rejoining with '\n' is lossless. + var rejoined = string.Join("\n", Array.ConvertAll(entries, e => e.arg)); + Assert.Equal(text, rejoined); + } +} diff --git a/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs b/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs new file mode 100644 index 000000000..71dd163d8 --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; +using Server; +using Xunit; + +namespace Server.Tests; + +public class OplTextBlockTests +{ + private static (int cliloc, string arg)[] Decode(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var entries = new List<(int, string)>(); + var pos = 15; + while (true) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + entries.Add((cliloc, Encoding.Unicode.GetString(buffer, pos, byteLen))); + pos += byteLen; + } + + return entries.ToArray(); + } + + [Fact] + public void MultipleLines_JoinIntoSingleCyclingEntry() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + block.Add("Line One".AsSpan()); + block.Add("Line Two".AsSpan()); + } + + var entries = Decode(opl); + Assert.Single(entries); + Assert.Equal((1042971, "Line One\nLine Two"), entries[0]); // first cycling cliloc + } + + [Fact] + public void EmptyLines_AreSkipped() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + block.Add("Only".AsSpan()); + block.Add(ReadOnlySpan.Empty); + } + + Assert.Equal((1042971, "Only"), Decode(opl)[0]); + } + + [Fact] + public void NoLines_EmitsNothing() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + // add nothing + } + + Assert.Empty(Decode(opl)); + } + + [Fact] + public void Interpolated_AppendsZeroAlloc() + { + var opl = new ObjectPropertyList(null); + var v = 5; + using (var block = opl.TextBlock()) + { + block.Add($"Luck Bonus: +{v}%"); + } + + Assert.Equal((1042971, "Luck Bonus: +5%"), Decode(opl)[0]); + } + + [Fact] + public void LongContent_SplitsIntoMultipleEntriesUnderCap() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + for (var i = 0; i < 10; i++) + { + block.Add(new string((char)('a' + i), 100).AsSpan()); + } + } + + var entries = Decode(opl); + Assert.True(entries.Length > 1, "expected the long block to chunk into multiple entries"); + foreach (var (_, arg) in entries) + { + Assert.True(arg.Length <= ObjectPropertyList.MaxArgumentLength); + } + } +} diff --git a/Projects/Server/PropertyList/IPropertyList.cs b/Projects/Server/PropertyList/IPropertyList.cs index 54b41e4c6..d6b529823 100644 --- a/Projects/Server/PropertyList/IPropertyList.cs +++ b/Projects/Server/PropertyList/IPropertyList.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * *************************************************************************/ +using System; using System.Runtime.CompilerServices; using Server.Text; @@ -28,8 +29,17 @@ public interface IPropertyList : ISelfInterpolatedStringHandler /** Convenience method for $"{argument}". */ public void Add(int number, string argument); - /** Convenience method for $"{text}". */ - public void Add(string text); + /** Convenience method for span-based text without allocating a string. */ + public void Add(ReadOnlySpan argument); + + /** Convenience method for span-based text without allocating a string. */ + public void Add(int number, ReadOnlySpan argument); + + /** Emits newline-joined text across multiple properties so none exceeds the legacy client's per-property buffer. */ + public void AddChunked(ReadOnlySpan text); + + /** Builder for variable-length multi-line free text; flushes via AddChunked on dispose. */ + public OplTextBlock TextBlock(); /** Convenience method for $"{value}". */ public void Add(int number, int value); diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index d1e15abd8..73288f5e1 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -20,6 +20,7 @@ using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using Server.Buffers; +using Server.Logging; using Server.Network; using Server.Text; @@ -37,6 +38,14 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable 1114779 // ~1_val~ }; + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectPropertyList)); + + // Max characters for a SINGLE OPL property argument. The legacy 2D client copies each + // property's text into a fixed ~512-char (1024-byte) buffer; exceeding it corrupts the heap + // (smashes an adjacent world object's vtable -> client crash). 504 = multiple of 8, safely + // under the empirically confirmed ~510-char ceiling. For multi-line content use AddChunked(). + public const int MaxArgumentLength = 504; + private int _hash; private int _stringNumbersIndex; private byte[] _buffer; @@ -148,11 +157,106 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable } public void Add(int number, string? arguments) => InternalAdd(number, $"{arguments}"); - public void Add(string argument) => InternalAdd(GetStringNumber(), $"{argument}"); public void Add(int number, int value) => InternalAdd(number, $"{value}"); public void AddLocalized(int value) => InternalAdd(GetStringNumber(), $"{value:#}"); public void AddLocalized(int number, int value) => InternalAdd(number, $"{value:#}"); + public void Add(ReadOnlySpan argument) => InternalAdd(GetStringNumber(), argument); + public void Add(int number, ReadOnlySpan argument) => InternalAdd(number, argument); + public OplTextBlock TextBlock() => new(this); + + // Emits newline-joined text across as many OPL properties as needed, breaking ONLY at '\n', + // so no single property exceeds MaxArgumentLength characters. Each chunk goes through the + // passthrough-cliloc rotation. Use for variable-length multi-line content instead of one + // Add(joined) call, which would overflow the legacy 2D-client per-property tooltip buffer. + public void AddChunked(ReadOnlySpan text) + { + if (text.IsEmpty) + { + return; + } + + var chunkStart = 0; + var searchFrom = 0; + + while (true) + { + var nl = text[searchFrom..].IndexOf('\n'); + var lineEnd = nl < 0 ? text.Length : searchFrom + nl; + + // If appending this line would push the current chunk past the cap, flush the chunk + // up to the end of the previous line (excluding its '\n') first. + if (lineEnd - chunkStart > MaxArgumentLength && searchFrom > chunkStart) + { + Add(text[chunkStart..(searchFrom - 1)]); + chunkStart = searchFrom; + continue; + } + + if (nl < 0) + { + Add(text[chunkStart..]); // remainder (a single over-cap line is clamped by Add) + return; + } + + searchFrom = lineEnd + 1; + } + } + + // Hard backstop: never let a single property exceed the legacy client's per-property buffer. + // Callers with multi-line content should use AddChunked(); this truncates anything that slips + // through and surfaces the offending entity/cliloc. + private ReadOnlySpan ClampArgument(int number, ReadOnlySpan chars) + { + if (chars.Length <= MaxArgumentLength) + { + return chars; + } + + logger.Warning( + "OPL property on {Entity} (cliloc {Cliloc}) is {Length} chars; truncating to {Max} to avoid legacy 2D-client tooltip-buffer overflow. Use AddChunked for multi-line text.", + Entity, + number, + chars.Length, + MaxArgumentLength + ); + + return chars[..MaxArgumentLength]; + } + + private void InternalAdd(int number, ReadOnlySpan chars) + { + if (number == 0) + { + return; + } + + chars = ClampArgument(number, chars); + + if (Header == 0) + { + Header = number; + HeaderArgs = chars.ToString(); + } + + AddHash(number); + AddHash(string.GetHashCode(chars, StringComparison.Ordinal)); + + var strLength = chars.Length * 2; + var length = _bufferPos + 6 + strLength; + while (length > _buffer.Length) + { + Flush(); + } + + var writer = new SpanWriter(_buffer.AsSpan(_bufferPos)); + writer.Write(number); + writer.Write((ushort)strLength); + writer.Write(chars, TextEncoding.UnicodeLE); + + _bufferPos += writer.BytesWritten; + } + private int GetStringNumber() => _stringNumbers[_stringNumbersIndex++ % _stringNumbers.Length]; // String Interpolation @@ -177,7 +281,7 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable return; } - var chars = _arrayToReturnToPool.AsSpan(0, _pos); + var chars = ClampArgument(number, _arrayToReturnToPool.AsSpan(0, _pos)); if (Header == 0) { diff --git a/Projects/Server/PropertyList/OplTextBlock.cs b/Projects/Server/PropertyList/OplTextBlock.cs new file mode 100644 index 000000000..296094a21 --- /dev/null +++ b/Projects/Server/PropertyList/OplTextBlock.cs @@ -0,0 +1,113 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: OplTextBlock.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; +using Server.Text; + +namespace Server; + +// Accumulates '\n'-joined free-text lines and emits them on dispose via AddChunked, which splits +// into as many cycling passthrough entries as needed so no single OPL property exceeds the legacy +// client's per-property buffer (ObjectPropertyList.MaxArgumentLength). +// Use with `using var block = list.TextBlock();`. ref struct: single-threaded OPL build only. +public ref struct OplTextBlock +{ + private readonly IPropertyList _list; + internal ValueStringBuilder _builder; + private bool _any; + + internal OplTextBlock(IPropertyList list) + { + _list = list; + _builder = ValueStringBuilder.Create(); + _any = false; + } + + public void Add(scoped ReadOnlySpan line) + { + if (line.IsEmpty) + { + return; + } + + _builder.Append(line); + _builder.Append('\n', 1); // ValueStringBuilder has no single-char Append + _any = true; + } + + // Zero-alloc interpolated overload: block.Add($"Luck Bonus: +{v}%"). + public void Add([InterpolatedStringHandlerArgument("")] scoped ref OplInterpolationHandler handler) + { + var wrote = handler._wrote; + this = handler._block; // reconcile possibly-grown builder + if (wrote) + { + _builder.Append('\n', 1); + _any = true; + } + } + + public void Dispose() + { + if (_any) + { + // Strip the trailing '\n' (Length >= 2 whenever _any: content + separator). + // AddChunked splits across multiple properties so a long block never overflows the + // legacy client's per-property tooltip buffer. + _list.AddChunked(_builder.AsSpan(0, _builder.Length - 1)); + } + + _builder.Dispose(); + } + + [InterpolatedStringHandler] + public ref struct OplInterpolationHandler + { + internal OplTextBlock _block; + internal bool _wrote; + + public OplInterpolationHandler(int literalLength, int formattedCount, OplTextBlock block) + { + _block = block; + _wrote = false; + _block._builder.EnsureCapacity(_block._builder.Length + literalLength + formattedCount * 11); + } + + public void AppendLiteral(string value) + { + _block._builder.Append(value); + _wrote = true; + } + + public void AppendFormatted(T value) + { + _block._builder.Append(value); + _wrote = true; + } + + public void AppendFormatted(T value, string format) + { + _block._builder.Append(value, format); + _wrote = true; + } + + public void AppendFormatted(scoped ReadOnlySpan value) + { + _block._builder.Append(value); + _wrote = true; + } + } +} diff --git a/dev-docs/claude-skills/modernuo-property-lists.md b/dev-docs/claude-skills/modernuo-property-lists.md index b9e018cdf..4440c63db 100644 --- a/dev-docs/claude-skills/modernuo-property-lists.md +++ b/dev-docs/claude-skills/modernuo-property-lists.md @@ -26,11 +26,14 @@ description: > ```csharp public interface IPropertyList { - void Add(int number); // Cliloc number only - void Add(int number, string argument); // Cliloc with ~1_val~ arg - void Add(string text); // Raw string (uses internal cliloc) - void Add(int number, int value); // Cliloc with int arg - void AddLocalized(int value); // Cliloc number as value + void Add(int number); // Cliloc number only + void Add(int number, string argument); // Cliloc with ~1_val~ arg + void Add(ReadOnlySpan argument); // Raw text, no string alloc (uses passthrough cliloc) + void Add(int number, ReadOnlySpan argument); + void AddChunked(ReadOnlySpan 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 @@ -39,6 +42,9 @@ public interface IPropertyList } ``` +> 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 @@ -177,6 +183,29 @@ public override void GetProperties(IPropertyList list) } ``` +### 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 | @@ -205,6 +234,7 @@ public override void GetProperties(IPropertyList list) - **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) diff --git a/dev-docs/property-lists.md b/dev-docs/property-lists.md index da0ed40e8..98e26efc1 100644 --- a/dev-docs/property-lists.md +++ b/dev-docs/property-lists.md @@ -13,13 +13,19 @@ The system uses cliloc numbers (localized string IDs) with argument substitution Defined in `Projects/Server/PropertyList/IPropertyList.cs`: ```csharp -public interface IPropertyList +public interface IPropertyList : ISelfInterpolatedStringHandler { - void Add(int number); // Cliloc number only - void Add(int number, string argument); // Cliloc with string argument - void Add(string text); // Raw string - void Add(int number, int value); // Cliloc with int argument - void AddLocalized(int value); // Cliloc number as argument value + void Reset(); + void Terminate(); + + void Add(int number); // Cliloc number only + void Add(int number, string argument); // Cliloc with string argument + void Add(ReadOnlySpan argument); // Raw text, no string allocation + void Add(int number, ReadOnlySpan argument); // Cliloc with span argument + void AddChunked(ReadOnlySpan 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 argument + void AddLocalized(int value); // Cliloc number as argument value void AddLocalized(int number, int value); // Cliloc with localized argument // String interpolation support @@ -28,6 +34,9 @@ public interface IPropertyList } ``` +> There is no `Add(string)` overload. Pass raw text as a span (`Add(text.AsSpan())`) or, ideally, +> as an interpolated `$"..."` literal so the handler formats straight into the pooled buffer. + ## GetProperties Override Override `GetProperties()` to add custom tooltip lines: @@ -141,6 +150,100 @@ If you don't know what text a cliloc number maps to (and therefore what argument - Placeholders in the text look like `~1_val~`, `~2_AMOUNT~`, etc. - Ask the user where their `cliloc.enu` file is located (typically in the UO client data directory) +## Multi-Line Free Text: `AddChunked` and `OplTextBlock` + +Some tooltips need a block of **free-form text** (not cliloc lookups) whose length is variable and +potentially large — e.g. a consolidated dump of every AOS attribute on an item, or staged +identification text that grows as the item is identified. Emitting that as a single property is +dangerous: + +> **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. `ObjectPropertyList.MaxArgumentLength = 504` is the safe per-property cap (a +> multiple of 8, comfortably under the empirically confirmed ~510-char ceiling). + +Two APIs handle this safely. Both split the text at `\n` boundaries into as many OPL properties as +needed, so no single property ever exceeds `MaxArgumentLength`. Each chunk is emitted through the +cycling **passthrough clilocs** (`1042971`, `1070722`, `1114057`, `1114778`, `1114779` — each +localized to a single `~1_val~`/`~1_NOTHING~` argument), which render the raw string verbatim. + +### `AddChunked` — the interface primitive + +`AddChunked(ReadOnlySpan)` is on `IPropertyList`, so it works from any `GetProperties(IPropertyList list)` +override. Give it `\n`-joined text; it breaks **only** at newlines: + +```csharp +public override void GetProperties(IPropertyList list) +{ + base.GetProperties(list); + + // _description may be hundreds of chars and contains embedded '\n's. + list.AddChunked(_description); +} +``` + +Splitting only at `\n` means each line stays intact across the chunk boundary — a chunk is flushed at +the last newline that keeps it under the cap. (A single line longer than `MaxArgumentLength` is the +one case that still gets clamped; the engine logs a warning naming the offending entity and cliloc.) + +### `OplTextBlock` — the ergonomic builder + +`OplTextBlock` is a `ref struct` builder that accumulates `\n`-joined lines and calls `AddChunked` for +you on dispose. Obtain it from `IPropertyList.TextBlock()` (so it works in any `GetProperties` override) +and always scope it with `using` so the flush happens: + +```csharp +using var block = list.TextBlock(); + +// Zero-alloc interpolated overload — formats directly into the pooled buffer: +block.Add($"Luck Bonus: +{luck}%"); +block.Add($"Damage: {min} - {max}"); + +// Plain text as a span (no string allocation): +block.Add("Cannot be repaired".AsSpan()); +``` + +Behavior worth knowing: + +- **Lines join with `\n`**; the trailing separator is stripped before the single `AddChunked` flush on `Dispose()`. +- **Empty lines are skipped** (`block.Add(ReadOnlySpan.Empty)` is a no-op). +- **No lines added → nothing is emitted** (no empty property, no wasted passthrough cliloc). +- The `Add($"...")` overload is a dedicated `[InterpolatedStringHandler]`, so interpolation allocates no + intermediate strings — the same anti-patterns as `IPropertyList.Add($"...")` apply (no `.ToString()` + in holes, no ternaries/`string.Format`/concat — see [`string-handling.md`](string-handling.md#interpolation-anti-patterns)). +- It is a `ref struct` tied to the single-threaded OPL build pass — never store it, capture it in a + closure, or use it across an `await`. + +### When to use which + +| Situation | Use | +|---|---| +| You already have a `\n`-joined string (e.g. a serialized description) | `list.AddChunked(text)` | +| You're building several free-text lines conditionally | `using var block = list.TextBlock();` then `block.Add(...)` | +| The content is a single short cliloc-backed property | Plain `list.Add(number, $"...")` — chunking is unnecessary | + +Real-world pattern (consolidated attribute lines, condensed from UOEvolution's `BaseWeapon`): + +```csharp +public override void GetProperties(IPropertyList list) +{ + base.GetProperties(list); + + using var block = list.TextBlock(); + + if (_attrs.HitLowerParryCap > 0) + { + block.Add($"Lower Parry Cap {_attrs.HitLowerParryCap}%"); + } + + if (_attrs.ParryBonusDamage > 0) + { + block.Add($"Parry Damage {_attrs.ParryBonusDamage}%"); + } + // ...any number of optional lines; the block flushes safely on dispose. +} +``` + ## Common Cliloc Numbers | Number | Text | Usage | @@ -207,6 +310,7 @@ Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`: - **String building**: Uses `STArrayPool` for zero-GC string construction - **Global toggle**: `ObjectPropertyList.Enabled` can disable the entire system - **Lazy initialization**: Property lists are built on first access +- **Per-property cap**: `MaxArgumentLength` (504) bounds each property's text so the legacy 2D client's fixed tooltip buffer can't overflow. `AddChunked`/`OplTextBlock` keep multi-line content under it; anything that slips through is clamped with a logged warning ### Update Flow 1. `InvalidateProperties()` is called