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
|
|
@ -13,6 +13,7 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
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<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}". */
|
||||
public void Add(int number, int value);
|
||||
|
|
|
|||
|
|
@ -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<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];
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue