## Summary Adds comprehensive RunUO → ModernUO migration documentation and Claude AI skills to help shard owners and script authors convert RunUO 2.7 code to ModernUO. - **10 migration skills** (`dev-docs/claude-skills/migrate-from-runuo/`) — system-by-system conversion guides (foundation, serialization, timers, gumps, packets, property lists, commands/events, persistence, items/mobiles, systems/engines) - **12 reference docs** (`dev-docs/runuo-migration-docs/`) — deep-reference with before/after examples, API mapping tables, edge cases, and gotchas - **Updated existing skills** — `modernuo-timers`, `modernuo-serialization`, and `modernuo-threading` now document that `Serialize()` runs on background threads and timers are not thread-safe - **Updated `CLAUDE.md`** — added migration skill lookup table ### Key migration patterns covered - Manual `Serialize()`/`Deserialize()` → source-generated `[SerializableField]` - `Packet` class hierarchy → static `SpanWriter`/`SpanReader` methods - `Timer` subclasses → `TimerExecutionToken` fire-and-forget - `Gump` → `StaticGump<T>`/`DynamicGump` with builders - `EventSink.WorldSave` → `GenericPersistence` - `ObjectPropertyList` → `IPropertyList` with string hole rules - Universal changes: naming (`m_` → `_`), `[Constructable]` → `[Constructible]`, logging, spatial queries
6.1 KiB
Property Lists (Tooltips) Migration
Overview
RunUO uses ObjectPropertyList as both the interface and implementation for item/mobile tooltips. ModernUO introduces an IPropertyList interface and has a critical rule: string literals in interpolated strings must be wrapped as holes ({"text"} not text).
RunUO Pattern
public override void GetProperties(ObjectPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, m_Charges.ToString()); // "charges: ~1_val~"
list.Add(1060658, "Map\t" + m_MapDest); // "~1_val~: ~2_val~"
list.Add(1060637, "{0}\t{1}", m_Current, m_Max); // "~1_val~ / ~2_val~"
list.Add("Custom text line");
}
ModernUO Equivalent
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
list.Add(1060658, $"{"Map"}\t{_mapDest}"); // "~1_val~: ~2_val~" — "Map" is a hole!
list.Add(1060637, $"{_current}\t{_max}"); // "~1_val~ / ~2_val~"
list.Add($"{"Custom text line"}"); // Raw string — still a hole
}
Migration Mapping Table
| RunUO | ModernUO | Notes |
|---|---|---|
GetProperties(ObjectPropertyList list) |
GetProperties(IPropertyList list) |
Interface instead of class |
list.Add(number, string.Format(...)) |
list.Add(number, $"...") |
Interpolated string |
list.Add(number, value.ToString()) |
list.Add(number, $"{value}") |
Interpolated |
list.Add(number, "text\t" + value) |
list.Add(number, $"{"text"}\t{value}") |
Text must be hole |
list.Add(number, string.Format("{0}\t{1}", a, b)) |
list.Add(number, $"{a}\t{b}") |
Tab-separated args |
list.Add("raw string") |
list.Add($"{"raw string"}") |
String literal as hole |
list.Add(number) |
list.Add(number) |
Same — cliloc only |
list.Add(number, "#" + cliloc) |
list.Add(number, $"{cliloc:#}") |
Cliloc as argument |
The String Literal Rule (CRITICAL)
The IPropertyList interpolated string handler distinguishes between literals (text between {} holes) and holes (values inside {}). Literals are treated as delimiters (like \t). Holes are treated as cliloc arguments.
Rule: The only bare literal text should be \t (argument separator). All other text must be inside {} holes.
// BAD — "Map" is a literal, treated as a delimiter
list.Add(1060658, $"Map\t{_mapDest}");
// GOOD — "Map" is a hole, treated as argument ~1_val~
list.Add(1060658, $"{"Map"}\t{_mapDest}");
Why this matters: The property list data is consumed beyond just the game client (e.g., web renderers). The system must distinguish arguments from delimiters to correctly format tooltips for all consumers.
Cliloc Number as Argument
When a cliloc argument is itself a cliloc number to resolve:
// BAD — string "#1060000" is not properly handled by all consumers
list.Add(1050039, $"{_amount}\t{"#1060000"}");
// GOOD — :# format specifier marks it as a cliloc reference
list.Add(1050039, $"{_amount}\t{1060000:#}");
Or use the convenience methods:
list.AddLocalized(clilocNumber); // Single cliloc value
list.AddLocalized(1050039, clilocNumber); // Cliloc with cliloc argument
Step-by-Step Conversion
Step 1: Change Method Signature
// RunUO
public override void GetProperties(ObjectPropertyList list)
// ModernUO
public override void GetProperties(IPropertyList list)
Step 2: Always Call Base First
base.GetProperties(list); // Unchanged
Step 3: Convert Each list.Add() Call
Simple value:
// RunUO
list.Add(1060741, m_Charges.ToString());
// ModernUO
list.Add(1060741, $"{_charges}");
Multiple tab-separated arguments:
// RunUO
list.Add(1060637, string.Format("{0}\t{1}", m_Current, m_Max));
// ModernUO
list.Add(1060637, $"{_current}\t{_max}");
String literal arguments:
// RunUO
list.Add(1060658, "Coords\t" + m_Location.ToString());
// ModernUO
list.Add(1060658, $"{"Coords"}\t{_location}");
Raw text:
// RunUO
list.Add("Soulbound");
// ModernUO
list.Add($"{"Soulbound"}");
Step 4: Add [InvalidateProperties] to Serialized Fields
If the RunUO property setter called InvalidateProperties(), add the attribute:
[SerializableField(0)]
[InvalidateProperties] // Auto-refreshes tooltip
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
Before/After Example
RunUO:
public override void GetProperties(ObjectPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, m_Charges.ToString());
list.Add(1060658, "Map\t" + m_MapDest);
list.Add(1060659, "Coords\t" + m_PointDest);
list.Add(1060660, "Creatures\t" + (m_Creatures ? "Yes" : "No"));
list.Add(1060661, "Range\t" + m_Range);
if (m_Active)
list.Add(1060742); // "active"
}
ModernUO:
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}");
list.Add(1060658, $"{"Map"}\t{_mapDest}");
list.Add(1060659, $"{"Coords"}\t{_pointDest}");
list.Add(1060660, $"{"Creatures"}\t{(Creatures ? "Yes" : "No")}");
list.Add(1060661, $"{"Range"}\t{_range}");
if (_active)
list.Add(1060742);
}
Edge Cases & Gotchas
1. Integer Overload vs String Interpolation
list.Add(number, int) exists and is different from list.Add(number, $"{int}"). The integer overload passes the raw int; the interpolation formats it as a string. Use whichever matches the cliloc expectation.
2. Era-Conditional Properties
Check era when properties differ between expansions:
if (Core.ML)
list.Add(1072241, $"{TotalItems}\t{MaxItems}\t{TotalWeight}\t{MaxWeight}");
else
list.Add(1050044, $"{TotalItems}\t{TotalWeight}");
3. Don't Call InvalidateProperties() in Loops
It triggers hash computation and potential network sends. Batch changes first.
See Also
dev-docs/property-lists.md— Complete ModernUO property list reference02-serialization.md— [InvalidateProperties] on serialized fields01-foundation-changes.md— Foundation changes