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.
Item templates at runtime
Section titled “Item templates at runtime”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 = 0takes the parent’s graphic;hueinherits when unset; without a value anywhere in the chain, it uses 0;rarityalways 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 / Equipawait factory.SaveAsync(coin); // the database gives it its serialCreate 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.
Mobile templates at runtime
Section titled “Mobile templates at runtime”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,resistancesandsoundsare inherited key by key: the child’s entries override, the parent’s other entries stay;tagsmerge: the child’s keys add to and override the parent’s;equipmentandloot, when set, replace the parent’s;carve, when unset, is the parent’s, andcarve = ""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):
- rejects a location outside the map, before anything else, and a template that resolves to no body and no race;
- creates the mobile and places it;
- publishes
MobileBeforeSpawnEvent: a handler may change the mobile, even move it; the place is checked against the map again afterwards; - in one transaction, saves the mobile (its serial comes from the mobile range), then:
- a backpack (
ultima.items.backpack_template) worn on theBackpacklayer, for every NPC; - each equipment entry through
IItemFactoryService: an entry with agenderis 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, asultima.items.gold_templatepiles of at most 65535, into the backpack; - one roll of each
loottable (a table listed twice is rolled twice), into the backpack; - everything in the backpack lands at a random spot inside its
containers.tomlbounds;
- a backpack (
- after the commit, publishes
MobileMovedToWorldEvent(with the place the mobile was saved at) and thenMobileAfterSpawnEvent, 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.
Loot tables at runtime
Section titled “Loot tables at runtime”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.)
Carve tables at runtime
Section titled “Carve tables at runtime”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).
The loader contract
Section titled “The loader contract”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.
Registering a loader
Section titled “Registering a loader”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.
Running the loaders
Section titled “Running the loaders”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.
Fields that resolve randomly
Section titled “Fields that resolve randomly”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);Fields that resolve to a fresh number
Section titled “Fields that resolve to a fresh number”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.
Registering a TOML converter
Section titled “Registering a TOML converter”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
