Classic Ultima Online on Godot 4 .NET
How to write a GUO mod, test it, ship it to your players, and what to do when a mod makes the game slow.
A GUO mod teaches the client a packet your shard sends that ClassicUO
does not know. Shards with their own server packages (POL, ServUO and RunUO
forks) used to need a forked client for that. With a mod, players keep the
normal GUO and add your mod. The design is ADR-0038; what else you can change
without a fork (assistant plugins, scripts, packs, server gumps) is listed in
the modding survey, docs/modding/surface_2026-10-18.md.
What a mod is, plainly: a small .NET assembly that runs inside GUO with the player’s rights. GUO only loads it on a desktop (Windows or Linux), only when the player has turned Load mods on, and only on the shards the mod names. It never downloads one by itself.
"override": true).Not yet: opening a gump or changing the world directly (a later ABI version), and anything on Android or the web build. GUO keeps 0xBF subcommand 0x4755 for itself (the shard offer); no mod can take it.
Start from the sample, docs/modding/samples/announce: it adds packet 0xF9,
a shard announcement shown in the journal, and its README shows the bytes a
shard sends.
godot/GUO.ModApi
(GUO.ModApi.csproj, with Private="false" so the client’s copy is used).
That assembly is everything a mod sees of GUO.Write a public class with a parameterless constructor that implements
IGuoMod. In Register, call registrar.AddPacket once per packet:
public void Register(IModRegistrar registrar) =>
registrar.AddPacket(new PacketRegistration { Id = 0xF9, Length = -1, Handler = OnAnnounce });
Register runs once at start. Do nothing else there: no threads, files or
sockets.
mod.json next to the assembly. Every packet you register must be
declared there, with the same length and override; anything undeclared is
refused. shards lists your login host or shard name (or "*"). The
fields are in docs/data_formats.md, section 42.A registration can carry MinClientVersion / MaxClientVersion (encode a
version with ModAbi.Encode(7, 0, 15, 1)) when a packet differs between
client versions.
Unit-test your handler by calling it with bytes, the way
tools/network_tests does for the sample:
dotnet run --project tools\network_tests
Then try it in the client:
.dll and mod.json into
Data\Mods\<your-mod>\ beside the client.[mods] refused ...: an
undeclared packet, a packet another mod already has, a typo in mod.json,
the wrong ABI.--load-mods, and --mods-dir FOLDER to load from another folder.Your shard then has to send the packet. Send it only to players who have the mod: a client without it misreads the stream.
Two ways, both installed by the player:
.dll and mod.json to put in
Data\Mods\<your-mod>\. Simple, but unsigned: they have to trust where the
files came from.mod component in a content pack
(the contract is in docs/asset_pack_ecosystem.md, Mod components). The
pack is signed with your publisher key (tools/asset_store), lists the
packets it touches, and installing it runs nothing. The player sees your key
fingerprint, the packets and the shards in the Store, and approves the mod;
it loads on the next start with Load mods on. An update or a new key asks
again. Publish your key fingerprint where your players can check it. Store
mod packs need the owner’s decision first (ADR-0038’s amendment), so this
route may not be open yet.Measure it with the perf lab (launchers\dev\perf_lab.bat, on the
Performance page once it is published), once without the mod and once with
it, on the same machine at the same size:
launchers\dev\perf_lab.bat --label without
launchers\dev\perf_lab.bat --label with --args "--load-mods --mods-dir C:\path\to\mods"
The lab plays on the local dev shard, which does not send your shard’s packets, so it shows the cost of having the mod loaded, not of handling your traffic. If the drop only happens on your shard, say so, and say which packet the mod handles and how often your shard sends it.
Report it with both report files the lab names on its last line (share
this file), the mod’s name and version, and its mod.json. They carry no
paths or names. Post it where you report other GUO problems
(Community and Support).
--load-mods,
--mods-dir and --mods-probe.