modernuo/dev-docs/gump-system.md
Kamron Batman 9ea1b54758
docs(messages): document interpolation anti-patterns and :L format spec (#2441)
## 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.
2026-05-03 18:23:50 -07:00

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

  1. First instance calls BuildLayout() -- the layout bytes are compiled and cached
  2. Subsequent instances reuse the cached layout bytes
  3. BuildStrings() is called per-instance to fill dynamic text placeholders
  4. Use AddLabelPlaceholder/AddHtmlPlaceholder for text that changes per instance
  5. Use AddLabel/AddHtml/AddHtmlLocalized for 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.Page for 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 that DisplayTo is the only entry point
  • All validation/short-circuiting happens in DisplayTo before new MyGump(...) is called
  • If prerequisites fail, no gump is constructed or sent
  • The constructor and BuildLayout can 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