This guide explains how to build plugins for UnifierTSL, how to take advantage of its runtime services, and how to migrate existing TShock or OTAPI-based plugins.
- Important Best Practices
- 1. Getting Started
- 2. Plugin Lifecycle & Hosting
- 3. Configuration Management
- 4. Event & Hook Integration
- 5. Networking & Data Exchange
- 6. Logging & Diagnostics
- 7. Migrating Legacy Plugins & Frameworks
- 8. Advanced Techniques
- 9. Command System V2
- 10. Atelier REPL
- Always derive contexts from
ServerContext. UnifierTSL assumes every active root context is aServerContext(or subclass) so that USP detours can safely callToServer(this RootContext). Creating a customRootContextthat does not inherit fromServerContextwill throw at runtime; keep detours and helpers usingToServer()so they align with the framework’s lifetime management. - Use
SampleServerfor throwaway contexts. When you need a static sample context for API calls that only care about the type (e.g.,Item.SetDefaults(RootContext ctx, int itemType)), instantiateSampleServeror a subclass. It overrides console wiring, so it will not pop extra console windows while still satisfying theServerContextinheritance requirement. - Pass contexts through call sites instead of caching them. Prefer passing the relevant context as a method parameter. Keep long-lived references only on owners that are themselves extension instances created through
ServerContext.RegisterExtension(), so the owner lifetime is tied to the same server context. Caching contexts elsewhere can keep whole server instances alive and cause memory leaks. - Lean on platform services before rolling your own. Build configuration via
IPluginConfigRegistrar, create role-based loggers withUnifierApi.CreateLogger, and expose reusable hooks throughEventHubproviders. This keeps plugins aligned with coordinator lifecycle rules, structured logging, and shared event surfaces.
Most plugin authors can stay outside the UnifierTSL repository by referencing the published UnifierTSL NuGet package that ships with each release. Working directly against the source tree is only necessary when you need to debug the runtime or contribute runtime changes.
Scenario: build WelcomePlugin, a module that replies to !hello in chat with a green welcome message.
-
Create a .NET 9 class library and enable nullable/implicit usings to match the runtime style. Delete the generated
Class1.csfile after scaffolding.dotnet new classlib -n WelcomePlugin -f net9.0Update
WelcomePlugin.csprojso the<PropertyGroup>includes:<ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable>
-
Reference the runtime package. Match the version to the UnifierTSL release you are targeting (the launcher prints the version banner on startup, and
UnifierTSL.runtimeconfig.jsonlists the same value):dotnet add package UnifierTSL --version <runtime-version>
Expand implementation steps 3-5
-
Add your plugin entry point. Start with the smallest viable stub, then layer in behavior.
Minimal skeleton
using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using UnifierTSL.Plugins; [assembly: CoreModule] namespace WelcomePlugin { [PluginMetadata("WelcomePlugin", "1.0.0", "Contoso", "Replies to !hello with a welcome message.")] public sealed class Plugin : BasePlugin { public override Task InitializeAsync( IPluginConfigRegistrar registrar, ImmutableArray<PluginInitInfo> prior, CancellationToken cancellationToken = default) => Task.CompletedTask; } }
[assembly: CoreModule]marks the assembly as a core module (see README plugin system for a runtime overview). This designation allows other assemblies to declare themselves as "satellite modules" using[RequiresCoreModule("CoreModuleName")], which will load in the sameAssemblyLoadContextand share dependencies.[PluginMetadata]publishes the plugin identity that surfaces in load order, log entries, and publisher output. The loader discovers and organizes modules based on these attributes, not their initialization behavior.Subscribe during initialization and log readiness
Add the following members to the
Pluginclass (keeping the attributes from the stub) and ensure the file importsUnifierTSL,UnifierTSL.Events.Handlers, andUnifierTSL.Logging:public sealed class Plugin : BasePlugin, ILoggerHost { readonly RoleLogger logger; public Plugin() { logger = UnifierApi.CreateLogger(this); } public string Name => "WelcomePlugin"; public string? CurrentLogCategory => null; public override Task InitializeAsync( IPluginConfigRegistrar registrar, ImmutableArray<PluginInitInfo> prior, CancellationToken cancellationToken = default) { UnifierApi.EventHub.Chat.MessageEvent.Register(OnChatMessage, HandlerPriority.Normal); logger.Info("WelcomePlugin ready. Type !hello in chat to test."); return Task.CompletedTask; } }
Add an unload hook so you clean up registrations
Place this override inside the same class. It uses the
BasePlugindisposal hook (DisposeAsync(bool isDisposing)) to undo the subscription:public override ValueTask DisposeAsync(bool isDisposing) { if (!isDisposing) { return ValueTask.CompletedTask; } UnifierApi.EventHub.Chat.MessageEvent.UnRegister(OnChatMessage); return ValueTask.CompletedTask; }
Handle the chat callback you registered
Add this method to the class and import
System,Microsoft.Xna.Framework, andUnifierTSL.Events.Core:static void OnChatMessage(ref ReadonlyEventArgs<MessageEvent> args) { if (!args.Content.Sender.IsClient) { return; } var text = args.Content.Text.Trim(); if (string.Equals(text, "!hello", StringComparison.OrdinalIgnoreCase)) { args.Content.Sender.Chat("Hello from WelcomePlugin!", Color.LightGreen); args.Handled = true; } }
Completed example
using System; using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using Microsoft.Xna.Framework; using UnifierTSL; using UnifierTSL.Events.Core; using UnifierTSL.Events.Handlers; using UnifierTSL.Logging; using UnifierTSL.Plugins; [assembly: CoreModule] namespace WelcomePlugin { [PluginMetadata("WelcomePlugin", "1.0.0", "Contoso", "Replies to !hello with a welcome message.")] public sealed class Plugin : BasePlugin, ILoggerHost { readonly RoleLogger logger; public Plugin() { logger = UnifierApi.CreateLogger(this); } public string Name => "WelcomePlugin"; public string? CurrentLogCategory => null; public override int InitializationOrder => 0; public override Task InitializeAsync( IPluginConfigRegistrar registrar, ImmutableArray<PluginInitInfo> prior, CancellationToken cancellationToken = default) { UnifierApi.EventHub.Chat.MessageEvent.Register(OnChatMessage, HandlerPriority.Normal); logger.Info("WelcomePlugin ready. Type !hello in chat to test."); return Task.CompletedTask; } public override ValueTask DisposeAsync(bool isDisposing) { if (isDisposing) { UnifierApi.EventHub.Chat.MessageEvent.UnRegister(OnChatMessage); } return ValueTask.CompletedTask; } static void OnChatMessage(ref ReadonlyEventArgs<MessageEvent> args) { if (!args.Content.Sender.IsClient) { return; } var text = args.Content.Text.Trim(); if (string.Equals(text, "!hello", StringComparison.OrdinalIgnoreCase)) { args.Content.Sender.Chat("Hello from WelcomePlugin!", Color.LightGreen); args.Handled = true; } } } }
-
Build and copy the plugin into your UnifierTSL install:
dotnet build WelcomePlugin/WelcomePlugin.csproj -c Debug mkdir -p <unifier-install>/plugins/WelcomePlugin cp WelcomePlugin/bin/Debug/net9.0/WelcomePlugin.dll <unifier-install>/plugins/WelcomePlugin/Keep any additional assemblies (satellites or dependencies) alongside the main DLL inside the plugin folder.
-
Launch the UnifierTSL runtime (from the release download or your existing deployment). The console log will report
WelcomePluginduring startup. Joining the server and sending!hellonow prints the green welcome message back to the player, demonstrating attribute wiring, event registration, and logging.
Need the full debugging story or want to bundle the runtime yourself? Clone the repository and follow the Run from Source checklist, then create or duplicate a plugin project under src/Plugins/.
-
Scaffold the plugin by copying
src/Plugins/ExamplePluginas a template, or create a new class library:dotnet new classlib -n WelcomePlugin -f net9.0inside
src/Plugins/, then update the.csprojto enable<ImplicitUsings>enable</ImplicitUsings>and<Nullable>enable</Nullable>. -
Reference the runtime project instead of the NuGet package:
<ItemGroup> <ProjectReference Include="..\..\UnifierTSL\UnifierTSL.csproj" /> </ItemGroup>
-
Add the project to the solution so it builds with everything else:
dotnet sln src/UnifierTSL.slnx add src/Plugins/WelcomePlugin/WelcomePlugin.csproj -
Implement the plugin logic exactly as shown in the NuGet quickstart guide above (sections 3–5 of the example), using the same event registration, logging, and lifecycle patterns.
-
Build and publish: Use the Publisher to generate a proper distributable bundle with all plugins integrated. Replace
win-x64with the runtime identifier you need (e.g.,linux-x64,osx-arm64):dotnet run --project src/UnifierTSL.Publisher/UnifierTSL.Publisher.csproj -- --rid win-x64This is the only reliable way to test plugins, as it ensures the correct file layout (
plugins/,lib/,config/directories) and dependency resolution that matches production deployments. Directdotnet buildordotnet runcommands do not trigger plugin compilation or guarantee UnifierTSL launches with a valid runtime structure. -
To produce a distributable bundle that includes your plugin, the same Publisher command applies. The output will be ready for deployment to production systems.
Expand Publisher output mode deep dive
The publisher has two distinct output modes controlled by the --output-path argument:
Default behavior (no --output-path specified):
- Output directory:
src/UnifierTSL.Publisher/bin/<Configuration>/<TFM>/utsl-<rid>/ <Configuration>follows how the publisher itself is built (dotnet rundefaults toDebugunless-c Releaseis passed).- This default uses the Publisher project's own build folder, which maintains compatibility with the repository structure.
- The publisher automatically locates the solution root by searching up to 5 directories for
.slnor.slnxfiles, so this works correctly whether you invoke it viadotnet runor from a compiled binary.
Custom output location (with --output-path specified):
- Use
--output-pathto specify any other directory:dotnet run --project src/UnifierTSL.Publisher/UnifierTSL.Publisher.csproj -- --rid win-x64 --output-path ./bin - The
--output-pathargument accepts both absolute and relative paths:- Absolute paths are used as-is.
- Relative paths are resolved relative to the current working directory from which you invoke the publisher (not the solution root).
In both cases, the publisher writes to <output-path>/utsl-<rid>/, copying every plugin from src/Plugins/ into the published application's plugins/ folder.
Preserving existing output on re-runs:
By default, the publisher cleans the output directory before writing. If you are re-running the publisher to update an existing deployment and want to preserve other files (e.g., generated configurations, saved world data), append --clean-output-dir false:
dotnet run --project src/UnifierTSL.Publisher/UnifierTSL.Publisher.csproj -- --rid win-x64 --output-path ./bin --clean-output-dir false
Without this flag, the output folder is deleted and recreated, which is useful for clean builds but destructive when updating a live deployment.
Excluding specific plugins:
Optionally append --excluded-plugins to omit specific plugins from the bundle:
dotnet run --project src/UnifierTSL.Publisher/UnifierTSL.Publisher.csproj -- --rid win-x64 --excluded-plugins ExamplePlugin
Skipping the RID subfolder:
For development convenience, you can append --use-rid-folder false to write directly to your output folder without the utsl-<rid>/ subfolder, which is useful for iterating on a single target platform:
dotnet run --project src/UnifierTSL.Publisher/UnifierTSL.Publisher.csproj -- --rid win-x64 --output-path ./bin --use-rid-folder false
This writes to ./bin/plugins/ instead of ./bin/utsl-win-x64/plugins/.
-
Recommended: add a post-build step to copy your plugin directly into the publisher output so incremental plugin changes do not require rerunning the publisher. This keeps your development cycle fast:
<Target Name="CopyToPublish" AfterTargets="Build"> <PropertyGroup> <PublishRid>win-x64</PublishRid> <PublishOutputFolder>$(MSBuildProjectDirectory)/../../bin</PublishOutputFolder> <PublishFolder>$(PublishOutputFolder)/utsl-$(PublishRid)/plugins</PublishFolder> </PropertyGroup> <MakeDir Directories="$(PublishFolder)" /> <Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PublishFolder)" /> </Target>
If you've configured the publisher with
--use-rid-folder false, adjust thePublishFolderpath accordingly:<PublishFolder>$(PublishOutputFolder)/plugins</PublishFolder>
This workflow lets you toggle between quick NuGet-driven iterations and full-source debugging without rewriting your plugin.
Implement the IPlugin interface (src/UnifierTSL/Plugins/IPlugin.cs) or inherit from BasePlugin which provides defaults:
[PluginMetadata("MyPlugin", "1.0.0", "Me", "Sample")]
public sealed class MyPlugin : BasePlugin
{
public override int InitializationOrder => 10;
public override void BeforeGlobalInitialize(ImmutableArray<IPluginContainer> plugins)
{
// Wire dependencies or register events that require other plugins to exist.
}
public override async Task InitializeAsync(
IPluginConfigRegistrar registrar,
ImmutableArray<PluginInitInfo> prior,
CancellationToken token)
{
// Register configuration, set up services, start background tasks.
}
public override Task ShutdownAsync(CancellationToken token)
=> Task.CompletedTask;
public override ValueTask DisposeAsync(bool isDisposing)
=> ValueTask.CompletedTask;
}- Plugins are sorted by
InitializationOrderthen type name. Use low numbers for foundational systems, higher numbers for extensions. BeforeGlobalInitializeexecutes after all plugin instances exist but beforeInitializeAsync. Use it to grab references from other plugins or register shared services.InitializeAsyncreceives anImmutableArray<PluginInitInfo>for plugins scheduled before yours, including each plugin container and its initializationTask. Await only the dependencies you actually need instead of assuming serial completion order:
var tshockInit = prior.FirstOrDefault(p => p.Metadata.Name == "TShock");
if (tshockInit.InitializationTask is { } task)
{
await task.ConfigureAwait(false);
}- To ship a custom plugin host, implement
IPluginHost, provide a public parameterless constructor, and annotate the class withPluginHostAttribute. - Runtime admission is checked against
PluginOrchestrator.ApiVersion(currently1.0.0): host major version must equal runtime major version; host minor version must be less than or equal to runtime minor version. - Hosts that fail version checks are skipped (warning logged), so plugins targeting that host will not load until versions are aligned.
- V1 introduces an opt-in handoff contract via
IHotReloadCapablePlugin. Plugins that do not implement it are rejected by default for targeted hot reload. - The host path is manual (
TryHotReloadAsync(PluginHotReloadRequest, ...)); file-watch auto-trigger is intentionally out of scope for V1. - Handoff payload uses
HotReloadEnvelope, which carriesSchemaVersion,MatchKey, old/new versions,JObjectstate, and a runtime snapshot. - Keep handoff state assembly-agnostic. Only put neutral data in
JObject(no cross-ALC runtime objects). - V1 currently does not enable a global isolation gate during commit. The host logs this as a risk and relies on plugin-side coordination.
- Reserved binding-scope interfaces (
IPluginBindingScopeand related sub-scopes) are present as placeholders only, and are not wired into runtime lifecycle yet.
- Obtain a config registration from the
IPluginConfigRegistrarpassed intoInitializeAsync:
var configHandle = registrar
.CreateConfigRegistration<MyConfig>("config.json")
.WithDefault(() => new MyConfig { Enabled = true, CooldownSeconds = 30 })
.TriggerReloadOnExternalChange(true)
.Complete();
MyConfig config = await configHandle.RequestAsync(cancellationToken: token);- Config files live under the plugin-scoped config directory (currently
config/<PluginModuleFileName>/, derived from the loaded plugin module path). Multiple configs can be registered per plugin.
OnDeserializationFailure(DeserializationFailureHandling.ReturnNewInstance, autoPersistFallback: false)decides whether to retain defaults or surface errors.OnSerializationFailure(SerializationFailureHandling.WriteNewInstance)controls how write errors are handled (e.g., rewrite defaults instead of throwing).- Use
TriggerReloadOnExternalChange(true)per registration, or set it once onconfigRegistrar.DefaultOptionto apply that default to subsequent registrations in the current plugin. OnChangedAsynclets you react to external edits. Handlers returnValueTask<bool>; returntrueto signal that you already handled the change (skip automatic cache update):
configHandle.OnChangedAsync += async (sender, updatedConfig) =>
{
// Validate and apply new settings
return true; // true = handled, skip auto cache update
};- Use
ModifyInMemoryorOverwritewhen writing configs programmatically; config handle IO paths are guarded byFileLockManagerto prevent concurrent file corruption.
- Access providers via domain-specific properties:
UnifierApi.EventHub.Chat.ChatEvent,UnifierApi.EventHub.Coordinator.SwitchJoinServer,UnifierApi.EventHub.Game.PreUpdate, etc. - Choose the provider kind that matches your scenario:
ValueEventProvider<T>for mutable payloads with cancellation (Handled,StopPropagation).ReadonlyEventProvider<T>when you only inspect data but still want veto semantics.ValueEventNoCancelfor no-cancel hooks where handlers may still mutate shared event data (for example, injecting routing or selection results).ReadonlyEventNoCancelfor pure notifications where handlers should only observe state.
- Specify
HandlerPriorityto run before/after other handlers. UseFilterEventOption.Handledto react only when prior handlers marked the event as handled.
UnifierApi.EventHub.Chat.ChatEvent.Register(
(ref ValueEventArgs<ChatEvent> args) =>
{
if (args.Content.Text.StartsWith("!servers", StringComparison.OrdinalIgnoreCase))
{
args.Content.Text = string.Join(", ",
UnifiedServerCoordinator.Servers.Select(s => s.Name));
args.Handled = true;
}
},
priority: HandlerPriority.Highest);- When no provider exists, you can use
On.detours provided by MonoMod (e.g.,On.Terraria.Main.Update). Forward toorigwhen you want to preserve existing behavior and add pre/post logic; skip forwarding only when you intentionally replace the original path. - If multiple plugins need the same hook and require consistent cross-plugin ordering, consider introducing an event provider in your plugin first. Promote it to the core runtime (
src/UnifierTSL/Events/Handlers) only when the hook is broadly reusable.
- Coordinator –
CheckVersion,SwitchJoinServer,ServerCheckPlayerCanJoinIn,JoinServer,CreateSocket,PreServerTransfer,PostServerTransfer,Started,LastPlayerLeftEvent. CheckVersionis currently invoked twice duringClientHello; keep handlers idempotent.- Netplay – inspect or cancel packet exchange, detect socket resets.
- Game –
GameInitialize(beforeMain.Initializeoriginal logic),GamePostInitialize(afterNetplaySystemContext.StartServer),PreUpdate,PostUpdate, andGameHardmodeTileUpdate. GameHardmodeTileUpdateis raised from both hardmode hooks (InvokeHardmodeTilePlaceandInvokeHardmodeTileUpdate), so one handler can cover both placement and update paths.- Chat – manipulate player or console chat before vanilla processing.
- Server – react to server add/remove, console service creation.
- Launcher –
InitializedEventfires after launcher args are finalized (including interactive prompts) and before coordinator launch.
PacketSender.SendFixedPackettargets unmanaged packets with a predictable size;SendDynamicPackethandles managedIManagedPacketpayloads that allocate their own buffers._Svariants (e.g.,SendFixedPacket_S) toggle theISideSpecific.IsServerSideflag before dispatching.- Retrieve a
LocalClientSenderviaUnifiedServerCoordinator.clientSenders[clientId]. Prefer the higher-level helpers it exposes (such asKick) when they exist so coordinator bookkeeping stays in sync.
using Terraria.Localization;
var sender = UnifiedServerCoordinator.clientSenders[clientId];
// Example: tell the client to remove a projectile it owns (fixed-size packet).
var killProjectile = new TrProtocol.NetPackets.KillProjectile(
(short)projectileIndex,
(byte)clientId);
sender.SendFixedPacket(in killProjectile);
// Kicking a client? Use the helper so termination flags are updated correctly.
sender.Kick(NetworkText.FromLiteral("Maintenance window"));- Register handlers via
NetPacketHandler.Register<TPacket>to intercept packets before Terraria’sNetMessage.GetDataprocesses them:
NetPacketHandler.Register<TrProtocol.NetPackets.TileChange>(
(ref ReceivePacketEvent<TrProtocol.NetPackets.TileChange> args) =>
{
if (IsProtectedRegion(args.Packet.X, args.Packet.Y))
{
args.HandleMode = PacketHandleMode.Cancel;
}
});- Set
HandleMode = PacketHandleMode.Overwriteand modifyargs.Packetto rewrite the packet; the handler will reserialize and dispatch it throughClientPacketReceiver. - Use
NetPacketHandler.ProcessPacketEventto inspect raw packet bytes (ProcessPacketEvent.RawData) for diagnostics.
UnifiedServerCoordinator.TransferPlayerToServertransfers a client across servers. Wrap calls in try/catch and honour cancellation viaPreServerTransferEvent.ServerContext.SyncPlayerJoinToOthersand related methods can realign visibility after custom transfer flows, but they are low-level primitives. PreferTransferPlayerToServerunless you explicitly need custom sequencing.- Query
UnifiedServerCoordinator.Serversfor active contexts; each context directly inheritsRootContextand exposesNameplus registered extensions. - Launcher-side routing options map into coordinator events:
-joinserver random|rnd|rregisters a low-priority join-policy handler that picks a random destination.-joinserver first|fregisters a low-priority join-policy handler that picks the first available server.- The first valid
-joinserverpolicy wins for the current process.
- Auto-start options (
-server,-addserver,-autostart) are parsed during startup argument handling, beforeUnifiedServerCoordinator.Launch(...). Each accepted entry immediately creates/adds aServerContext, forming the initial server set. - Language override precedence is fixed at launch time:
UTSL_LANGUAGEis applied before CLI parsing and suppresses later-lang/-culture/-languageoverrides once accepted.
- Implement
ILoggerHost(e.g., provideName, optionalCurrentLogCategory) and callUnifierApi.CreateLogger(this)to obtain aRoleLogger. - Leverage extension methods from
src/UnifierTSL/Logging/LoggerExt.csfor severity-specific logging (Debug,Info,Warning,Error,Success,LogHandledException). - Inject additional metadata by implementing
ILogMetadataInjectoror passing spans:
log.Info("Teleporting player", metadata: stackalloc[]
{
new KeyValueMetadata("Player", player.Name),
new KeyValueMetadata("TargetServer", target.Name),
});- Subscribe to
PacketSender.SentPacketfor traffic auditing. - Event providers expose
HandlerCountfor tooling; iterateEventProvider.AllEventsto surface runtime dashboards. - Use structured metadata (e.g., teleport targets, config names) to simplify log filtering in external sinks.
- Context-first mindset – USP replaces Terraria statics with per-server contexts. Audit every legacy usage of
Main,Netplay,NetMessage, etc., and migrate them to theirServerContextcounterparts (ctx.Main,ctx.Netplay,ctx.Router). The table in USP dev-guide lists the most common mappings. - Root context creation – Many legacy launchers assumed a single world. When porting initialization logic, construct or resolve the
RootContext/ServerContextsupplied by UnifierTSL instead of instantiating your own. Use coordinator helpers (UnifiedServerCoordinator.Servers) to enumerate active contexts. - Hook surfaces – Replace
TerrariaApi.Serveror OTAPI static hooks withEventHubproviders or context-bound MonoMod hooks. If you need a hook that does not exist yet, expose a new provider in your plugin so other migrations can share it.
- Commands & permissions – Legacy TShock commands can live inside UnifierTSL plugins. Register them in
InitializeAsyncand resolve dependencies through constructor injection orPluginHost. The existingCommandTeleportplugin shows a clean port of command metadata and execution. - Configuration – Migrate raw file IO to the
IPluginConfigRegistrar. This keeps configs under the host-assigned plugin config directory (currentlyconfig/<PluginModuleFileName>/) and enables hot reload, fallback handling, and schema validation. - Networking – Static packet detours become
NetPacketHandler.Register<TPacket>handlers. Use TrProtocol packet types for serialization; they automatically honor USP’s context rules and enforce length boundaries. - World & tile access – Swap
TileorITileoperations for USP’sTileCollection,TileData, andRefTileData. For migration steps, see USP dev-guide.
- The built-in
src/Plugins/TShockAPIplugin demonstrates how an established ecosystem integrates with UnifierTSL. Mirror its approach when migrating plugins that previously extended TShock directly. - TShock permission checks remain in TShock's own group/permission system, while logging is emitted through UnifierTSL's
RoleLogger. This gives TShock modules access to the shared category/metadata logging pipeline. - Packet-level features that depended on TShock’s custom serializers now rely on TrProtocol models in the migrated codebase. This avoids manual buffer arithmetic and keeps compatibility with USP’s IL-merged packet definitions.
- Many USP detours that were contextified already receive the current pipeline context. Prefer that parameter when available, instead of re-querying
UnifiedServerCoordinator.GetClientCurrentlyServer(plr). UseGetClientCurrentlyServeronly for hooks that truly have no context parameter and no better source. - Treat
ILengthAware/IExtraDataprimarily as packet-shape markers used in generic constraints and dispatch paths. For current TrProtocol reads, always pass the packet end pointer (or equivalent bounds) during deserialisation for bounded parsing, regardless of packet category. - Blocking work inside event handlers stalls the coordinator. Event args are
ref struct, so they cannot be captured across async boundaries; copy required data first, then dispatch long-running work to background services (Task.Run, dedicated worker queues). - Legacy plugins from single-server OTAPI/Terraria models often rely on process-wide statics (for example, player-index caches or static world-tile snapshot buffers). Rewrite these as per-context services or coordinator-managed dictionaries to avoid cross-server state leakage.
- Use
MonoMod.RuntimeDetourto apply hooks beyond the provided events. Ensure detours are disposed duringShutdownAsyncorDisposeAsyncto avoid stale patching when a plugin unloads. - Combine detours with new
EventHubproviders to expose reusable hooks for other plugins.
- Package shared services or native libs by creating a core module with
[ModuleDependencies]. ImplementIDependencyProviderto extract payloads intoplugins/<Module>/lib/. - Satellites can carry optional features, commands, or data migrations without bloating the core plugin DLL.
ModuleAssemblyLoader.PreloadModules(...)deduplicates discovered DLLs bydll.Name. If multiple files share the same name, the last indexed file wins (rootplugins/*.dllentries are indexed after subdirectories).- Native load resolution in
ModuleLoadContext.LoadUnmanagedDllis manifest-driven: it readsdependencies.jsonand matches manifest file names (including version-suffixed names such assqlite3.1.0.0.dll). - RID fallback usually happens earlier while extracting dependency assets (NuGet and embedded dependencies).
LoadUnmanagedDllitself does not run a RID folder probing loop at runtime.
- Maintain shared state via dedicated services registered on
UnifiedServerCoordinatoror through plugin-specific extension dictionaries. - Bridge chat or gameplay events across servers by combining coordinator events (
SwitchJoinServer,ServerListChanged) with packet sending APIs. - Follow the sample
CommandTeleportplugin to see how server lists and transfers are exposed to users.
- Instantiate
ServerContextin headless tests to validate migrations against USP without running the full launcher. - Use event providers to simulate player joins, packet flows, or coordinator switches in isolation.
- Consider building an integration test harness under
tests/once the xUnit project is created per the repository guidelines.
The command system v2 gives you a structured, declarative way to define commands. Instead of registering string callbacks, you annotate static controller classes and methods with attributes. The framework handles discovery, parameter binding, permission checks, help text generation, Prompt completion, and audit logging — all from the same declarations.
Controllers must be static classes. Actions must be static methods. This is intentional: controllers are declaration containers, not service instances.
[CommandController("greet")]
[Aliases("g")]
public static class GreetCommand
{
[CommandAction("player")]
[TShockCommand("myplugin.greet")]
public static CommandOutcome Player(
[TSPlayerRef] TSPlayer target,
[RemainingText] string? message = null)
{
var text = message ?? $"Hello, {target.Name}!";
return CommandOutcome.Success(text);
}
[MismatchHandler]
public static CommandOutcome OnMismatch()
=> CommandOutcome.Usage("greet player <name> [message]");
}A few things worth noting here. The controller only declares the root and its actions; grouping happens on a separate type via [ControllerGroup(typeof(GreetCommand), ...)], which you install as a unit. [TerminalCommand] and [TShockCommand] expose actions to different endpoints — you can combine them, but only when the parameter list can be satisfied by every endpoint you expose. [MismatchHandler] fires when the root matches but the action path or parameters don't; it can only consume injected values, not user-bound parameters.
Architecturally, TShock is not a parallel command stack that happens to coexist with Command System V2. It plugs into and extends the V2 infrastructure with TShock-specific endpoints, permissions, binders, prompt behaviors, and audit integration. That means plugin authors usually should not build a separate adapter layer from scratch for player commands. If you are targeting TShock-style command execution, start from the TShock-facing facilities and patterns that already exist.
Parameters are bound from several sources depending on their type and attributes:
Scalar types bind directly from positional tokens — no attribute needed:
[CommandAction("set")]
public static CommandOutcome Set(CommandInvocationContext ctx, string key, int value) { ... }Remaining text collects everything after the last bound token:
[CommandAction("say")]
public static CommandOutcome Say(
[FromAmbientContext] TSExecutionContext exec,
[RemainingText] string message) { ... }Remaining args gives you the tokens as an array:
[CommandAction("run")]
public static CommandOutcome Run(CommandInvocationContext ctx, [RemainingArgs] string[] args) { ... }Injected context values come from the ambient execution context. CommandInvocationContext and CancellationToken are implicit; other ambient values should be marked with [FromAmbientContext]:
[CommandAction("info")]
public static CommandOutcome Info(
CommandInvocationContext ctx, // command metadata, raw input
[FromAmbientContext] ServerContext server,
[FromAmbientContext] TSExecutionContext exec,
CancellationToken ct) // for long-running ops
{ ... }CommandFlagsAttribute<TEnum> maps a flags enum to command-line tokens. Flags can appear anywhere in the command — they're extracted before positional binding.
[Flags]
public enum KickFlags
{
None = 0,
[CommandFlag("-s")] Silent = 1,
[CommandFlag("-b")] Ban = 2,
}
[CommandAction("kick")]
[TShockCommand("myplugin.kick")]
public static CommandOutcome Kick(
[FromAmbientContext] TSExecutionContext exec,
[TSPlayerRef] TSPlayer target,
[CommandFlags<KickFlags>] KickFlags flags,
[RemainingText] string reason)
{
var silent = flags.HasFlag(KickFlags.Silent);
// ...
return CommandOutcome.Success($"Kicked {target.Name}");
}Pre-bind guards run before parameter binding, and post-bind guards run after binding but before the action body. They're useful for context availability checks or early permission gates that don't depend on user input.
[CommandAction("reload")]
[TerminalCommand(AllowLauncherConsole = false)]
[RequireRunningServer]
public static CommandOutcome Reload([FromAmbientContext] ServerContext server) { ... }
public sealed class RequireRunningServerAttribute : PreBindGuardAttribute
{
public override CommandGuardResult Evaluate(CommandInvocationContext context)
=> context.Server?.IsRunning == true
? CommandGuardResult.Continue()
: CommandGuardResult.Fail(CommandOutcome.Error("No server is currently running."));
}For TShock endpoints, [TShockCommand("permission.node")] or [TShockCommand(nameof(Permissions.somePermission))] is usually sufficient — the dispatcher checks permissions before binding. Guards are for cases where the check depends on injected context rather than the player's permission set.
TShock registers implicit bindings for its domain types, so you can use them directly in signatures:
[CommandAction("group add")]
[TShockCommand("tshock.group.add")]
public static CommandOutcome GroupAdd(
[FromAmbientContext] TSExecutionContext exec,
Group group,
TSPlayer target)
{ ... }Implicit bindings are registered for common TShock domain types such as TSPlayer, Group, Warp, Region, and UserAccount. Additional explicit binders include [TSItemRef], [NpcRef], [ProjectileRef], [TileRef], [PageRef], [CommandRef], and [WorldTime]. Greedy phrase matching is type-specific or opt-in — for example, RegionRef is greedy by default, while player/item bindings expose options to switch consumption mode when you need names with spaces.
For most gameplay/admin plugins, this is the default integration path you want. TShock already bridges Command System V2 into player-facing command execution, permissions, REST exposure, prompt/completion behavior, and outcome formatting. Unless you have a genuinely different surface or policy model, prefer building on the TShock facilities the same way src/Plugins/CommandTeleport does instead of designing your own command-access layer.
Another way to think about the architecture is:
- UTSL V2 provides the neutral machinery.
- TShock turns that machinery into a TShock-native command surface.
- Your plugin should usually plug into that TShock-native surface, not rebuild it.
That separation is what keeps the lower layer generic while still giving plugin authors a high-level, batteries-included path for common admin/gameplay commands.
Group your controllers with [ControllerGroup] and install them in InitializeAsync:
[ControllerGroup(typeof(GreetCommand), typeof(KickCommand))]
public sealed partial class MyCommandGroup { }
// In your plugin:
IDisposable? _commandRegistration;
public override Task InitializeAsync(
IPluginConfigRegistrar configRegistrar,
ImmutableArray<PluginInitInfo> priorInitializations,
CancellationToken cancellationToken = default)
{
_commandRegistration = CommandSystem.Install(static context =>
context.AddControllerGroup<MyCommandGroup>());
return Task.CompletedTask;
}
public override ValueTask DisposeAsync(bool isDisposing)
{
if (isDisposing) {
_commandRegistration?.Dispose();
_commandRegistration = null;
}
return base.DisposeAsync(isDisposing);
}CommandSystem.Install returns a CommandInstallHandle (or can be held as IDisposable). Disposing it removes exactly the commands your plugin contributed, leaving everything else intact.
Atelier is a Roslyn-based interactive C# workspace that runs inside the Unifier process. It's not a replacement for your IDE — it's a runtime workbench for exploration, diagnostics, and one-off automation. You can access live server state, call plugin APIs, and run arbitrary C# against the running system.
Use the repl terminal command from the launcher console or a per-server console. The command is terminal-only in the current build, and you can optionally override the default target:
repl # launcher console -> launcher target; server console -> current server target
repl --server MyWorld # target a specific running server
repl --launcher # from a server console, switch the REPL target back to launcher scope
By default, submissions are persistent — variables and definitions accumulate across submissions:
// Submission 1
var serverNames = Launcher.RunningServers
.Select(s => s.Name)
.ToList();
// Submission 2 — serverNames is still in scope
serverNames.CountRun :transient <code> to execute code transiently — the result is shown, but session state is unchanged. This is useful for one-off probes you don't want to commit to the running session.
The REPL injects a ScriptGlobals host object. Every session gets Launcher, Log, HostLabel, TargetLabel, Cancellation, PendingTasks, and LastTask. Server-targeted sessions additionally expose Server, whose Context property is the underlying ServerContext.
// List running servers
Launcher.RunningServers.Select(s => s.Name)
// Inspect the current server target
Server?.Name
Server?.Snapshot(TimeSpan.FromSeconds(10))Meta-commands start with : and control the REPL itself:
| Command | Effect |
|---|---|
:help |
List available meta-commands |
:reset |
Clear session state (variables, imports) |
:clear |
Clear the output display |
:imports |
Show baseline/effective imports and reference paths |
:target |
Show the current target and invocation host |
| `:paste [on | off]` |
:transient <code> |
Run transient code without committing it |
Long-running operations can run in the background so the REPL stays responsive:
// Start a background task
var job = Task.Run(async () => {
await Task.Delay(5000, Cancellation);
return "done";
}, Cancellation);Background tasks are surfaced through PendingTasks and LastTask, and long-running code should honor Cancellation. The current build does not expose separate :jobs or :cancel meta-commands.
- Use transient mode for queries —
:transient <code>doesn't pollute session state - Watch for session invalidation — if a plugin you referenced reloads, your session is marked stale and you'll need to reopen it
- Promote to a command — once a REPL snippet proves useful, consider turning it into a proper
CommandActionso it's available to all operators