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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue