## Summary Captures the durable learnings from the message-interpolation work (PRs #2434, #2436, #2437, #2438, #2440) as reference documentation. **Doc-only PR — no code changes.** The original Phase 2 audit (PR #2435) was development scaffolding and was closed unmerged once Phase 3 consumed it. This PR replaces it with proper reference docs that future authors can consult. ## What's added ### `dev-docs/string-handling.md` - Promote `RawInterpolatedStringHandler` from a one-line note to a proper section listing all APIs that accept it (messages, OPL, gumps, packets). - Document the `:L` lowercase format specifier. - New comprehensive **"Interpolation Anti-Patterns"** section covering 8 patterns with before/after examples — applies to any handler-aware API: 1. Ternary with interpolated branches 2. Switch expression with interpolated arms 3. Pre-built local typed as `string` 4. `.ToString()` (or any string-returning method) inside a hole 5. String concatenation inside a hole 6. `string.Format` feeding a handler-aware API 7. LINQ-built strings inside a hole 8. Pre-built concat var ### `dev-docs/networking-packets.md` - Add **"Player-Facing Message APIs"** section listing `Mobile` / `Item` / `NetState` message methods with their handler overloads. - Note the `IBroadcastFilter` pattern for new spatial-broadcast helpers. ### `dev-docs/property-lists.md`, `dev-docs/gump-system.md` - Cross-reference the new anti-patterns section. - Add explicit `.ToString()` inside holes warning to property-lists (it had no such guidance before). ### `dev-docs/claude-skills/` - Mirror the same content (condensed) in `modernuo-string-handling.md`, `modernuo-networking.md`, `modernuo-property-lists.md`, `modernuo-gump-system.md`. - Add audit rule #17 to `modernuo-code-audit.md` covering all 8 anti-patterns with severity WARNING, plus the `:L` format spec. ### `CLAUDE.md` - Add audit rule #18 summarizing the interpolation anti-patterns + `:L`, pointing to `dev-docs/string-handling.md` for details. ## Why this matters Before this PR there was no documentation explaining when an interpolated string call site silently allocates a string despite the receiving API providing a handler overload. The Phase 3 cleanup (PRs #2436/#2437/#2438) discovered ~28 such sites in the codebase; without these docs the same patterns would re-emerge. The new audit rule + CLAUDE.md entry will catch them at write time.
17 KiB
ModernUO Gump System
This document covers ModernUO's gump (UI dialog) system, including BaseGump, StaticGump, DynamicGump, builders, response handling, and best practices.
Overview
Gumps are custom UI dialogs displayed to players. ModernUO provides a modern gump system with two main types:
- StaticGump: Layout is cached and reused across instances (better performance)
- DynamicGump: Layout is rebuilt each time (for variable content)
Class Hierarchy
BaseGump (abstract)
├── StaticGump<TSelf> -- Cached layout, for fixed-structure gumps
└── DynamicGump -- Rebuilt layout, for dynamic-structure gumps
All gump classes are in Projects/UOContent/Gumps/Base/.
Sending Gumps
using Server.Gumps; // REQUIRED for extension methods
// Send a gump
mobile.SendGump(new MyGump(mobile));
// Check if gump is open
if (mobile.HasGump<MyGump>()) { }
// Find an open gump
var gump = mobile.FindGump<MyGump>();
// Close a gump
mobile.CloseGump<MyGump>();
Important: using Server.Gumps; is required. Without it, SendGump(), HasGump<T>(), FindGump<T>(), and CloseGump<T>() extension methods won't resolve.
These methods are also available on NetState:
mobile.NetState.SendGump(gump);
mobile.NetState.HasGump<MyGump>();
StaticGump
Use for gumps where the layout structure is the same for all instances. The layout is compiled and cached on first use, then reused.
Template
using Server.Gumps;
namespace Server.Gumps;
public class MyStaticGump : StaticGump<MyStaticGump>
{
private readonly Mobile _player;
private readonly string _data;
public override bool Singleton => true; // Only one per player
public MyStaticGump(Mobile player, string data) : base(50, 50)
{
_player = player;
_data = data;
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddAlphaRegion(10, 10, 380, 280);
// Static text (baked into cached layout)
builder.AddHtmlLocalized(15, 15, 370, 20, 1060635, 0x7800); // "Warning"
// Dynamic text (placeholder filled per-instance via BuildStrings)
builder.AddHtmlPlaceholder(15, 45, 370, 200, "content", false, true);
// Buttons
builder.AddButton(100, 265, 4005, 4007, 1); // OK (buttonID=1)
builder.AddHtmlLocalized(135, 267, 100, 20, 1011036); // "OK"
builder.AddButton(250, 265, 4017, 4019, 0); // Cancel (buttonID=0 = close)
builder.AddHtmlLocalized(285, 267, 100, 20, 1011012); // "Cancel"
}
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
builder.SetHtmlText("content", _data, "#FFC000", 4);
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID == 1)
{
_player.SendMessage("Confirmed!");
}
}
}
How Caching Works
- First instance calls
BuildLayout()-- the layout bytes are compiled and cached - Subsequent instances reuse the cached layout bytes
BuildStrings()is called per-instance to fill dynamic text placeholders- Use
AddLabelPlaceholder/AddHtmlPlaceholderfor text that changes per instance - Use
AddLabel/AddHtml/AddHtmlLocalizedfor text baked into the cache
Placeholders
// In BuildLayout:
builder.AddLabelPlaceholder(x, y, hue, "slotKey");
builder.AddHtmlPlaceholder(x, y, w, h, "slotKey", background, scrollbar);
builder.AddTextEntryPlaceholder(x, y, w, h, hue, entryId, "slotKey");
// In BuildStrings:
builder.SetStringSlot("slotKey", "text value");
builder.SetHtmlText("slotKey", "html content", "#color", fontSize);
DynamicGump
Use for gumps where the layout structure varies per instance (e.g., lists of items, search results).
Template
using Server.Gumps;
namespace Server.Gumps;
public class MyDynamicGump : DynamicGump
{
private readonly Mobile _player;
private readonly List<Item> _items;
public override bool Singleton => true;
public MyDynamicGump(Mobile player, List<Item> items) : base(50, 50)
{
_player = player;
_items = items;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
var height = 60 + _items.Count * 30;
builder.AddPage();
builder.AddBackground(0, 0, 400, height, 5054);
builder.AddAlphaRegion(10, 10, 380, height - 20);
builder.AddHtml(15, 15, 370, 20, "Select an item:");
for (var i = 0; i < _items.Count; i++)
{
var y = 45 + i * 30;
var item = _items[i];
builder.AddLabel(20, y, 0x480, item.Name ?? "Unknown");
builder.AddButton(350, y, 4005, 4007, i + 1);
}
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID > 0 && info.ButtonID <= _items.Count)
{
var selected = _items[info.ButtonID - 1];
_player.SendMessage($"You selected: {selected.Name}");
}
}
}
Builder Methods Reference
Layout Structure
builder.AddPage(int page = 0); // Add page (0 = all pages)
builder.AddBackground(x, y, w, h, gumpID);
builder.AddAlphaRegion(x, y, w, h); // Transparent background
builder.AddImageTiled(x, y, w, h, gumpID); // Tiled background image
builder.AddGroup(int groupId); // Radio button group
Images
builder.AddImage(x, y, gumpID, hue); // Gump art image
builder.AddItem(x, y, itemID, hue); // Item graphic
builder.AddImageTiledButton(x, y, normalID, pressedID, buttonID, type, param, itemID, hue, w, h);
Text
builder.AddLabel(x, y, hue, text); // Single-line text
builder.AddLabelCropped(x, y, w, h, hue, text); // Cropped text
builder.AddHtml(x, y, w, h, text, bg, scrollbar); // HTML text
builder.AddHtml(x, y, w, h, text, color, size, fontStyle, align, bg, scrollbar);
builder.AddHtmlLocalized(x, y, w, h, clilocNumber); // Localized text
builder.AddHtmlLocalized(x, y, w, h, clilocNumber, color);
Interpolation in text
Most text-accepting builders take a ReadOnlySpan<char> and have a ref RawInterpolatedStringHandler overload, so $"..." literals at the call site are zero-allocation. The same applies to Html.Center, Html.Color, Html.Right helpers used when wrapping text in HTML markup:
// Zero allocation — interpolation handler renders directly into a pooled buffer
builder.AddHtml(20, 20, 200, 100, $"<center>{Title}: {Score:N0}</center>");
builder.AddLabel(20, 40, hue, $"You have {gold} gold");
Several call-site shapes silently defeat the handler overload selection (ternaries with interpolated branches, .ToString() inside holes, pre-built var msg = $"..." locals, etc.). See dev-docs/string-handling.md for the full list and fixes — they apply equally inside BuildLayout.
Interactive Elements
builder.AddButton(x, y, normalID, pressedID, buttonID);
builder.AddButton(x, y, normalID, pressedID, buttonID, GumpButtonType.Page, pageNum);
builder.AddCheckbox(x, y, inactiveID, activeID, selected, switchID);
builder.AddRadio(x, y, inactiveID, activeID, selected, switchID);
builder.AddTextEntry(x, y, w, h, hue, entryID, initialText);
builder.AddTextEntryLimited(x, y, w, h, hue, entryID, initialText, maxLength);
Modifiers
builder.SetNoClose(); // Disable right-click close
builder.SetNoMove(); // Disable dragging
builder.SetNoResize(); // Disable resizing
builder.SetNoDispose(); // Disable dispose
builder.AddTooltip(num); // Tooltip on hover
builder.AddItemProperty(serial); // Item property tooltip
Response Handling
public override void OnResponse(NetState sender, in RelayInfo info)
{
var mobile = sender.Mobile;
// Button ID (0 = close/cancel, 1+ = custom buttons)
switch (info.ButtonID)
{
case 0: return; // Closed
case 1:
// Handle button 1
break;
}
// Check checkbox/radio state
bool isChecked = info.IsSwitched(switchID);
// Get text entry value
string text = info.GetTextEntry(entryID);
}
Button ID Convention
0= Close/Cancel (default when player closes gump)1+= Custom action buttons- Use
GumpButtonType.Pagefor page navigation buttons (don't trigger OnResponse)
BaseGump Properties
public int X { get; set; } // Gump X position
public int Y { get; set; } // Gump Y position
public virtual bool Singleton => false; // Only one instance per player
public int TypeID { get; } // Unique type identifier
public Serial Serial { get; } // Gump serial
Common Gump IDs (Background Art)
| ID | Description |
|---|---|
| 5054 | Dark stone background |
| 9200 | Scroll background |
| 9250 | Light parchment |
| 3600 | Brown wood panel |
| 5120 | Gray stone border |
| 2620 | Ornate gold frame |
Common Button IDs (Art)
| Normal/Pressed | Description |
|---|---|
| 4005/4007 | Small right arrow (green) |
| 4017/4019 | Small X (red) |
| 4023/4025 | Small left arrow |
| 4020/4022 | Small checkmark |
| 4029/4031 | Large right arrow |
| 247/248 | Large green gem |
| 241/242 | Large red gem |
Important Properties
Singleton
public override bool Singleton => true;
When true, the gump system automatically closes any existing instance of this gump type for the player before sending a new one. Always set this for gumps that shouldn't stack. Without it, repeated sends create duplicate gumps the player must close individually.
Cached (StaticGump only)
protected virtual bool Cached => true; // default
Controls whether StaticGump<T> caches its compiled layout. The layout is compiled once on first send, then reused for all subsequent instances.
Set to false during development to force the layout to rebuild every send — useful for hot-reload iteration and debugging layout changes without restarting the server:
// Temporary: disable caching while iterating on layout
protected override bool Cached => false;
Remove the override (or set back to true) before committing. Leaving it false in production wastes CPU recompiling identical layouts.
Empty Gump Rule (CRITICAL)
NEVER send a gump with no visual components. An empty gump (no background, no buttons, no content) has no close button and no right-click dismiss — the client cannot close it. This causes a gump leak on both the client and server: the gump stays in the tracking list forever, the client renders an invisible undismissable element, and the slot is consumed until the player relogs.
Empty gumps typically happen when a developer short-circuits inside the constructor or BuildLayout:
// BAD: Short-circuit in constructor creates an empty gump
public MyGump(Mobile from) : base(50, 50)
{
if (!from.Alive)
return; // Gump is already constructed — it's empty but will still be sent!
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
// ...
}
The Fix: Static DisplayTo Pattern
Use a static entry-point method that validates prerequisites before constructing the gump. The constructor is private — the only way to create the gump is through DisplayTo, which guarantees the gump is never empty. See GoGump.cs for the canonical example:
public class MyGump : DynamicGump // or StaticGump<MyGump>, or Gump
{
public override bool Singleton => true;
// Private constructor — can only be called from DisplayTo
private MyGump(Mobile from, SomeData data) : base(50, 50)
{
// Safe to build layout — prerequisites already validated
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
// Always produces visual content — DisplayTo guarantees valid state
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// ...
}
// Static entry point — validates before constructing
public static void DisplayTo(Mobile from)
{
if (!from.Alive || from.NetState == null)
return; // No gump created at all
var data = GetData(from);
if (data == null)
return; // No gump created at all
from.SendGump(new MyGump(from, data));
}
}
Key points:
- Constructor is
private— enforces thatDisplayTois the only entry point - All validation/short-circuiting happens in
DisplayTobeforenew MyGump(...)is called - If prerequisites fail, no gump is constructed or sent
- The constructor and
BuildLayoutcan assume valid state and always produce visual output - Reference implementation:
Projects/UOContent/Gumps/Go/GoGump.cs
Converting Legacy Gump to DynamicGump / StaticGump
The legacy Gump class (in Gumps/Base/Legacy/Gump.cs) builds layouts by appending GumpEntry objects to a list. The modern DynamicGump and StaticGump<T> use ref struct builders that write directly to buffers — fewer allocations, better performance.
Step-by-Step Conversion
1. Choose the target type
| If the layout... | Convert to |
|---|---|
| Is the same structure every time (fixed elements, maybe some dynamic text) | StaticGump<T> |
| Changes shape based on instance data (loops, conditionals that add/remove elements) | DynamicGump |
When in doubt, use DynamicGump — it's simpler and still much better than legacy Gump.
2. Change the class declaration
// Legacy
public class MyGump : Gump
// Modern — pick one:
public class MyGump : DynamicGump
public class MyGump : StaticGump<MyGump>
3. Move layout code into BuildLayout
Legacy gumps build their layout in the constructor. Modern gumps build it in BuildLayout:
// Legacy — layout in constructor
public class OldGump : Gump
{
public OldGump(Mobile from) : base(50, 50)
{
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
AddLabel(20, 20, 0x480, "Hello");
AddButton(20, 260, 4005, 4007, 1);
}
}
// Modern DynamicGump — layout in BuildLayout
public class NewGump : DynamicGump
{
private readonly Mobile _from;
public override bool Singleton => true;
private NewGump(Mobile from) : base(50, 50)
{
_from = from;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddLabel(20, 20, 0x480, "Hello");
builder.AddButton(20, 260, 4005, 4007, 1);
}
public static void DisplayTo(Mobile from)
{
from.SendGump(new NewGump(from));
}
}
4. Key API differences
Legacy Gump |
Modern builder |
|---|---|
AddPage(0) |
builder.AddPage() (0 is the default) |
AddHtml(x, y, w, h, text, bg, scroll) |
builder.AddHtml(x, y, w, h, text, background: bg, scrollbar: scroll) |
Closable = false |
builder.SetNoClose() |
Draggable = false |
builder.SetNoMove() |
Resizable = false |
builder.SetNoResize() |
Disposable = false |
builder.SetNoDispose() |
AddLabel(x, y, hue, string) |
builder.AddLabel(x, y, hue, ReadOnlySpan<char>) |
Intern(string) / string list |
Not needed — builder handles strings internally |
5. For StaticGump: extract dynamic text into placeholders
If converting to StaticGump<T> and some text varies per instance, replace those AddLabel/AddHtml calls with placeholder versions and fill them in BuildStrings:
// In BuildLayout:
builder.AddLabelPlaceholder(20, 20, 0x480, "playerName");
builder.AddHtmlPlaceholder(20, 50, 360, 200, "description", false, true);
// In BuildStrings:
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
builder.SetStringSlot("playerName", _from.Name);
builder.SetHtmlText("description", _description, "#FFC000", 4);
}
6. Update OnResponse signature
// Legacy
public override void OnResponse(NetState sender, RelayInfo info)
// Modern (RelayInfo is passed by ref)
public override void OnResponse(NetState sender, in RelayInfo info)
7. Add DisplayTo and make constructor private
Always add a static DisplayTo method and make the constructor private to prevent empty gumps (see Empty Gump Rule above).
When to Use Which
| Scenario | Type | Reason |
|---|---|---|
| Confirmation dialog | StaticGump | Fixed layout, shown frequently |
| Warning prompt | StaticGump | Fixed layout |
| Settings menu | StaticGump | Fixed structure |
| Item list (variable length) | DynamicGump | Layout depends on data |
| Craft menu | DynamicGump | Player-specific recipes |
| Search results | DynamicGump | Variable result count |
| Vendor inventory | DynamicGump | Different items per vendor |
Key File References
| File | Description |
|---|---|
Projects/UOContent/Gumps/Base/BaseGump.cs |
Abstract base class |
Projects/UOContent/Gumps/Base/StaticGump.cs |
Cached static gump |
Projects/UOContent/Gumps/Base/DynamicGump.cs |
Dynamic gump |
Projects/UOContent/Gumps/Base/StaticGumpBuilder.cs |
Static layout builder |
Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs |
Dynamic layout builder |
Projects/UOContent/Gumps/Base/GumpStringsBuilder.cs |
String slot builder |
Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs |
Shared layout methods |
Projects/UOContent/Gumps/Base/GumpSystem.cs |
Extension methods |
Projects/UOContent/Gumps/StaticWarningGump.cs |
Example static gump |