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.
This commit is contained in:
parent
f7c44f7c10
commit
d7668df5ee
7 changed files with 588 additions and 15 deletions
|
|
@ -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<char>)
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs
Normal file
108
Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs
Normal file
|
|
@ -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<char>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Server.Text;
|
using Server.Text;
|
||||||
|
|
||||||
|
|
@ -28,8 +29,17 @@ public interface IPropertyList : ISelfInterpolatedStringHandler
|
||||||
/** Convenience method for $"{argument}". */
|
/** Convenience method for $"{argument}". */
|
||||||
public void Add(int number, string argument);
|
public void Add(int number, string argument);
|
||||||
|
|
||||||
/** Convenience method for $"{text}". */
|
/** Convenience method for span-based text without allocating a string. */
|
||||||
public void Add(string text);
|
public void Add(ReadOnlySpan<char> argument);
|
||||||
|
|
||||||
|
/** Convenience method for span-based text without allocating a string. */
|
||||||
|
public void Add(int number, ReadOnlySpan<char> argument);
|
||||||
|
|
||||||
|
/** Emits newline-joined text across multiple properties so none exceeds the legacy client's per-property buffer. */
|
||||||
|
public void AddChunked(ReadOnlySpan<char> text);
|
||||||
|
|
||||||
|
/** Builder for variable-length multi-line free text; flushes via AddChunked on dispose. */
|
||||||
|
public OplTextBlock TextBlock();
|
||||||
|
|
||||||
/** Convenience method for $"{value}". */
|
/** Convenience method for $"{value}". */
|
||||||
public void Add(int number, int value);
|
public void Add(int number, int value);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Server.Buffers;
|
using Server.Buffers;
|
||||||
|
using Server.Logging;
|
||||||
using Server.Network;
|
using Server.Network;
|
||||||
using Server.Text;
|
using Server.Text;
|
||||||
|
|
||||||
|
|
@ -37,6 +38,14 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
1114779 // ~1_val~
|
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 _hash;
|
||||||
private int _stringNumbersIndex;
|
private int _stringNumbersIndex;
|
||||||
private byte[] _buffer;
|
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(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 Add(int number, int value) => InternalAdd(number, $"{value}");
|
||||||
public void AddLocalized(int value) => InternalAdd(GetStringNumber(), $"{value:#}");
|
public void AddLocalized(int value) => InternalAdd(GetStringNumber(), $"{value:#}");
|
||||||
public void AddLocalized(int number, int value) => InternalAdd(number, $"{value:#}");
|
public void AddLocalized(int number, int value) => InternalAdd(number, $"{value:#}");
|
||||||
|
|
||||||
|
public void Add(ReadOnlySpan<char> argument) => InternalAdd(GetStringNumber(), argument);
|
||||||
|
public void Add(int number, ReadOnlySpan<char> 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<char> 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<char> ClampArgument(int number, ReadOnlySpan<char> 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<char> 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];
|
private int GetStringNumber() => _stringNumbers[_stringNumbersIndex++ % _stringNumbers.Length];
|
||||||
|
|
||||||
// String Interpolation
|
// String Interpolation
|
||||||
|
|
@ -177,7 +281,7 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var chars = _arrayToReturnToPool.AsSpan(0, _pos);
|
var chars = ClampArgument(number, _arrayToReturnToPool.AsSpan(0, _pos));
|
||||||
|
|
||||||
if (Header == 0)
|
if (Header == 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
113
Projects/Server/PropertyList/OplTextBlock.cs
Normal file
113
Projects/Server/PropertyList/OplTextBlock.cs
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
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<char> 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>(T value)
|
||||||
|
{
|
||||||
|
_block._builder.Append(value);
|
||||||
|
_wrote = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AppendFormatted<T>(T value, string format)
|
||||||
|
{
|
||||||
|
_block._builder.Append(value, format);
|
||||||
|
_wrote = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AppendFormatted(scoped ReadOnlySpan<char> value)
|
||||||
|
{
|
||||||
|
_block._builder.Append(value);
|
||||||
|
_wrote = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,11 +26,14 @@ description: >
|
||||||
```csharp
|
```csharp
|
||||||
public interface IPropertyList
|
public interface IPropertyList
|
||||||
{
|
{
|
||||||
void Add(int number); // Cliloc number only
|
void Add(int number); // Cliloc number only
|
||||||
void Add(int number, string argument); // Cliloc with ~1_val~ arg
|
void Add(int number, string argument); // Cliloc with ~1_val~ arg
|
||||||
void Add(string text); // Raw string (uses internal cliloc)
|
void Add(ReadOnlySpan<char> argument); // Raw text, no string alloc (uses passthrough cliloc)
|
||||||
void Add(int number, int value); // Cliloc with int arg
|
void Add(int number, ReadOnlySpan<char> argument);
|
||||||
void AddLocalized(int value); // Cliloc number as value
|
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
|
void AddLocalized(int number, int value); // Cliloc wrapper for cliloc
|
||||||
|
|
||||||
// String interpolation overloads
|
// 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
|
## Patterns
|
||||||
|
|
||||||
### Basic GetProperties Override
|
### 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
|
## Common Cliloc Numbers
|
||||||
|
|
||||||
| Number | Text | Usage |
|
| Number | Text | Usage |
|
||||||
|
|
@ -205,6 +234,7 @@ public override void GetProperties(IPropertyList list)
|
||||||
- **Not using cliloc**: Raw strings don't get localized
|
- **Not using cliloc**: Raw strings don't get localized
|
||||||
- **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops
|
- **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops
|
||||||
- **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed
|
- **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
|
## Real Examples
|
||||||
- Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties)
|
- Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties)
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,19 @@ The system uses cliloc numbers (localized string IDs) with argument substitution
|
||||||
Defined in `Projects/Server/PropertyList/IPropertyList.cs`:
|
Defined in `Projects/Server/PropertyList/IPropertyList.cs`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public interface IPropertyList
|
public interface IPropertyList : ISelfInterpolatedStringHandler
|
||||||
{
|
{
|
||||||
void Add(int number); // Cliloc number only
|
void Reset();
|
||||||
void Add(int number, string argument); // Cliloc with string argument
|
void Terminate();
|
||||||
void Add(string text); // Raw string
|
|
||||||
void Add(int number, int value); // Cliloc with int argument
|
void Add(int number); // Cliloc number only
|
||||||
void AddLocalized(int value); // Cliloc number as argument value
|
void Add(int number, string argument); // Cliloc with string argument
|
||||||
|
void Add(ReadOnlySpan<char> argument); // Raw text, no string allocation
|
||||||
|
void Add(int number, ReadOnlySpan<char> argument); // Cliloc with span 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 argument
|
||||||
|
void AddLocalized(int value); // Cliloc number as argument value
|
||||||
void AddLocalized(int number, int value); // Cliloc with localized argument
|
void AddLocalized(int number, int value); // Cliloc with localized argument
|
||||||
|
|
||||||
// String interpolation support
|
// 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
|
## GetProperties Override
|
||||||
|
|
||||||
Override `GetProperties()` to add custom tooltip lines:
|
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.
|
- 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)
|
- 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<char>)` 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<char>.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
|
## Common Cliloc Numbers
|
||||||
|
|
||||||
| Number | Text | Usage |
|
| Number | Text | Usage |
|
||||||
|
|
@ -207,6 +310,7 @@ Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`:
|
||||||
- **String building**: Uses `STArrayPool<char>` for zero-GC string construction
|
- **String building**: Uses `STArrayPool<char>` for zero-GC string construction
|
||||||
- **Global toggle**: `ObjectPropertyList.Enabled` can disable the entire system
|
- **Global toggle**: `ObjectPropertyList.Enabled` can disable the entire system
|
||||||
- **Lazy initialization**: Property lists are built on first access
|
- **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
|
### Update Flow
|
||||||
1. `InvalidateProperties()` is called
|
1. `InvalidateProperties()` is called
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue