## 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.
12 KiB
| name | description |
|---|---|
| modernuo-gump-system | Trigger when creating or modifying gumps (UI dialogs). When working with BaseGump, StaticGump, DynamicGump, or GumpSystem. |
ModernUO Gump System
When This Activates
- Creating new gumps (UI dialogs/windows)
- Modifying existing gump layouts
- Handling gump button responses
- Using
mobile.SendGump(),HasGump<T>(),CloseGump<T>()
Key Rules
- Use
StaticGump<TSelf>when layout is fixed (cached, better performance) - Use
DynamicGumpwhen layout depends on instance data (rebuilt each time) - Use
mobile.SendGump(gump)to send -- requiresusing Server.Gumps; - Override
Singleton => trueif only one instance should be open per player - Never forget
using Server.Gumps;-- extension methods won't resolve without it
Hierarchy
BaseGump (abstract)
├── StaticGump<TSelf> -- Cached layout, use for menus/dialogs
└── DynamicGump -- Rebuilt layout, use for dynamic content
StaticGump Pattern (Preferred for Fixed Layouts)
using Server.Gumps;
namespace Server.Gumps;
public class MyGump : StaticGump<MyGump>
{
private readonly Mobile _player;
private readonly string _message;
public override bool Singleton => true; // One per player
public MyGump(Mobile player, string message) : base(50, 50)
{
_player = player;
_message = message;
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// Static text (cached)
builder.AddHtmlLocalized(10, 10, 380, 20, 1060635, 0x7800); // "Warning"
// Dynamic text placeholder (filled per-instance)
builder.AddHtmlPlaceholder(10, 40, 380, 220, "content", false, true);
// Buttons
builder.AddButton(150, 265, 4005, 4007, 1); // OK button (buttonID=1)
builder.AddHtmlLocalized(185, 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)
{
// Fill dynamic placeholders
builder.SetHtmlText("content", _message, "#FFC000");
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID == 1)
{
_player.SendMessage("You clicked OK!");
}
}
}
DynamicGump Pattern (For Variable Layouts)
using Server.Gumps;
namespace Server.Gumps;
public class InventoryGump : DynamicGump
{
private readonly Mobile _player;
private readonly List<Item> _items;
public override bool Singleton => true;
public InventoryGump(Mobile player, List<Item> items) : base(50, 50)
{
_player = player;
_items = items;
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 40 + _items.Count * 30, 5054);
builder.AddHtml(10, 10, 380, 20, "Inventory");
for (var i = 0; i < _items.Count; i++)
{
var y = 40 + i * 30;
builder.AddLabel(10, y, 0, _items[i].Name ?? "Unknown");
builder.AddButton(350, y, 4005, 4007, i + 1); // Button per item
}
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID > 0 && info.ButtonID <= _items.Count)
{
var item = _items[info.ButtonID - 1];
_player.SendMessage($"Selected: {item.Name}");
}
}
}
Sending and Managing Gumps
using Server.Gumps;
// Send gump
mobile.SendGump(new MyGump(mobile, "Hello!"));
// Check if gump is open
if (mobile.HasGump<MyGump>())
{
// Already open
}
// Find existing gump
var gump = mobile.FindGump<MyGump>();
// Close gump
mobile.CloseGump<MyGump>();
Builder Methods Reference
Layout Elements
builder.AddPage(int page = 0);
builder.AddBackground(int x, int y, int width, int height, int gumpID);
builder.AddAlphaRegion(int x, int y, int width, int height);
builder.AddImageTiled(int x, int y, int width, int height, int gumpID);
builder.AddImage(int x, int y, int gumpID, int hue = 0);
builder.AddItem(int x, int y, int itemID, int hue = 0);
Text
builder.AddLabel(int x, int y, int hue, ReadOnlySpan<char> text);
builder.AddHtml(int x, int y, int w, int h, ReadOnlySpan<char> text, ...);
builder.AddHtmlLocalized(int x, int y, int w, int h, int number, ...);
builder.AddLabelPlaceholder(int x, int y, int hue, ReadOnlySpan<char> slotKey);
builder.AddHtmlPlaceholder(int x, int y, int w, int h, ReadOnlySpan<char> slotKey, ...);
Text-accepting builders take ROS<char> and have ref RawInterpolatedStringHandler overloads — $"..." literals at the call site are zero-allocation. Same for Html.Center/Html.Color/Html.Right helpers used for HTML markup wrapping.
builder.AddHtml(20, 20, 200, 100, $"<center>{Title}: {Score:N0}</center>");
builder.AddLabel(20, 40, hue, $"You have {gold} gold");
Avoid the interpolation anti-patterns (ternaries with $"..." branches, .ToString() inside holes, pre-built var msg = $"..." locals, string.Format, etc.) — they silently fall back to string-allocating overloads. See dev-docs/claude-skills/modernuo-string-handling.md § "Interpolation Anti-Patterns" for the full list.
Interactive
builder.AddButton(int x, int y, int normalID, int pressedID, int buttonID, ...);
builder.AddCheckbox(int x, int y, int inactiveID, int activeID, bool selected, int switchID);
builder.AddRadio(int x, int y, int inactiveID, int activeID, bool selected, int switchID);
builder.AddTextEntry(int x, int y, int w, int h, int hue, int entryID, ...);
Modifiers
builder.SetNoClose(); // Cannot close with right-click
builder.SetNoMove(); // Cannot move the gump
builder.SetNoResize(); // Cannot resize
builder.SetNoDispose(); // Cannot dispose
builder.AddTooltip(int number); // Hover tooltip
Response Handling
public override void OnResponse(NetState sender, in RelayInfo info)
{
var buttonID = info.ButtonID; // 0 = close, 1+ = button clicks
var isChecked = info.IsSwitched(0); // Checkbox/radio state by switchID
var text = info.GetTextEntry(0); // Text entry value by entryID
}
Static vs Dynamic: When to Use Which
| Use StaticGump | Use DynamicGump |
|---|---|
| Confirmation dialogs | Lists of variable length |
| Static menus | Player-specific content |
| Warning prompts | Crafting interfaces |
| Settings panels | Search results |
| Help pages | Dynamic data display |
Important Properties
Singleton
public override bool Singleton => true;
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 duplicates the player must close individually.
Cached (StaticGump only)
protected virtual bool Cached => true; // default
Controls whether StaticGump<T> caches its compiled layout bytes. Override to false during development only to force layout rebuild each send — useful for iterating on layout without restarting the server:
protected override bool Cached => false; // TEMPORARY — remove before commit
Empty Gump Rule (CRITICAL AUDIT RULE)
NEVER send a gump with no visual components. An empty gump cannot be closed by the client — no close button, no right-click dismiss. This causes a gump leak on both client and server.
Empty gumps happen when short-circuiting inside a constructor or BuildLayout:
// BAD — early return in constructor leaves gump empty but it still gets sent
public MyGump(Mobile from) : base(50, 50)
{
if (!from.Alive) return; // Empty gump — LEAK!
// ...layout...
}
Fix: Static DisplayTo Pattern
Validate prerequisites before constructing the gump. Make the constructor private:
public class MyGump : DynamicGump
{
public override bool Singleton => true;
private MyGump(Mobile from, SomeData data) : base(50, 50) { /* store fields */ }
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
// Always has visual content — DisplayTo guarantees valid state
}
public static void DisplayTo(Mobile from)
{
if (!from.Alive || from.NetState == null) return; // No gump created
var data = GetData(from);
if (data == null) return; // No gump created
from.SendGump(new MyGump(from, data));
}
}
Reference: Projects/UOContent/Gumps/Go/GoGump.cs
Converting Legacy Gump to DynamicGump / StaticGump
Choose target type
- Layout is fixed structure →
StaticGump<T>(cached, best performance) - Layout varies per instance (loops, conditionals) →
DynamicGump - When in doubt →
DynamicGump(simpler, still much better than legacy)
Conversion checklist
- Change base class:
Gump→DynamicGumporStaticGump<MyGump> - Move layout code from constructor into
BuildLayout(ref DynamicGumpBuilder builder)orBuildLayout(ref StaticGumpBuilder builder) - Store any state the constructor used as fields (constructor now just stores state, doesn't build layout)
- Replace property flags with builder methods:
Closable = false→builder.SetNoClose()Draggable = false→builder.SetNoMove()Resizable = false→builder.SetNoResize()Disposable = false→builder.SetNoDispose()
AddPage(0)→builder.AddPage()(0 is default)- For
StaticGump<T>: extract dynamic text into placeholders (AddLabelPlaceholder/AddHtmlPlaceholder) and fill inBuildStrings(ref GumpStringsBuilder builder) - Update
OnResponsesignature:RelayInfo info→in RelayInfo info - Add
public override bool Singleton => true;if appropriate - Make constructor
private, addpublic static void DisplayTo(Mobile from)method - Move all validation/short-circuit logic into
DisplayTo(never leaveBuildLayoutempty)
Example: legacy → modern
// BEFORE (legacy Gump)
public class OldGump : Gump
{
public OldGump(Mobile from) : base(50, 50)
{
Closable = false;
AddPage(0);
AddBackground(0, 0, 400, 300, 5054);
AddLabel(20, 20, 0x480, from.Name);
AddButton(20, 260, 4005, 4007, 1);
}
public override void OnResponse(NetState sender, RelayInfo info) { }
}
// AFTER (DynamicGump)
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.SetNoClose();
builder.AddPage();
builder.AddBackground(0, 0, 400, 300, 5054);
builder.AddLabel(20, 20, 0x480, _from.Name);
builder.AddButton(20, 260, 4005, 4007, 1);
}
public override void OnResponse(NetState sender, in RelayInfo info) { }
public static void DisplayTo(Mobile from) => from.SendGump(new NewGump(from));
}
Anti-Patterns
- Missing
using Server.Gumps;:SendGump(),HasGump<T>()won't resolve - Creating DynamicGump for static layouts: Wastes CPU rebuilding every time
- Not setting
Singleton: Multiple copies of same gump stack up - ButtonID 0 for actions: 0 means "close" -- use 1+ for action buttons
- Empty gumps: Short-circuiting in constructor/BuildLayout causes gump leaks — use
DisplayTopattern - Leaving
Cached => falsein production: Defeats the purpose of StaticGump — only disable during development
Real Examples
- StaticGump warning:
Projects/UOContent/Gumps/StaticWarningGump.cs - DynamicGump craft:
Projects/UOContent/Engines/Craft/Core/CraftGump.cs - GumpSystem extensions:
Projects/UOContent/Gumps/Base/GumpSystem.cs - StaticGumpBuilder:
Projects/UOContent/Gumps/Base/StaticGumpBuilder.cs - DynamicGumpBuilder:
Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs - BaseGump:
Projects/UOContent/Gumps/Base/BaseGump.cs
See Also
dev-docs/gump-system.md- Complete gump documentationdev-docs/claude-skills/modernuo-commands-targeting.md- Commands that open gumps