Skip to content

Implementing TOML loaders

This reference is for C# plugin developers. Read Writing a plugin first. To author items, NPCs and loot in TOML, start with Creating TOML content. The existing loaders need no C# changes to load new files under their directories.

ItemTemplatesLoader reads every *.toml under templates/items/, subfolders included, when the game server starts, and resolves base_id once:

  • a field that can be left unset (name, layer, weight, amount, stackable, …) and is left unset takes the parent’s value, up the chain;
  • item_id = 0 takes the parent’s graphic;
  • hue inherits when unset; without a value anywhere in the chain, it uses 0;
  • rarity always has a value, so the template’s own is kept;
  • tags, when set, replace the parent’s; they are not merged.

The server stops when an id is empty or used twice (the message names both files), a base_id names no template, base_id loops back (a → b → a), or a resolved template fails ItemTemplate.Validate().

IItemTemplateService serves the resolved templates (TryGet, Get, Count; ids match case). IItemFactoryService makes items from them:

var coin = factory.Create("gold_coin", amount: 250);
coin.PutInContainer(backpack.Id, layout.RandomGridPosition(backpack.ItemId)); // or PlaceOnGround / Equip
await factory.SaveAsync(coin); // the database gives it its serial

Create builds the ItemEntity in memory, with no serial and no location. Random template values (hue, amount, rarity) are picked once, there, and stored with the item; nothing else is copied from the template, so name, movable and visibility stay null and the template’s values apply. An amount above 1 on an item that does not stack (stackable, else the tiledata Generic flag) throws. Saving refuses an item with no location and gives a new item a serial from world.items_id_seq, inside Serial.MinItem..MaxItem. SaveAsync(items) saves a list in one transaction, in order (a container before its contents), and SaveAsync(transaction, item) saves inside a transaction the caller opened.

MobileTemplatesLoader reads every *.toml under templates/mobiles/, subfolders included, after the item templates, and resolves base_id once, as UOX3’s GET does (the child inherits everything and overrides what it sets):

  • a field left unset takes the parent’s value, up the chain;
  • skills, resistances and sounds are inherited key by key: the child’s entries override, the parent’s other entries stay;
  • tags merge: the child’s keys add to and override the parent’s;
  • equipment and loot, when set, replace the parent’s; carve, when unset, is the parent’s, and carve = "" is none;
  • everything inherited is copied, never shared between templates.

The server stops when an id is empty or used twice, a base_id names no template or loops back, a template fails MobileTemplate.Validate(), a name_list names no list of data/names.toml ({gender} needs the male and female lists), an equipment item is not an item template, a loot id is not a loot table, or a carve id is not a carve table. IMobileTemplateService serves the resolved templates (TryGet, Get, Count).

IMobileFactoryService makes NPCs from them:

var spawned = await mobiles.SpawnAsync("guard", MapType.Felucca, new Point3D(1602, 1591, 20));
// spawned.Mobile is saved with its serial; spawned.Equipment is what it wears.

Create(templateId) builds the MobileEntity in memory and rolls every random value once: gender (random is 50/50, unset is male), body (the template’s, else the race body for the gender from races.toml), name (the template’s, else one of name_list, where {gender} picks the male or female list), skin, hair and beard (from the template, else the race; hair = [] is bald; females get no beard), stats (unset is 10), hits, mana and stamina (which default to strength, intelligence and dexterity), armor, resistances, fame, karma and skills (template points × 10, the tenths the mobile stores). title stays null: the template’s applies. notoriety is the template’s, and for a template that has none a human is left with none, which reads as innocent, and anything else is attackable (grey), as ModernUO’s animals and monsters. The NPCs saved before this was so are given theirs when the server starts, and saved with the world.

SpawnAsync(templateId, map, location):

  1. rejects a location outside the map, before anything else, and a template that resolves to no body and no race;
  2. creates the mobile and places it;
  3. publishes MobileBeforeSpawnEvent: a handler may change the mobile, even move it; the place is checked against the map again afterwards;
  4. in one transaction, saves the mobile (its serial comes from the mobile range), then:
    • a backpack (ultima.items.backpack_template) worn on the Backpack layer, for every NPC;
    • each equipment entry through IItemFactoryService: an entry with a gender is skipped for the other gender; the item is worn on its layer (the template’s, else tiledata’s for a wearable graphic); an item with no layer, or whose layer is taken, goes into the backpack, as UOX3 does;
    • the rolled gold, as ultima.items.gold_template piles of at most 65535, into the backpack;
    • one roll of each loot table (a table listed twice is rolled twice), into the backpack;
    • everything in the backpack lands at a random spot inside its containers.toml bounds;
  5. after the commit, publishes MobileMovedToWorldEvent (with the place the mobile was saved at) and then MobileAfterSpawnEvent, without cancellation: the mobile is saved by then. A failure before the commit leaves the in-memory mobile without a serial.

The event bus logs a handler’s exception and goes on, so a handler cannot stop or undo a spawn.

SaveAsync(mobile) saves a mobile that already has its serial.

LootTemplatesLoader reads every *.toml under templates/loots/, subfolders included, before the mobile templates. The server stops when an id is empty or used twice, an entry sets both item_id and loot_template_id, an item_id is not an item template, a loot_template_id names no table, a weight is below 1, an amount can roll outside 1 to 65535, nested tables loop, or an item template’s loot names a table that does not exist. A table with no entries is kept, logged as a warning, and gives nothing: two shipped UOX3 tables (randomwands, random_useless_junk) are empty in UOX3’s own data.

