ModernUO/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs
Kamron Batman 61e41df00c
feat: Add zero-alloc interpolation handler to ValueStringBuilder, replace all StringBuilder usage (#2387)
## Summary

- **Add a self-referencing `InterpolationHandler` to `ValueStringBuilder`** that writes directly into the builder's buffer — zero intermediate allocation, works with `stackalloc`-backed builders
- **Replace all `System.Text.StringBuilder` usage** across the codebase with `ValueStringBuilder`
- **Convert `ValueStringBuilder.Create()` to `stackalloc`** at 10 sites where output length is provably bounded
- **Convert manual `Dispose()` to `using var`** where possible, and hoist loop-scoped builders outside loops with `Reset()`
- **Convert verbose `Append()` chains to `Append($"...")`** interpolation for readability
- **Add comprehensive documentation** for string handling patterns

## InterpolationHandler Design

`ValueStringBuilder` is a `ref struct`, which creates challenges for C#'s interpolated string handler pattern:

- **`ref` fields to ref structs are not allowed** (CS9050)
- **`[InterpolatedStringHandlerArgument("")]` passes struct receivers by value**, not by ref
- **`ISelfInterpolatedStringHandler` requires boxing** ref structs into interface fields

**Solution: Copy-and-reconcile pattern.** The handler receives a value copy of the builder. The copy shares the same underlying `char` buffer (`Span` points to the same `stackalloc`/pooled memory), so writes go to the original buffer. `Append()` reconciles by `this = handler._builder`, updating `_length` and any buffer references changed by `Grow()`.

This is safe because:
- The game loop is single-threaded — no concurrent access between handler construction and reconciliation
- If `Grow()` occurs in the copy, the original's stale buffer isn't accessed until `Append()` replaces it
- `Dispose()` correctly returns the reconciled buffer to the pool

## Changes by Category

### ValueStringBuilder (`Projects/Server/Buffers/ValueStringBuilder.cs`)
- Added nested `InterpolationHandler` ref struct with copy-and-reconcile pattern
- Added `Append([InterpolatedStringHandlerArgument("")] scoped ref InterpolationHandler)` method
- Removed `RawInterpolatedStringHandler` overloads (new handler replaces them)
- All `AppendFormatted` overloads delegate to existing `Append` methods (no code duplication)
- Alignment support via direct private field access (nested type privilege)

### StringBuilder → ValueStringBuilder (15 files)
Replaced all `new StringBuilder()` with `ValueStringBuilder.Create()` or `stackalloc`:
- ConPVP games: KingOfTheHill, DoubleDom, CTF, BombingRun, TourneyMatch
- ConPVP infrastructure: Tournament, Participant, TourneyParticipant
- ConPVP gumps: ArenaGump, TournamentBracketGump, AcceptTeamGump, ConfirmSignupGump
- Commands: Handlers, Logging, Add
- Other: TownCrier, SpeechLogGump, TestCenter

Key patterns:
- `sb = new StringBuilder()` reassignment → `sb.Reset()`
- `sb.AppendFormat("{0:N0}", value)` → `sb.Append($"{value:N0}")`
- `sb.Append(x).Append(y)` chains → separate statements (VSB returns void)

### Create() → stackalloc (10 files)
Converted heap-allocated builders to stackalloc where output is bounded:
- ClientVersion (32), MapSelection (160), HouseRaffleStone (48)
- HolySense (96), UnholySense (96), ClientVerification (192)
- AcceptTeamGump (64), ConfirmSignupGump (64)
- BaseWeapon (160), BaseArmor (128)

### Loop optimizations (2 files)
Hoisted `ValueStringBuilder` creation outside loops with `Reset()` per iteration:
- TourneyMatch.cs: `using var` inside for loop → stackalloc before loop
- ArenaGump.cs: `Create()` + `Dispose()` per iteration → stackalloc before loop

### Append chain → interpolation (5 files)
Converted multi-line `Append()` chains to `Append($"...")`:
- BountyMessage.cs: title switch (6 cases), paragraph (15→1 Append), description lines, closing
- AcceptTeamGump, ConfirmSignupGump, TournamentBracketGump: tournament type strings
- AdminGump: comment/tag formatting in loops

### Documentation
- `dev-docs/string-handling.md`: Full reference — construction, interpolation, disposal, decision guide
- `dev-docs/claude-skills/modernuo-string-handling.md`: Claude skill with quick reference
- `CLAUDE.md`: Added rule 17 (no StringBuilder), dev-docs table entry, skills table entry
- `dev-docs/code-standards.md`: Updated memory management section

## Test Plan

- [x] `dotnet build` — 0 errors, 0 warnings
- [x] `dotnet test` — 940/940 tests pass
- [x] 28 ValueStringBuilder tests covering all reconciliation scenarios:
  - Stackalloc no-grow, stackalloc with grow (→pool transition)
  - Heap no-grow, heap with grow, heap double grow
  - Pre-existing content with and without grow
  - Sequential multiple `Append($"...")` calls
  - Mixed plain + interpolated Append
  - Empty interpolation, literal-only, format specifiers
  - Null string holes, ISpanFormattable types
  - Dispose after stackalloc→pool grow
2026-03-22 14:23:44 -07:00

393 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Buffers;
using System.IO;
using Server.Items;
using Server.Mobiles;
using Server.Network;
using Server.Text;
namespace Server.Engines.PlayerMurderSystem;
public static class BountyMessage
{
/// <summary>
/// Sends a synthetic container content packet (0x3C) using offset player serials.
/// The client displays these as bulletin board message entries. When clicked,
/// HandleBBRequest reverses the offset to recover the real player serial.
/// </summary>
public static void SendBountyContainerContent(NetState ns, BaseBulletinBoard board, uint syntheticSerialBase)
{
if (ns.CannotSendPackets())
{
return;
}
var bounties = PlayerMurderSystem.GetActiveBounties();
var entrySize = ns.ContainerGridLines ? 20 : 19;
var totalSize = 5 + bounties.Count * entrySize;
var writer = totalSize > 1024 ? new SpanWriter(totalSize) : new SpanWriter(stackalloc byte[totalSize]);
writer.Write((byte)0x3C); // Packet ID
writer.Seek(4, SeekOrigin.Current); // Length & count placeholder
var written = 0;
foreach (var (player, _) in bounties)
{
writer.Write((Serial)(syntheticSerialBase + (uint)player.Serial));
writer.Write((ushort)0xEB0); // BulletinMessage ItemID
writer.Write((byte)0); // signed, itemID offset
writer.Write((ushort)1); // Amount
writer.Write((short)0); // X
writer.Write((short)0); // Y
if (ns.ContainerGridLines)
{
writer.Write((byte)0); // Grid location
}
writer.Write(board.Serial); // Container = this board
writer.Write((ushort)0); // Hue
written++;
}
writer.Seek(1, SeekOrigin.Begin);
writer.Write((ushort)writer.BytesWritten);
writer.Write((ushort)written);
writer.Seek(0, SeekOrigin.End);
ns.Send(writer.Span);
writer.Dispose();
}
/// <summary>
/// Sends a synthetic BB message packet (header or content) for a bounty entry,
/// built entirely from live MurderSystem data — no real BulletinMessage item required.
/// Uses a resizable SpanWriter for single-pass writing with zero intermediate allocations.
/// </summary>
public static void SendBountyBBMessage(NetState ns, BaseBulletinBoard board, Serial messageSerial, PlayerMobile player,
int bounty, DateTime lastMurderTime, bool content)
{
if (ns.CannotSendPackets())
{
return;
}
// Build subject and time without heap allocation
using var subjectBuilder = new ValueStringBuilder(stackalloc char[32]);
subjectBuilder.Append(bounty);
subjectBuilder.Append(" gold");
using var timeBuilder = new ValueStringBuilder(stackalloc char[16]);
timeBuilder.Append(lastMurderTime, "MMM dd, yyyy");
// Reusable UTF-8 encoding buffer (768 = GetMaxByteCount(255), covers any WriteString call)
Span<byte> textBuffer = stackalloc byte[768];
// Single-pass writing with resizable SpanWriter — auto-grows if needed
var writer = new SpanWriter(stackalloc byte[1024], resize: true);
writer.Write((byte)0x71); // Packet ID
writer.Seek(2, SeekOrigin.Current);
writer.Write((byte)(content ? 0x02 : 0x01)); // Command
writer.Write(board.Serial);
writer.Write(messageSerial); // Synthetic serial — must match the 0x3C entry
if (!content)
{
writer.Write(Serial.Zero); // Thread serial (all root-level)
}
writer.WriteString(player.Name.AsSpan(), textBuffer);
writer.WriteString(subjectBuilder.AsSpan(), textBuffer);
writer.WriteString(timeBuilder.AsSpan(), textBuffer);
if (content)
{
writer.Write((short)player.Body);
writer.Write((short)player.Hue);
// Write equipment directly from player items (no intermediate array)
var equipCount = 0;
for (var i = 0; i < player.Items.Count; i++)
{
if (player.Items[i].Layer >= Layer.OneHanded && player.Items[i].Layer <= Layer.Mount)
{
equipCount++;
}
}
var equipLength = Math.Min(255, equipCount);
writer.Write((byte)equipLength);
var equipWritten = 0;
for (var i = 0; i < player.Items.Count && equipWritten < equipLength; i++)
{
var item = player.Items[i];
if (item.Layer >= Layer.OneHanded && item.Layer <= Layer.Mount)
{
writer.Write((short)item.ItemID);
writer.Write((short)item.Hue);
equipWritten++;
}
}
WriteContentLines(ref writer, textBuffer, player, bounty);
}
writer.WritePacketLength();
ns.Send(writer.Span);
writer.Dispose();
}
/// <summary>
/// Writes all content lines directly to the packet writer, avoiding intermediate
/// string/list allocations. Uses ValueStringBuilder for line construction and
/// span slicing for word wrapping.
/// </summary>
private static void WriteContentLines(ref SpanWriter writer, Span<byte> textBuffer, PlayerMobile player, int bounty)
{
var isFemale = player.Body.IsFemale;
var pronoun = isFemale ? "she" : "he";
var possessive = isFemale ? "her" : "his";
var objective = isFemale ? "her" : "him";
// Placeholder for line count — patched after all lines are written
var lineCountPos = writer.Position;
writer.Write((byte)0);
var lineCount = 0;
// Reusable builder for constructing each line
using var lineBuilder = new ValueStringBuilder(stackalloc char[64]);
// Title line (random, 6 variants matching uo98 bountyboard.m)
switch (Utility.Random(6))
{
case 0:
{
lineBuilder.Append($"Bounty for {player.RawName}!"); break;
}
case 1:
{
lineBuilder.Append($"{player.RawName} must die!"); break;
}
case 2:
{
lineBuilder.Append($"A price on {player.RawName}!"); break;
}
case 3:
{
lineBuilder.Append($"{player.RawName} outlawed!"); break;
}
case 4:
{
lineBuilder.Append($"Execute {player.RawName}!"); break;
}
default:
{
lineBuilder.Append($"WANTED: {player.RawName}!"); break;
}
}
writer.WriteString(lineBuilder.AsSpan(), textBuffer, true);
lineCount++;
// Blank line
writer.WriteString(ReadOnlySpan<char>.Empty, textBuffer, true);
lineCount++;
// Build main paragraph with ValueStringBuilder
var paraBuilder = new ValueStringBuilder(stackalloc char[256]);
// Random verb phrase (18 variants matching uo98 bountyboard.m)
var verb = Utility.Random(18) switch
{
0 => "hath murdered one too many!",
1 => "shall not slay again!",
2 => "hath slain too many!",
3 => "cannot continue to kill!",
4 => "must be stopped.",
5 => "is a bloodthirsty monster.",
6 => "is a killer of the worst sort.",
7 => "hath no conscience!",
8 => "hath cowardly slain many.",
9 => "must die for all our sakes.",
10 => "sheds innocent blood!",
11 => "must fall to preserve us.",
12 => "must be taken care of.",
13 => "is a thug and must die.",
14 => "cannot be redeemed.",
15 => "is a shameless butcher.",
16 => "is a callous monster.",
_ => "is a cruel, casual killer."
};
// Random bounty intro phrase (7 variants matching uo98 bountyboard.m)
var intro = Utility.Random(7) switch
{
0 => " A bounty is hereby offered",
1 => " Lord British sets a price",
2 => " Claim the reward! 'Tis",
3 => " Lord Blackthorn set a price",
4 => " The Paladins set a price",
5 => " The Merchants set a price",
_ => " Lord British's bounty "
};
paraBuilder.Append($"The foul scum known as {player.RawName} {verb} For {pronoun} is responsible for {player.Kills} murders. {intro} of {bounty} gold pieces for {possessive} head!");
// Word-wrap at 28 chars and write each line directly (matching uo98 bountyboard.m CONST:28)
WriteWordWrappedLines(ref writer, paraBuilder.AsSpan(), 28, textBuffer, ref lineCount);
paraBuilder.Dispose();
// Physical description
writer.WriteString(ReadOnlySpan<char>.Empty, textBuffer, true);
lineCount++;
writer.WriteString(" A description:", textBuffer, true);
lineCount++;
lineBuilder.Reset();
lineBuilder.Append($" - {GetHairStyle(player.HairItemID)}");
writer.WriteString(lineBuilder.AsSpan(), textBuffer, true);
lineCount++;
lineBuilder.Reset();
lineBuilder.Append($" - {GetHairColor(player.HairHue)} hair");
writer.WriteString(lineBuilder.AsSpan(), textBuffer, true);
lineCount++;
lineBuilder.Reset();
lineBuilder.Append($" - {GetSkinTone(player.Hue)} skin");
writer.WriteString(lineBuilder.AsSpan(), textBuffer, true);
lineCount++;
// Closing instructions
writer.WriteString(ReadOnlySpan<char>.Empty, textBuffer, true);
lineCount++;
lineBuilder.Reset();
lineBuilder.Append($"If you kill {objective}, remove the");
writer.WriteString(lineBuilder.AsSpan(), textBuffer, true);
lineCount++;
writer.WriteString("head, and give it to a guard", textBuffer, true);
lineCount++;
writer.WriteString("to claim your reward.", textBuffer, true);
lineCount++;
// Patch line count
var endPos = writer.Position;
writer.Seek(lineCountPos, SeekOrigin.Begin);
writer.Write((byte)Math.Min(255, lineCount));
writer.Seek(endPos, SeekOrigin.Begin);
}
/// <summary>
/// Word-wraps text at maxWidth characters and writes each line directly to the packet.
/// Uses span slicing instead of Substring to avoid allocations.
/// </summary>
private static void WriteWordWrappedLines(ref SpanWriter writer, scoped ReadOnlySpan<char> text, int maxWidth,
Span<byte> textBuffer, ref int lineCount)
{
// Pre-allocate buffer for last segment (needs trailing space)
Span<char> lastSegmentBuf = stackalloc char[maxWidth + 2];
var current = 0;
while (current < text.Length)
{
var remaining = text.Length - current;
if (remaining > maxWidth)
{
var length = maxWidth;
while (length > 0 && text[current + length] != ' ')
{
length--;
}
if (length == 0)
{
length = maxWidth; // hard break — no space found
}
else
{
length++; // include the space
}
writer.WriteString(text.Slice(current, length), textBuffer, true);
current += length;
}
else
{
// Last segment — append trailing space (matching original behavior)
text.Slice(current, remaining).CopyTo(lastSegmentBuf);
lastSegmentBuf[remaining] = ' ';
writer.WriteString(lastSegmentBuf[..(remaining + 1)], textBuffer, true);
current += remaining;
}
lineCount++;
}
}
// Maps hair item IDs to style descriptions (from uo98 bountyboard.m lookup table)
private static string GetHairStyle(int itemID) => itemID switch
{
0x203B => "hair worn short",
0x203C => "hair worn long",
0x203D => "hair tied back",
0x2044 => "a mohawk hairstyle",
0x2045 => "pageboy hair",
0x2046 => "hair tied in buns",
0x2047 => "curly hair",
0x2048 => "receding hairline",
0x2049 => "hair in two pigtails",
0x204A => "shaved head and topknot",
_ => "bald"
};
// Maps hair hues to color descriptions (from uo98 bountyboard.m lookup table)
// Human hair hues: 0x44E0x47D. Index 0 (hue 0) = indeterminate, index 1+ maps to 0x44E+
private static string GetHairColor(int hue) => hue switch
{
0 => "indeterminate color",
0x44E or 0x44F or 0x450 => "white",
0x451 or 0x452 or 0x453 => "graying",
0x454 or 0x455 => "black",
0x456 or 0x457 or 0x458 => "copper",
0x459 or 0x45A or 0x45B or 0x45C => "brown",
0x45D => "reddish brown",
0x45E or 0x45F or 0x460 => "blonde",
0x461 or 0x462 or 0x463 => "light brown",
0x464 or 0x465 => "golden brown",
0x466 or 0x467 or 0x468 => "golden",
0x469 or 0x46A or 0x46B => "bronze",
0x46C or 0x46D => "dark brown",
0x46E or 0x46F => "sandy",
0x470 or 0x471 or 0x472 => "honey-colored",
0x473 or 0x474 or 0x475 => "red",
0x476 or 0x477 or 0x478 => "nut brown",
0x479 or 0x47A or 0x47B => "rich brown",
0x47C or 0x47D => "very dark brown",
_ => "outlandishly colored"
};
// Maps body hues to skin tone descriptions (from uo98 bountyboard.m lookup table)
// Human skin hues: 0x3EA0x422 (may have 0x8000 flag). Index 1 = 0x3EA.
private static string GetSkinTone(int hue) => (hue & 0x7FFF) switch
{
0x3F1 or 0x3F2 or 0x3F8 or 0x3FF or 0x400 or 0x406 or 0x407 or 0x408
or 0x40D or 0x40E or 0x415 or 0x416 => "pale",
0x3EA or 0x3EB or 0x3F9 or 0x3FA or 0x3FB or 0x417 => "fair",
0x3F3 or 0x3F4 or 0x3FC or 0x401 or 0x402 or 0x409 or 0x40F
or 0x410 or 0x411 or 0x418 => "tanned",
0x3EC or 0x3ED or 0x3EE or 0x3F5 or 0x403 or 0x412 or 0x413
or 0x419 or 0x421 => "copper",
0x3EF or 0x3F0 or 0x3F6 or 0x3F7 or 0x3FD or 0x3FE or 0x404
or 0x405 or 0x40A or 0x40B or 0x40C or 0x414 or 0x41A
or 0x41B or 0x420 => "dark",
0x41C or 0x41D or 0x41E => "yellow",
_ => "deathly"
};
}