ILootService.Roll(tableId) picks one entry, in proportion to weight, and returns what it gives, built through IItemFactoryService with no serial and no location: nothing for an entry with neither id (UOX3’s blank), one pile for a stackable item, that many separate items otherwise, and, for a nested table, amount rolls of it: LOOTLIST=randomgems,2 rolls randomgems twice. (UOX3 itself looks the nested entry up as an item list and spawns nothing; Moongate does what the data plainly means.)

CarveTemplatesLoader reads every *.toml under templates/carves/, subfolders included, after the item templates and before the mobile templates, which name the tables with carve. The server stops when an id is empty or used twice, an entry’s item is not an item template, an amount can roll outside 1 to 65535, a chance is outside 1 to 100 or a message is below 0. A table with no entries is kept and gives nothing. What the tables mean, and what happens to a corpse, is in Carving. ICarveService serves the tables (TryGet) and carves corpses (Carve).

IDataLoader<TEntity>, in Moongate.Server.Ultima:

public interface IDataLoader<TEntity>
{
Task InitializeAsync(CancellationToken cancellationToken = default);
Task<DataLoaderResult<TEntity>> LoadDataAsync(CancellationToken cancellationToken = default);
}

InitializeAsync prepares the loader, opening files or connections; LoadDataAsync reads everything and returns it in a DataLoaderResult<TEntity>, whose one property is IReadOnlyList<TEntity> Entities. A loader for item templates would enumerate every .toml file under its own subdirectory of templates/ and deserialize each with TomlUtils.

Call AddUltimaDataLoader<TLoader, TEntity> from a plugin’s Register, the same place services and metric providers are registered:

container.AddUltimaDataLoader<ItemTemplatesLoader, ItemTemplate>(12);

The loader is a singleton, reachable both by its concrete type and as IDataLoader<TEntity>. The registration is appended to the list that DataLoaderService runs at startup.

DataLoaderService is an ordinary startup service at priority -5, after IUltimaDataService at -10, because a loader reading MUL or UOP files needs the client path already configured. On StartAsync it runs every registered loader in ascending priority order and keeps each result under its entity type:

IReadOnlyList<ItemTemplate> items = dataLoaderService.GetEntities<ItemTemplate>();

Asking for a type nothing was registered for throws immediately, naming the type: a missing AddUltimaDataLoader call fails loudly at first use, not with a silently empty list. With no loaders registered at all, StartAsync still completes.

A template field is sometimes a fixed value and sometimes “pick one of these each time an entity is created from this template”. EnumValueSpec<TEnum> covers both without the template type needing two fields:

public readonly struct EnumValueSpec<TEnum> where TEnum : struct, Enum
{
public bool IsRandom { get; }
public static EnumValueSpec<TEnum> FromValue(TEnum value);
public static EnumValueSpec<TEnum> FromCandidates(IReadOnlyList<TEnum> candidates);
public static EnumValueSpec<TEnum> Random();
public TEnum Resolve();
}

Resolve() is called at the point of use, when an entity is created from the template, not while the template is loaded: a random_of field gives a different value on every spawn. It draws from Moongate.Core.Random.BuiltInRng, the generator the rest of the codebase uses.

As text, the three forms are:

TOML Meaning
rarity = "common" Always Common
rarity = "random_of" Any member of the enum, picked fresh each Resolve()
rarity = "random_of:rare,epic,legendary" One of exactly these three, picked fresh each Resolve()

Parsing member names ignores case and underscores; writing uses lowercase snake_case, so FromValue(ItemRarityType.Epic).ToString() is "epic", matching how a designer types it, and what is written always reads back. See EnumValueSpec for every accepted form and error. A field declares this by its type, nothing else:

public EnumValueSpec<ItemRarityType> Rarity { get; set; } =
EnumValueSpec<ItemRarityType>.FromValue(ItemRarityType.Common);

RangeValueSpec<T> is the numeric sibling, generic over INumber<T>:

public readonly struct RangeValueSpec<T> where T : struct, INumber<T>
{
public bool IsRandom { get; }
public static RangeValueSpec<T> FromValue(T value);
public static RangeValueSpec<T> FromRange(T min, T max);
public T Resolve();
}

As text, a bare number (amount = 5) is fixed; a quoted min-max (amount = "5-10") picks a fresh value in that inclusive range on every Resolve(). A quoted bare number (amount = "5") is accepted too. Writing a fixed value emits a bare number; writing a range emits the quoted form. See RangeValueSpec for every accepted form and error.

public RangeValueSpec<int> Amount { get; set; } = RangeValueSpec<int>.FromValue(1);

Hues have their own type, HueSpec, with the same fixed-or-range text form and hex values such as "0x03EA-0x0422"; ItemTemplate.Hue uses it. See HueSpec.

EnumValueSpec<TEnum>, RangeValueSpec<T>, HueSpec, Serial, the point types, Rectangle2D and DiceSpec read and write through converters that MoongateUltimaPlugin registers once with TomlUtils.AddTomlConverter; enums such as Visibility go through the shared enum converter factory. A template needs nothing more than the field type. For the accepted and written forms of every type, the errors, and how to write and register a converter of your own, see TOML value types.

Made with ❤️ by Tom

Buy me a coffee