Welcome! This doc walks you through how the UnifierTSL runtime is put together on top of OTAPI Unified Server Process (USP) — the key subsystems, how they fit together, and the public APIs you can use. If you haven't checked out the README or USP's Developer-Guide.md yet, those are good starting points.
- 1. Runtime Architecture
- 2. Core Services & Subsystems
- 3. USP Integration Points
- 4. Public API Surface
- 5. Runtime Lifecycle & Operations
- 6. Extensibility Guidelines & Best Practices
- USP (OTAPI.UnifiedServerProcess) – the patched server runtime layer (evolved from OTAPI) that provides per-server context isolation (
RootContext) and runtime contracts such as TrProtocol packet models and detourable hook surfaces. Unifier runtime code reaches Terraria state through this layer. - UnifierTSL Core – the launcher itself, plus orchestration, multi-server coordination, logging, config, module loading, and the plugin host.
- Modules & Plugins – your assemblies, staged under
plugins/. They can be core hosts or feature satellites, and they can embed dependencies (managed, native, NuGet) for the loader to pull out automatically. - Console Client / Publisher – tooling projects that sit alongside the runtime and share the same subsystems. The console client now renders ANSI-safe logs, semantic readline prompts, and live status frames over the host protocol.
Program.cscallsUnifierApi.HandleCommandLinePreRun(args), thenUnifierApi.PrepareRuntime(args)to parse launcher overrides, loadconfig/config.json, merge startup settings, and configure durable logging.Initializer.Initialize()andUnifierApi.InitializeCore()set up global services (logging, event hub, launcher console host, module loader) and initialize aPluginOrchestrator.- Modules get discovered and preloaded through
ModuleAssemblyLoader— assemblies are staged and dependency blobs extracted. - Plugin hosts (the built-in .NET host plus custom
[PluginHost(...)]hosts discovered from loaded modules) discover, load, and initialize plugins. UnifierApi.CompleteLauncherInitialization()resolves any missing interactive port/password inputs, syncs the effective runtime snapshot, and raisesEventHub.Launcher.InitializedEvent.UnifiedServerCoordinatoropens the listening socket and spins up aServerContextfor each configured world.- After the coordinator is live,
UnifierApi.StartRootConfigMonitoring()enables hot reload for the launcher root config. - Event bridges (chat, game, coordinator, netplay, server) hook into USP/Terraria via detours and pipe everything into
EventHub.
UnifierApi– your main entry point for grabbing loggers, events, plugin hosts, and window title helpers.UnifiedServerCoordinator– the multi-server router that manages shared Terraria state and connection lifecycles.ServerContext– a USPRootContextsubclass per world, wiring in logging, packet receivers, and extension slots.PluginOrchestrator+ hosts – handle plugin discovery, loading, init ordering, and shutdown/unload.ModuleAssemblyLoader– takes care of module staging, dependency extraction, collectible load contexts, and unload order.EventHub– the central event registry that bridges MonoMod detours into priority-sorted event pipelines.Loggingsubsystem – lightweight, allocation-friendly logging with metadata injection and pluggable writers.
The event system is the heart of UnifierTSL's pub/sub — a fast, priority-sorted pipeline with no heap allocations and full type safety.
Expand Event Hub implementation deep dive
EventHub (src/UnifierTSL/EventHub.cs) collects all event providers in one place, grouped by domain:
public class EventHub
{
public readonly ConsoleLifecycleEventHandler Launcher = new();
public readonly ChatHandler Chat = new();
public readonly CoordinatorEventBridge Coordinator = new();
public readonly GameEventBridge Game = new();
public readonly NetplayEventBridge Netplay = new();
public readonly ServerConsoleEventBridge Server = new();
}You access events like this: UnifierApi.EventHub.Game.PreUpdate, UnifierApi.EventHub.Chat.MessageEvent, etc.
UnifierApi.EventHub.Launcher.InitializedEvent fires from UnifierApi.CompleteLauncherInitialization() once launcher arguments are finalized (including interactive prompts) — right before UnifiedServerCoordinator.Launch(...) starts accepting clients.
That Launcher domain is now the console-lifecycle surface as well: plugins can replace the launcher console host and contribute prompt/status UI content before startup input begins.
There are four provider types, each tuned for different needs around mutability and cancellation:
| Provider Type | Event Data | Cancellation | Use Case |
|---|---|---|---|
ValueEventProvider<T> |
Mutable (ref T) |
Yes (Handled flag) |
Events where handlers modify data and can cancel actions (e.g., chat commands, transfers) |
ReadonlyEventProvider<T> |
Immutable (in T) |
Yes (Handled flag) |
Events where handlers inspect data and can veto actions (e.g., connection validation) |
ValueEventNoCancelProvider<T> |
Mutable (ref T) |
No | Informational events where handlers may need to modify shared state |
ReadonlyEventNoCancelProvider<T> |
Immutable (in T) |
No | Pure notification events for lifecycle/telemetry (e.g., PreUpdate, PostUpdate) |
All event args are ref struct types living on the stack — no heap allocations, no GC pressure.
Handlers run in ascending priority order (lower number = higher priority):
public enum HandlerPriority : byte
{
Highest = 0,
VeryHigh = 10,
Higher = 20,
High = 30,
AboveNormal = 40,
Normal = 50, // Default
BelowNormal = 60,
Low = 70,
Lower = 80,
VeryLow = 90,
Lowest = 100
}Registration API:
// Basic registration (Normal priority on ValueEventProvider)
UnifierApi.EventHub.Chat.ChatEvent.Register(OnChat);
// With explicit priority
UnifierApi.EventHub.Netplay.ConnectEvent.Register(OnConnect, HandlerPriority.Higher);
// With filter option (run only if already handled)
UnifierApi.EventHub.Game.GameHardmodeTileUpdate.Register(OnTileUpdate,
HandlerPriority.Low, FilterEventOption.Handled);
// Unregistration (pass same delegate reference)
UnifierApi.EventHub.Chat.ChatEvent.UnRegister(OnChat);Under the hood (src/UnifierTSL/Events/Core/ValueEventBaseProvider.cs:28-68):
- Uses a volatile snapshot array so reads during invocation are lock-free
- Binary search insertion keeps handlers sorted by priority automatically
- Copy-on-write: registration creates a new array, so ongoing invocations against the old snapshot aren't disrupted
- Only modifications are guarded by
Lock _sync
FilterEventOption controls handler execution based on event state:
public enum FilterEventOption : byte
{
Normal = 1, // Only execute if NOT handled
Handled = 2, // Only execute if already handled (e.g., cleanup/logging)
All = 3 // Always execute (Normal | Handled)
}Cancellation Model:
Handled = true: Marks event as "consumed" (conceptually cancels the action)StopPropagation = true: Stops executing remaining handlers- Different providers interpret
Handleddifferently:ReadonlyEventProvider: Returns boolean to caller (out bool handled)ValueEventProvider: Caller receives the cancellation result fromInvoke(ref data, out bool handled)(handlers setargs.Handled)- No-cancel providers: No
Handledflag exposed
Example - Chat Command Interception:
UnifierApi.EventHub.Chat.MessageEvent.Register(
(ref ReadonlyEventArgs<MessageEvent> args) =>
{
if (args.Content.Text.StartsWith("!help"))
{
SendHelpText(args.Content.Sender);
args.Handled = true; // Prevent further processing
}
},
HandlerPriority.Higher);Event bridges are the glue between MonoMod runtime detours and the event system — they turn low-level hooks into typed event invocations:
GameEventBridge (src/UnifierTSL/Events/Handlers/GameEventBridge.cs):
public class GameEventBridge
{
public readonly ReadonlyEventNoCancelProvider<ServerEvent> GameInitialize = new();
public readonly ReadonlyEventNoCancelProvider<ServerEvent> GamePostInitialize = new();
public readonly ReadonlyEventNoCancelProvider<ServerEvent> PreUpdate = new();
public readonly ReadonlyEventNoCancelProvider<ServerEvent> PostUpdate = new();
public readonly ReadonlyEventProvider<GameHardmodeTileUpdateEvent> GameHardmodeTileUpdate = new();
public GameEventBridge() {
On.Terraria.Main.Initialize += OnInitialize;
On.Terraria.NetplaySystemContext.StartServer += OnStartServer;
On.Terraria.Main.Update += OnUpdate;
On.OTAPI.HooksSystemContext.WorldGenSystemContext.InvokeHardmodeTilePlace += OnHardmodeTilePlace;
On.OTAPI.HooksSystemContext.WorldGenSystemContext.InvokeHardmodeTileUpdate += OnHardmodeTileUpdate;
}
private void OnInitialize(...) {
GameInitialize.Invoke(new(root.ToServer())); // Before Terraria.Main.Initialize original logic
orig(self, root);
}
private void OnStartServer(...) {
orig(self);
GamePostInitialize.Invoke(new(self.root.ToServer())); // After NetplaySystemContext.StartServer
}
private void OnUpdate(On.Terraria.Main.orig_Update orig, Main self, RootContext root, GameTime gameTime) {
ServerEvent data = new(root.ToServer());
PreUpdate.Invoke(data); // Before original
orig(self, root, gameTime); // Execute original Terraria logic
PostUpdate.Invoke(data); // After original
}
private bool OnHardmodeTilePlace(...) {
GameHardmodeTileUpdateEvent data = new(x, y, type, self.root.ToServer());
GameHardmodeTileUpdate.Invoke(data, out bool handled);
return !handled;
}
private bool OnHardmodeTileUpdate(...) {
GameHardmodeTileUpdateEvent data = new(x, y, type, self.root.ToServer());
GameHardmodeTileUpdate.Invoke(data, out bool handled);
return !handled; // Return false to cancel if handled
}
}GameHardmodeTileUpdate intentionally aggregates the two original OTAPI hardmode events (HardmodeTilePlace and HardmodeTileUpdate) behind one event provider.
Those hooks sit on Terraria hardmode tile infection/growth paths (for example evil/hallow spread and crystal shard growth), so one handler can enforce one policy.
After USP contextification, those event entry points move from static access to members on context instances, which is good for per-instance subscriptions but awkward for global registration. By detouring the two instance entry functions (InvokeHardmodeTilePlace and InvokeHardmodeTileUpdate) via MonoMod, Unifier exposes one global event stream; self carries the server root for the current call, and the bridge forwards self.root.ToServer() as ServerContext.
ChatHandler (src/UnifierTSL/Events/Handlers/ChatHandler.cs):
public class ChatHandler
{
public readonly ValueEventProvider<ChatEvent> ChatEvent = new();
public readonly ReadonlyEventProvider<MessageEvent> MessageEvent = new();
public ChatHandler() {
On.Terraria.Chat.Commands.SayChatCommand.ProcessIncomingMessage += ProcessIncomingMessage;
On.OTAPI.HooksSystemContext.MainSystemContext.InvokeCommandProcess += ProcessConsoleMessage;
}
}ConsoleLifecycleEventHandler (src/UnifierTSL/Events/Handlers/ConsoleLifecycleEventHandler.cs):
CreateLauncherConsoleHost(mutable) - Swap the launcher-level console host before interactive startup beginsBuildConsolePromptSummary(mutable) - Contribute summary text for semantic readline promptsBuildConsoleStatusFrame(mutable) - Contribute launcher/server status-bar contentInitializedEvent(informational) - Fired after interactive launcher inputs are resolved
NetplayEventBridge (src/UnifierTSL/Events/Handlers/NetplayEventBridge.cs):
ConnectEvent(cancellable) - Fires during client handshakeReceiveFullClientInfoEvent(cancellable) - After client metadata receivedLeaveEvent(informational) - Client disconnect notificationSocketResetEvent(informational) - Socket cleanup notification
CoordinatorEventBridge (src/UnifierTSL/Events/Handlers/CoordinatorEventBridge.cs):
CheckVersion(mutable) - GateClientHelloversion checks during pending connection authenticationSwitchJoinServerEvent(mutable) - Select destination server for joining playerServerCheckPlayerCanJoinIn(mutable) - Per-server admission hook used before candidate server selectionJoinServer(informational) - Raised once a player is bound to a destination serverPreServerTransfer(cancellable) - Before transferring player between serversPostServerTransfer(informational) - After successful transferCreateSocketEvent(mutable) - Customize socket creationStarted(informational) - Fired after coordinator launch and startup logging completesLastPlayerLeftEvent(informational) - Fired on transition fromActiveConnections > 0to0
ServerConsoleEventBridge (src/UnifierTSL/Events/Handlers/ServerConsoleEventBridge.cs):
CreateServerConsoleService(mutable) - Provide a custom per-server console implementationAddServer/RemoveServer(informational) - Server lifecycle notificationsServerListChanged(informational) - Aggregated server list changes
Event payloads use typed interfaces to carry context around:
public interface IEventContent { } // Base marker
public interface IServerEventContent : IEventContent
{
ServerContext Server { get; } // Server-scoped events
}
public interface IPlayerEventContent : IServerEventContent
{
int Who { get; } // Player-scoped events (includes server)
}Examples:
// Base event (no context)
public readonly struct MessageEvent(...) : IEventContent { ... }
// Server-scoped
public readonly struct ServerEvent(ServerContext server) : IServerEventContent { ... }
// Player-scoped (inherits server context)
public readonly struct LeaveEvent(int plr, ServerContext server) : IPlayerEventContent
{
public int Who { get; } = plr;
public ServerContext Server { get; } = server;
}Handler Invocation:
- Snapshot read: O(1) volatile access (lock-free)
- Handler iteration: O(n) where n = handler count
- Filter check: O(1) bitwise AND per handler
Memory:
- Event args: stack-allocated
ref struct— no heap allocation - Handler snapshots: immutable arrays, GC-friendly (long-lived Gen2)
- Registration: O(log n) binary search + O(n) array copy
In practice, the actual cost depends on how many handlers you have, what filters are active, and how heavy your handler logic is. The pipeline avoids per-call heap allocations as long as your handlers do the same.
- Use
EventHubfor shared/high-traffic hook points — it gives handler-level priority/filter control and predictable ordering. Raw MonoMod detours compose by detour registration order (typically last-registered-first), which is too coarse when multiple plugins need different ordering per event. If a hook is broadly useful, contribute anEventHubprovider instead of adding plugin-specific detours - Remember
ref structrules — event args can't be captured in closures or async methods, so grab what you need from them synchronously - Don't block in handlers — they run on the game thread. Offload heavy work with
Task.Run() - Unregister on shutdown — always call
UnRegister()in your plugin's dispose hook (DisposeAsync/DisposeAsync(bool isDisposing)) to avoid leaks - Pick the right provider type — use readonly/no-cancel variants when you can; they're lighter
- Go easy on
Highestpriority — save it for critical infrastructure like permission checks
Expand custom event provider extension example
To add new events, follow this pattern:
// 1. Define event content struct
public readonly struct MyCustomEvent(ServerContext server, int data) : IServerEventContent
{
public ServerContext Server { get; } = server;
public int Data { get; } = data;
}
// 2. Create provider in appropriate bridge
public class MyEventBridge
{
public readonly ValueEventProvider<MyCustomEvent> CustomEvent = new();
public MyEventBridge() {
On.Terraria.Something.Method += OnMethod;
}
private void OnMethod(...) {
MyCustomEvent data = new(server, 42);
CustomEvent.Invoke(ref data, out bool handled);
if (handled) return; // Honor cancellation
// ... original logic
}
}
// 3. Add to EventHub
public class EventHub
{
public readonly MyEventBridge MyEvents = new();
}For real-world examples, check out src/Plugins/TShockAPI/Handlers/MiscHandler.cs and src/Plugins/CommandTeleport.
The module system handles loading your plugin DLLs, pulling in dependencies, hot-reloading, and cleaning up when you unload. It sits between raw DLLs and the plugin host, using collectible AssemblyLoadContexts so modules can be swapped at runtime.
Expand Module System implementation deep dive
Three Module Categories (src/UnifierTSL/Module/ModulePreloadInfo.cs):
-
Core Modules (
[assembly: CoreModule]):- Anchor for related assemblies
- Get dedicated subdirectory:
plugins/<ModuleName>/ - Loaded in isolated
ModuleLoadContext(collectible) - Can declare dependencies via
[assembly: ModuleDependencies<TProvider>] - Other modules can depend on them via
[assembly: RequiresCoreModule("ModuleName")]
-
Satellite Modules (
[assembly: RequiresCoreModule("CoreModuleName")]):- Must reference an existing core module
- Staged in core module's directory:
plugins/<CoreModuleName>/SatelliteName.dll - Share core module's
ModuleLoadContext(critical: share types, coordinate unload) - Cannot declare own dependencies (inherit from core module)
- Loaded after core module initializes
-
Independent Modules (no special attributes):
- Stay in
plugins/root directory - Loaded in isolated
ModuleLoadContext - Cannot be targeted by satellites
- May declare dependencies if needed
- Stay in
Module Discovery and Staging (src/UnifierTSL/Module/ModuleAssemblyLoader.cs:43-175):
The loader doesn't actually load assemblies during discovery — it just reads PE headers via MetadataLoadContext:
public ModulePreloadInfo PreloadModule(string dll)
{
// 1. Read PE headers WITHOUT loading assembly
using PEReader peReader = MetadataBlobHelpers.GetPEReader(dll);
MetadataReader metadataReader = peReader.GetMetadataReader();
// 2. Extract assembly name
AssemblyDefinition asmDef = metadataReader.GetAssemblyDefinition();
string moduleName = metadataReader.GetString(asmDef.Name);
// 3. Check attributes via PE metadata
bool isCoreModule = MetadataBlobHelpers.HasCustomAttribute(metadataReader, "CoreModuleAttribute");
bool hasDependencies = MetadataBlobHelpers.HasCustomAttribute(metadataReader, "ModuleDependenciesAttribute");
string? requiresCoreModule = TryReadAssemblyAttributeData(metadataReader, "RequiresCoreModuleAttribute");
// 4. Determine staging location
string newLocation;
if (!hasDependencies && !isCoreModule && requiresCoreModule is null) {
newLocation = Path.Combine(loadDirectory, fileName); // Independent: stay in root
} else {
string moduleDir = Path.Combine(loadDirectory,
(hasDependencies || isCoreModule) ? moduleName : requiresCoreModule!);
Directory.CreateDirectory(moduleDir);
newLocation = Path.Combine(moduleDir, Path.GetFileName(dll));
}
// 5. Move files (preserving timestamps) and generate signature
CopyFileWithTimestamps(dll, newLocation);
CopyFileWithTimestamps(dll.Replace(".dll", ".pdb"), newLocation.Replace(".dll", ".pdb"));
File.Delete(dll); // Remove original
return new ModulePreloadInfo(FileSignature.Generate(newLocation), ...);
}PreloadModules() indexes discovered DLLs by dll.Name in a dictionary. If you have duplicate names across plugins/ subdirectories (or between a subdirectory and root), the last one indexed wins. Since root-level files are indexed after subdirectories, a plugins/<Name>.dll at the root will override same-name files found earlier in child folders.
Validation Rules:
- Cannot be both
CoreModuleandRequiresCoreModule RequiresCoreModulemodules cannot declare dependenciesRequiresCoreModulemust specify core module name
FileSignature (src/UnifierTSL/FileSystem/FileSignature.cs) tracks module changes with three levels of checking:
public record FileSignature(string FilePath, string Hash, DateTime LastWriteTimeUtc)
{
// Level 1: Fastest - check path + timestamp
public bool QuickEquals(string filePath) {
return FilePath == filePath && LastWriteTimeUtc == File.GetLastWriteTimeUtc(filePath);
}
// Level 2: Medium - check timestamp + SHA256 hash
public bool ExactEquals(string filePath) {
if (LastWriteTimeUtc != File.GetLastWriteTimeUtc(filePath)) return false;
return Hash == ComputeHash(filePath);
}
// Level 3: Slowest/Most thorough - check SHA256 hash only
public bool ContentEquals(string filePath) {
return Hash == ComputeHash(filePath);
}
}The module loader uses FileSignature.Hash comparison during Load() to spot updated modules (src/UnifierTSL/Module/ModuleAssemblyLoader.cs:204-207).
ModuleLoadContext (src/UnifierTSL/Module/ModuleLoadContext.cs:16) extends AssemblyLoadContext with:
public class ModuleLoadContext : AssemblyLoadContext
{
public ModuleLoadContext(FileInfo moduleFile) : base(isCollectible: true) {
this.moduleFile = moduleFile;
Resolving += OnResolving;
Unloading += OnUnloading;
ResolvingUnmanagedDll += OnResolvingUnmanagedDll;
}
}What's important here:
isCollectible: true— this is what lets you unload modules at runtime (GC collects the ALC once no references remain)- Disposal actions — plugins register cleanup via
AddDisposeAction(Func<Task>), which runs during theUnloadingevent - Resolution chain — multi-tier fallback for resolving both managed and native assemblies
Assembly Resolution Strategy (src/UnifierTSL/Module/ModuleLoadContext.cs:83-128):
OnResolving(AssemblyName assemblyName)
↓
1. Framework assemblies? -> Load from default ALC (BCL, System.*, etc.)
↓
2. Host assembly (UnifierTSL.dll)? -> Return singleton
↓
3. UTSL core libraries? -> Resolve via AssemblyDependencyResolver
↓
4. Preferred shared assembly resolution (EXACT version match):
- Search loaded modules for matching name + version
- Register dependency via LoadedModule.Reference()
- Return assembly from other module's ALC
↓
5. Module-local dependency:
- Check {moduleDir}/lib/{assemblyName}.dll
- Load from this context if exists
↓
6. Fallback shared assembly resolution (NAME-ONLY match):
- Search loaded modules for any version
- Try proxy loading (if requester references provider's core assembly)
- Return assembly from other module's ALC
↓
7. Return null (resolution failed)
Unmanaged DLL Resolution (src/UnifierTSL/Module/ModuleLoadContext.cs:134-155):
- Reads
dependencies.jsonfrom module directory - Matches by manifest entry (
DependencyItem.FilePath) rather than probing filesystem RID folders at load time - Accepts direct name matches (
sqlite3.dll) and version-suffixed manifest names (sqlite3.1.2.3.dll) - Loads the first non-obsolete manifest match via
LoadUnmanagedDllFromPath() - Current
LoadUnmanagedDllbehavior is manifest-driven; RID fallback is primarily applied earlier during dependency extraction (NugetPackageFetcher.GetNativeLibsPathsAsync,NativeEmbeddedDependency)
Dependency Declaration (src/UnifierTSL/Module/ModuleDependenciesAttribute.cs):
[assembly: ModuleDependencies<MyDependencyProvider>]
public class MyDependencyProvider : IDependencyProvider
{
public IReadOnlyList<ModuleDependency> GetDependencies() => [
new NugetDependency("Newtonsoft.Json", "13.0.3", "net9.0"),
new ManagedEmbeddedDependency(typeof(MyPlugin).Assembly, "MyPlugin.Libs.Helper.dll"),
new NativeEmbeddedDependency(typeof(MyPlugin).Assembly, "sqlite3", new("1.0.0"))
];
}Dependency Types:
-
NuGet Dependencies (src/UnifierTSL/Module/Dependencies/NugetDependency.cs):
- Resolves transitive dependencies via
NugetPackageCache.ResolveDependenciesAsync() - Downloads missing packages to global packages folder (
~/.nuget/packages) - Extracts managed libs (matching target framework) + native libs (matching RID)
- Returns
LibraryEntry[]with lazy streams
- Resolves transitive dependencies via
-
Managed Embedded Dependencies (src/UnifierTSL/Module/Dependencies/ManagedEmbeddedDependency.cs):
- Reads assembly identity from embedded resource via PE headers
- Extracts embedded DLL to
{moduleDir}/lib/{AssemblyName}.dll
-
Native Embedded Dependencies (src/UnifierTSL/Module/Dependencies/NativeEmbeddedDependency.cs):
- Probes RID fallback chain for matching embedded resource
- Extracts to
{moduleDir}/runtimes/{rid}/native/{libraryName}.{ext}
Dependency Extraction Process (src/UnifierTSL/Module/ModuleAssemblyLoader.cs:368-560):
private bool UpdateDependencies(string dll, ModuleInfo info)
{
// 1. Validate module structure (must be in named directory)
// 2. Load previous dependencies.json
DependenciesConfiguration prevConfig = DependenciesConfiguration.LoadDependenciesConfig(moduleDir);
// 3. Extract new/updated dependencies
foreach (ModuleDependency dependency in dependencies) {
if (dependency.Version != prevConfig.Version) {
ImmutableArray<LibraryEntry> items = dependency.LibraryExtractor.Extract(Logger);
// ... track highest version per file path
}
}
// 4. Copy files with lock handling
foreach (var (dependency, item) in highestVersion) {
try {
using Stream source = item.Stream.Value;
destination = Utilities.IO.SafeFileCreate(targetPath, out Exception? ex);
if (destination != null) {
source.CopyTo(destination); // Success path
}
else if (ex is IOException && FileSystemHelper.FileIsInUse(ex)) {
// File is locked by loaded assembly! Create versioned file instead
string versionedPath = Path.ChangeExtension(item.FilePath,
$"{item.Version}.{Path.GetExtension(item.FilePath)}");
destination = Utilities.IO.SafeFileCreate(versionedPath, ...);
source.CopyTo(destination);
// Track both old (obsolete) and new (active) files
currentSetting.Dependencies[dependency.Name].Manifests.Add(
new DependencyItem(item.FilePath, item.Version) { Obsolete = true });
currentSetting.Dependencies[dependency.Name].Manifests.Add(
new DependencyItem(versionedPath, item.Version));
}
}
finally { destination?.Dispose(); }
}
// 5. Cleanup obsolete files and save dependencies.json
currentConfig.SpecificDependencyClean(moduleDir, prevConfig.Setting);
currentConfig.Save(moduleDir);
}What happens when a file is locked:
- The loader detects locked files via
IOExceptionHResult code - It creates a versioned copy instead:
Newtonsoft.Json.13.0.3.dllalongsideNewtonsoft.Json.dll - The old file gets marked
Obsoletein the manifest - Cleanup happens on next restart, once the file is no longer locked
RID Graph for Native Dependencies (src/UnifierTSL/Module/Dependencies/RidGraph.cs):
- Loads embedded
RuntimeIdentifierGraph.json(NuGet's official RID graph) - BFS traversal for RID expansion:
win-x64→ [win-x64,win,any] - Used by extraction-time native selection (
NugetPackageFetcher,NativeEmbeddedDependency) ModuleLoadContext.LoadUnmanagedDll()is currently manifest-driven and does not perform RID fallback probing itself
LoadedModule (src/UnifierTSL/Module/LoadedModule.cs) keeps track of who depends on whom:
public record LoadedModule(
ModuleLoadContext Context,
Assembly Assembly,
ImmutableArray<ModuleDependency> Dependencies,
FileSignature Signature,
LoadedModule? CoreModule) // Null for core/independent modules
{
// Modules that depend on THIS module
public ImmutableArray<LoadedModule> DependentModules => dependentModules;
// Modules that THIS module depends on
public ImmutableArray<LoadedModule> DependencyModules => dependencyModules;
// Thread-safe reference tracking
public static void Reference(LoadedModule dependency, LoadedModule dependent) {
ImmutableInterlocked.Update(ref dependent.dependencyModules, x => x.Add(dependency));
ImmutableInterlocked.Update(ref dependency.dependentModules, x => x.Add(dependent));
}
}Unload Cascade (src/UnifierTSL/Module/LoadedModule.cs:50-68):
public void Unload()
{
if (CoreModule is not null) return; // Cannot unload satellites (share ALC)
if (Unloaded) return;
// Recursively unload all dependents
foreach (LoadedModule dependent in DependentModules) {
if (dependent.CoreModule == this) {
dependent.Unreference(); // Just break link (will unload with core)
} else {
dependent.Unload(); // Recursively cascade
}
}
Unreference(); // Clear all references
unloaded = true;
Context.Unload(); // Triggers OnUnloading event -> disposal actions
}Topological Sort for Ordered Unload (src/UnifierTSL/Module/LoadedModule.cs:77-109):
// Get dependents in execution order (preorder = leaf-to-root, postorder = root-to-leaf)
public ImmutableArray<LoadedModule> GetDependentOrder(bool includeSelf, bool preorder)
{
HashSet<LoadedModule> visited = []; // Cycle detection
Queue<LoadedModule> result = [];
void Visit(LoadedModule module) {
if (!visited.Add(module)) return; // Already visited (cycle detected)
if (preorder) result.Enqueue(module);
foreach (var dep in module.DependentModules) Visit(dep);
if (!preorder) result.Enqueue(module);
}
Visit(this);
return result.ToImmutableArray();
}ForceUnload (src/UnifierTSL/Module/ModuleAssemblyLoader.cs:179-189):
public void ForceUnload(LoadedModule module)
{
// If satellite, unload core instead (satellites share ALC)
if (module.CoreModule is not null) {
ForceUnload(module.CoreModule);
return;
}
// Unload in postorder (dependents before dependencies)
foreach (LoadedModule m in module.GetDependentOrder(includeSelf: true, preorder: false)) {
Logger.Debug($"Unloading module {m.Signature.FilePath}");
m.Unload();
moduleCache.Remove(m.Signature.FilePath, out _);
}
}These are two different things:
| Concept | Responsibility | Key Type |
|---|---|---|
| Module | Assembly loading, dependency management, ALC lifecycle | LoadedModule |
| Plugin | Business logic, event handlers, game integration | IPlugin / PluginContainer |
Flow:
ModuleAssemblyLoaderstages and loads assemblies →LoadedModulePluginDiscovererscans loaded modules forIPluginimplementations →IPluginInfoPluginLoaderinstantiates plugin classes →IPlugininstancesPluginContainerwrapsLoadedModule+IPlugin+PluginMetadataPluginOrchestratormanages plugin lifecycle (init, shutdown, unload)
Plugin Discovery (src/UnifierTSL/PluginHost/Hosts/Dotnet/PluginDiscoverer.cs):
public IReadOnlyList<IPluginInfo> DiscoverPlugins(string pluginsDirectory)
{
// 1. Use module loader to discover/stage modules
ModuleAssemblyLoader moduleLoader = new(pluginsDirectory);
List<ModulePreloadInfo> modules = moduleLoader.PreloadModules(ModuleSearchMode.Any).ToList();
// 2. Extract plugin metadata (find IPlugin implementations)
foreach (ModulePreloadInfo module in modules) {
pluginInfos.AddRange(ExtractPluginInfos(module));
}
return pluginInfos;
}Plugin Loading (src/UnifierTSL/PluginHost/Hosts/Dotnet/PluginLoader.cs):
public IPluginContainer? LoadPlugin(IPluginInfo pluginInfo)
{
// 1. Load module
ModuleAssemblyLoader loader = new("plugins");
if (!loader.TryLoadSpecific(info.Module, out LoadedModule? loaded, ...)) {
return null;
}
// 2. Instantiate plugin
Type? type = loaded.Assembly.GetType(info.EntryPoint.EntryPointString);
IPlugin instance = (IPlugin)Activator.CreateInstance(type)!;
// 3. Register disposal action in ALC
loaded.Context.AddDisposeAction(async () => await instance.DisposeAsync());
// 4. Wrap in container
return new PluginContainer(info.Metadata, loaded, instance);
}- Group related features into core modules — satellites share dependencies and unload together, which keeps things clean
- Declare your dependencies — use
ModuleDependenciesAttributeinstead of manually copying DLLs - Test hot reload — use
FileSignature.Hashchecks to make sure update detection works - Release file handles quickly — long-lived streams can prevent clean unloads
- Don't cache
LoadedModulereferences across reloads — they go stale - Let the loader handle NuGet — transitive resolution is automatic
- Embed for multiple RIDs if you ship native libs, or lean on NuGet package RID probing
Loading: Cost depends on how many assemblies you have, how deep the dependency graph goes, disk speed, and whether the NuGet cache is warm. Metadata scanning is lighter than a full load, but first-time NuGet resolution or native payload extraction can dominate.
Memory: Footprint scales with loaded assemblies and active ModuleLoadContext instances. Unloaded modules become reclaimable once references are released and GC runs.
PluginOrchestrator(src/UnifierTSL/PluginHost/PluginOrchestrator.cs) registers the built-inDotnetPluginHostplus any extra[PluginHost(...)]hosts discovered from loaded modules. This extension point is what enables non-default plugin/script runtimes.- Want a custom host? Implement
IPluginHost, give it a parameterless constructor, and tag it with[PluginHost(majorApiVersion, minorApiVersion)]. - Host admission is version-gated against
PluginOrchestrator.ApiVersion(currently1.0.0):majormust match exactly, and the host'sminormust be ≤ the runtime'sminor. If it doesn't match, the host is skipped with a warning. .InitializeAllAsyncpreloads modules, discovers plugin entry points viaPluginDiscoverer, and loads them throughPluginLoader.- Plugin containers are sorted by
InitializationOrder, andIPlugin.InitializeAsyncis called withPluginInitInfolists so you can await specific dependencies. ShutdownAllAsyncandUnloadAllAsyncexist on the orchestrator. In the built-inDotnetPluginHost,ShutdownAsyncnow runs pluginShutdownAsynccallbacks (graceful teardown), andUnloadPluginsAsyncunloads plugin modules/contexts.
The networking layer runs all your servers behind a single port — it routes packets to the right server, lets you intercept/modify them middleware-style, and uses pooled buffers to keep allocations low.
Expand Networking & Coordinator implementation deep dive
UnifiedServerCoordinator (src/UnifierTSL/UnifiedServerCoordinator.cs) centralizes shared networking state across all servers:
Global Shared State:
// Index = client slot (0-255)
Player[] players // Player entity state
RemoteClient[] globalClients // TCP socket wrappers
LocalClientSender[] clientSenders // Per-client packet senders
MessageBuffer[] globalMsgBuffers // Receive buffers (256 × servers count)
ServerContext?[] clientCurrentlyServers // Client → server mapping (read/write via Volatile helpers)Connection Pipeline:
TcpClient connects to unified port (7777)
↓
OnConnectionAccepted(): Find empty slot (0-255)
↓
Create PendingConnection (pre-auth phase)
↓
Async receive loop: ClientHello, SendPassword, SyncPlayer, ClientUUID
↓
SwitchJoinServerEvent fires → plugin selects destination server
↓
Activate client in chosen ServerContext
↓
Byte processing routed via ProcessBytes hook
PendingConnection (src/UnifierTSL/UnifiedServerCoordinator.cs:529-696):
- Handles pre-authentication packets before server assignment
- Validates client version against
Terraria{Main.curRelease}(with override viaCoordinator.CheckVersion) Coordinator.CheckVersionis currently invoked twice duringClientHellohandling; handlers should be idempotent and avoid side effects that assume single invocation- Password authentication (if
UnifierApi.ServerPasswordis set) - Collects client metadata:
ClientUUID, player name and appearance - Kicks incompatible clients with
NetworkTextreasons
Server Transfer Protocol (src/UnifierTSL/UnifiedServerCoordinator.cs:290-353):
public static void TransferPlayerToServer(byte plr, ServerContext to, bool ignoreChecks = false)
{
ServerContext? from = GetClientCurrentlyServer(plr);
if (from is null || from == to) return;
if (!to.IsRunning && !ignoreChecks) return;
UnifierApi.EventHub.Coordinator.PreServerTransfer.Invoke(new(from, to, plr), out bool handled);
if (handled) return;
// Sync leave → switch mapping → sync join
from.SyncPlayerLeaveToOthers(plr);
from.SyncServerOfflineToPlayer(plr);
SetClientCurrentlyServer(plr, to);
to.SyncServerOnlineToPlayer(plr);
to.SyncPlayerJoinToOthers(plr);
UnifierApi.EventHub.Coordinator.PostServerTransfer.Invoke(new(from, to, plr));
}Packet Routing Hook (src/UnifierTSL/UnifiedServerCoordinator.cs:356-416):
On.Terraria.NetMessageSystemContext.CheckBytes += ProcessBytes;
private static void ProcessBytes(
On.Terraria.NetMessageSystemContext.orig_CheckBytes orig,
NetMessageSystemContext netMsg,
int clientIndex)
{
ServerContext server = netMsg.root.ToServer();
MessageBuffer buffer = globalMsgBuffers[clientIndex];
lock (buffer) {
// Decode packet length, then route payload to NetPacketHandler.ProcessBytes(...)
NetPacketHandler.ProcessBytes(server, buffer, contentStart, contentLength);
}
}NetPacketHandler (src/UnifierTSL/Events/Handlers/NetPacketHandler.cs) provides middleware-style packet processing with cancellation and rewriting capabilities.
Handler Registration:
NetPacketHandler.Register<TrProtocol.NetPackets.TileChange>(
(ref ReceivePacketEvent<TileChange> args) =>
{
if (IsProtectedRegion(args.Packet.X, args.Packet.Y)) {
args.HandleMode = PacketHandleMode.Cancel; // Block packet
}
},
HandlerPriority.Highest);Handler Storage:
- Static array:
Array?[] handlers = new Array[INetPacket.GlobalIDCount] - One slot per packet type (indexed by
TPacket.GlobalID) - Each slot stores
PriorityItem<TPacket>[](sorted by priority)
Packet Processing Flow:
ProcessBytes(server, messageBuffer, contentStart, contentLength)
↓
1. Parse MessageID from buffer
↓
2. Dispatch to type-specific handler via switch(messageID):
- ProcessPacket_F<TPacket>() // Fixed, non-side-specific
- ProcessPacket_FS<TPacket>() // Fixed, side-specific
- ProcessPacket_D<TPacket>() // Dynamic (managed)
- ProcessPacket_DS<TPacket>() // Dynamic, side-specific
↓
3. Deserialize packet from buffer (unsafe pointers)
↓
4. Execute handler chain (priority-ordered)
↓
5. Evaluate PacketHandleMode:
- None: Forward to MessageBuffer.GetData() (original logic)
- Cancel: Suppress packet entirely
- Overwrite: Re-inject via ClientPacketReceiver.AsReceiveFromSender_*()
↓
6. Invoke PacketProcessed callbacks
PacketHandleMode (src/UnifierTSL/Events/Handlers/NetPacketHandler.cs):
public enum PacketHandleMode : byte
{
None = 0, // Pass through to original Terraria logic
Cancel = 1, // Block packet (prevent processing)
Overwrite = 2 // Use modified packet (re-inject via ClientPacketReceiver)
}Example - Packet Modification:
NetPacketHandler.Register<TrProtocol.NetPackets.TileChange>(
(ref ReceivePacketEvent<TileChange> args) =>
{
// Deny tile placement in protected regions
if (IsProtectedRegion(args.Packet.X, args.Packet.Y)) {
args.HandleMode = PacketHandleMode.Cancel;
}
});PacketSender (src/UnifierTSL/Network/PacketSender.cs) - Abstract base class for generic-specialized packet transmission (lets struct packet types stay on no-boxing fast paths):
API Methods:
// Fixed-size packets (unmanaged structs)
public void SendFixedPacket<TPacket>(scoped in TPacket packet)
where TPacket : unmanaged, INonSideSpecific, INetPacket
// Dynamic-size packets (managed types)
public void SendDynamicPacket<TPacket>(scoped in TPacket packet)
where TPacket : struct, IManagedPacket, INonSideSpecific, INetPacket
// Server-side variants (set IsServerSide flag)
public void SendFixedPacket_S<TPacket>(scoped in TPacket packet) where TPacket : unmanaged, ISideSpecific, INetPacket
public void SendDynamicPacket_S<TPacket>(scoped in TPacket packet) where TPacket : struct, IManagedPacket, ISideSpecific, INetPacket
// Runtime dispatch (uses a switch covering many packet types)
public void SendUnknownPacket<TPacket>(scoped in TPacket packet) where TPacket : struct, INetPacketBuffer Management (src/UnifierTSL/Network/SocketSender.cs:79-117):
private unsafe byte[] AllocateBuffer<TPacket>(in TPacket packet, out byte* ptr_start) where TPacket : INetPacket
{
int capacity = packet is TrProtocol.NetPackets.TileSection ? 16384 : 1024;
byte[] buffer = ArrayPool<byte>.Shared.Rent(capacity);
fixed (byte* buf = buffer) {
ptr_start = buf + 2; // Reserve 2 bytes for length header
}
return buffer;
}
private void SendDataAndFreeBuffer(byte[] buffer, int totalLength, SocketSendCallback? callback)
{
try {
if (callback is not null) {
Socket.AsyncSendNoCopy(buffer, 0, totalLength, callback);
} else {
Socket.AsyncSend(buffer, 0, totalLength);
}
}
finally {
ArrayPool<byte>.Shared.Return(buffer);
}
}LocalClientSender (src/UnifierTSL/Network/LocalClientSender.cs) - Per-client packet sender:
public class LocalClientSender : SocketSender
{
public readonly int ID;
public RemoteClient Client => UnifiedServerCoordinator.globalClients[ID];
public sealed override ISocket Socket => Client.Socket;
public sealed override void Kick(NetworkText reason, bool bg = false) {
Client.PendingTermination = true;
Client.PendingTerminationApproved = true;
base.Kick(reason, bg);
}
}Packet Reception Simulation - ClientPacketReceiver (src/UnifierTSL/Network/ClientPacketReceiver.cs):
Used when handlers set HandleMode = Overwrite:
public void AsReceiveFromSender_FixedPkt<TPacket>(LocalClientSender sender, scoped in TPacket packet)
where TPacket : unmanaged, INetPacket, INonSideSpecific
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(sizeof(TPacket) + 4);
try {
unsafe {
fixed (byte* buf = buffer) {
void* ptr = buf + 2;
packet.WriteContent(ref ptr); // Serialize modified packet
short len = (short)((byte*)ptr - buf);
*(short*)buf = len;
}
}
// Inject as if received from sender
Server.NetMessage.buffer[sender.ID].GetData(Server, 0, len, out _, buffer, ...);
}
finally {
ArrayPool<byte>.Shared.Return(buffer);
}
}UnifierTSL consumes TrProtocol packet models that USP bundles into the runtime via IL merge:
Packet Characteristics:
- Many packet types defined as structs under
TrProtocol.NetPacketsandTrProtocol.NetPackets.Modules - Interfaces:
INetPacket,IManagedPacket,ISideSpecific,INonSideSpecific - Dispatch strategy: prefer generic methods with interface constraints over runtime
is-based interface dispatch that boxes packet structs; paired interfaces likeISideSpecific/INonSideSpecificencode mutually exclusive compile-time paths and reduce misrouting risk - Serialization contract: pointer-based
ReadContent(ref void* ptr, void* ptrEnd)andWriteContent(ref void* ptr); the read end-pointer enables bounded reads and managed exceptions on overflow attempts
For concrete packet models and fields, inspect the actual TrProtocol packet structs shipped with the runtime.
UnifiedNetworkPatcher (src/UnifierTSL/Network/UnifiedNetworkPatcher.cs:10-31) hooks USP initialization to redirect to shared arrays:
On.Terraria.NetplaySystemContext.StartServer += (orig, self) =>
{
ServerContext server = self.root.ToServer();
self.Connection.ResetSpecialFlags();
self.ResetNetDiag();
// Replace per-server arrays with global shared arrays
self.Clients = UnifiedServerCoordinator.globalClients;
server.NetMessage.buffer = UnifiedServerCoordinator.globalMsgBuffers;
// Disable per-server broadcasting (coordinator handles it)
On.Terraria.NetplaySystemContext.StartBroadCasting = (_, _) => { };
On.Terraria.NetplaySystemContext.StopBroadCasting = (_, _) => { };
};-
Packet handlers:
- Register them early (in
InitializeAsyncorBeforeGlobalInitialize) - Choose priority by ordering requirements; reserve
Highestfor handlers that must run before everything else (for example hard security gates) - Always unregister in your dispose hook (
DisposeAsync(bool isDisposing)if you're usingBasePlugin)
- Register them early (in
-
Packet modification:
- Pick
CancelvsOverwriteby intent:Canceldrops the packet,Overwritere-injects your modified packet - If you set
HandleMode = Overwrite, usually also setStopPropagation = trueunless you explicitly want downstream handlers to process the rewritten packet - Keep packet data read-only unless you intentionally overwrite it
- Pick
-
Post-processing callbacks (
PacketProcessed):- Use for after-processing work such as metrics, tracing, or business logic that depends on final outcome
- Branch on the callback
PacketHandleMode(None,Cancel,Overwrite) instead of assuming a single path
-
Server transfers:
- Treat
PreServerTransferas the veto point before any state swap - Use
PostServerTransferfor logic that must run after mapping switch + join sync - Query current mapping via coordinator helpers (
GetClientCurrentlyServer) instead of caching long-lived snapshots
- Treat
-
Memory and sender lifecycle:
PacketSenderandClientPacketReceiverrent temporary buffers fromArrayPool<byte>.Shared; never retain rented arrays past the callback/scopeUnifiedServerCoordinatorpre-allocates oneLocalClientSenderper client slot inclientSenders
Packet processing: Handler lookup is O(1) via packet GlobalID. Total cost scales with how many handlers you registered for that packet type and what they do. Serialization overhead depends on packet shape and size.
Buffer pooling: Uses ArrayPool<byte>.Shared with larger initial buffers for tile-heavy packets. How well this works depends on your traffic patterns and concurrency.
Multi-server routing: Client→server lookup is O(1) through volatile-backed mapping. Transfer cost is mostly about sync steps and your event handlers.
The logging system is built for performance — LogEntry lives on the stack, metadata is pooled, and writer/filter/injector composition is held in immutable LoggerPipeline snapshots. Logger also keeps a bounded in-memory history ring so sinks can replay recent entries before joining live writes, while the launcher can attach an async durable writer that drains to txt or sqlite sinks in the background. History bookkeeping stays under publishSync, but external writer I/O happens after the lock is released so console and sink writes do not block the logger core.
Expand Logging Infrastructure implementation deep dive
Logger (src/UnifierTSL/Logging/Logger.cs) - Core logging engine:
public class Logger
{
private readonly LoggerPipeline pipeline;
private readonly ILogWriter localWriter;
private readonly Lock publishSync = new();
public void Log(ref LogEntry entry)
{
LoggerPipelineSnapshot pipelineSnapshot = pipeline.Snapshot;
// 1. Apply metadata injectors from the current snapshot
foreach (var injector in pipelineSnapshot.MetadataInjectors) {
injector.InjectMetadata(ref entry);
}
// 2. Filter check
if (!pipelineSnapshot.Filter.ShouldLog(in entry)) return;
// 3. Commit history bookkeeping only
lock (publishSync) {
CommitToHistory(in entry);
}
// 4. Write after releasing the history lock
localWriter.Write(in entry);
pipelineSnapshot.Writer.Write(in entry);
}
}LoggerPipeline uses lock-free snapshot replacement for filter/writer/injector changes. ServerConsoleLogWriter binds server loggers to RemoteConsoleService when present, and only strips ANSI when it must fall back to a non-remote console writer.
LogEntry (src/UnifierTSL/Logging/LogEntry.cs) - Structured log event:
public ref struct LogEntry
{
public string Role { get; init; } // Logger scope (e.g., "TShockAPI", "Log")
public string? Category { get; init; } // Sub-category (e.g., "ConnectionAccept")
public string Message { get; init; }
public LogLevel Level { get; init; }
public DateTime Timestamp { get; init; }
public Exception? Exception { get; init; }
public LogEventId? EventId { get; init; }
public ref readonly TraceContext TraceContext { get; }
private MetadataCollection metadata; // ArrayPool-backed sorted collection
public void SetMetadata(string key, string value) {
metadata.Set(key, value); // Binary search insert
}
}RoleLogger (src/UnifierTSL/Logging/RoleLogger.cs) wraps Logger with host context:
public class RoleLogger
{
private readonly Logger logger;
private readonly ILoggerHost host;
private ImmutableArray<ILogMetadataInjector> injectors = [];
public void Log(LogLevel level, string message, ReadOnlySpan<KeyValueMetadata> metadata = default)
{
// 1. Create entry
MetadataAllocHandle allocHandle = logger.CreateMetadataAllocHandle();
LogEntry entry = new(host.Name, message, level, ref allocHandle);
// 2. Apply manual metadata
foreach (var kv in metadata) {
entry.SetMetadata(kv.Key, kv.Value);
}
// 3. Apply RoleLogger injectors
foreach (var injector in injectors) {
injector.InjectMetadata(ref entry);
}
// 4. Delegate to Logger
logger.Log(ref entry);
// 5. Cleanup
allocHandle.Free();
}
}Extension Methods (src/UnifierTSL/Logging/LoggerExt.cs):
public static void Info(this RoleLogger logger, string message, string? category = null)
=> logger.Log(LogLevel.Info, message, overwriteCategory: category);
public static void Warning(this RoleLogger logger, string message, string? category = null)
=> logger.Log(LogLevel.Warning, message, overwriteCategory: category);
public static void Error(this RoleLogger logger, string message, Exception? ex = null, string? category = null)
=> logger.Log(LogLevel.Error, message, overwriteCategory: category, exception: ex);
public static void LogHandledException(this RoleLogger logger, string message, Exception ex, string? category = null)
=> logger.Log(LogLevel.Error, message, overwriteCategory: category, exception: ex);MetadataCollection (src/UnifierTSL/Logging/Metadata/MetadataCollection.cs) - Sorted key-value storage:
public ref struct MetadataCollection
{
private Span<KeyValueMetadata> _entries; // ArrayPool buffer
private int _count;
public void Set(string key, string value)
{
// Binary search for insertion point
int index = BinarySearch(key);
if (index >= 0) {
// Key exists - update value
_entries[index] = new(key, value);
} else {
// Key doesn't exist - insert at ~index
index = ~index;
EnsureCapacity(_count + 1);
_entries.Slice(index, _count - index).CopyTo(_entries.Slice(index + 1));
_entries[index] = new(key, value);
_count++;
}
}
private void EnsureCapacity(int requiredCapacity)
{
if (_entries.Length >= requiredCapacity) return;
// Grow via ArrayPool
int newCapacity = _entries.Length == 0 ? 4 : _entries.Length * 2;
Span<KeyValueMetadata> newBuffer = ArrayPool<KeyValueMetadata>.Shared.Rent(newCapacity);
_entries.CopyTo(newBuffer);
ArrayPool<KeyValueMetadata>.Shared.Return(_entries.ToArray());
_entries = newBuffer;
}
}MetadataAllocHandle (src/UnifierTSL/Logging/Metadata/MetadataAllocHandle.cs) - Allocation manager:
public unsafe struct MetadataAllocHandle
{
private delegate*<int, Span<KeyValueMetadata>> _allocate;
private delegate*<Span<KeyValueMetadata>, void> _free;
public Span<KeyValueMetadata> Allocate(int capacity) => _allocate(capacity);
public void Free(Span<KeyValueMetadata> buffer) => _free(buffer);
}ConsoleLogWriter (src/UnifierTSL/Logging/LogWriters/ConsoleLogWriter.cs) - Server-routed console output:
public class ConsoleLogWriter : ILogWriter
{
public void Write(in LogEntry raw)
{
// 1. Check for server-specific routing
if (raw.TryGetMetadata("ServerContext", out string? serverName)) {
ServerContext? server = UnifiedServerCoordinator.Servers
.FirstOrDefault(s => s.Name == serverName);
if (server is not null) {
WriteToConsole(server.Console, raw);
return;
}
}
// 2. Write to global console
WriteToConsole(Console, raw);
}
private static void WriteToConsole(IConsole console, in LogEntry raw)
{
lock (SynchronizedGuard.ConsoleLock) {
// Format segments with color codes
foreach (ColoredSegment segment in DefConsoleFormatter.Format(raw)) {
console.ForegroundColor = segment.ForegroundColor;
console.BackgroundColor = segment.BackgroundColor;
console.Write(segment.Text.Span);
}
console.WriteLine();
}
}
}Color Mapping:
| LogLevel | Level Text | Foreground Color |
|---|---|---|
| Trace | [Trace] |
Gray |
| Debug | [Debug] |
Blue |
| Info | [+Info] |
White |
| Success | [Succe] |
Green |
| Warning | [+Warn] |
Yellow |
| Error | [Error] |
Red |
| Critical | [Criti] |
DarkRed |
| (Unknown) | [+-·-+] |
White |
Output Format (src/UnifierTSL/Logging/Formatters/ConsoleLog/DefConsoleFormatter.cs:13-71):
Single-line message:
[Level][Role|Category] Message
Multi-line message:
[Level][Role|Category] First line
│ Second line
└── Last line
With exception (Handled - Level ≤ Warning):
[Level][Role|Category] Message
│ Handled Exception:
│ Exception line 1
│ Exception line 2
└── Exception line N
With exception (Unexpected - Level > Warning):
[Level][Role|Category] Message
│ Unexpected Exception:
│ Exception line 1
│ Exception line 2
└── Exception line N
Segment Structure:
- Segment 0: Level text (colored by level)
- Segment 1: Role/Category text (cyan foreground, black background)
- Segment 2: Main message with box-drawing characters for multi-line (colored by level)
- Segment 3 (optional): Exception details with box-drawing characters (red foreground, white background)
TraceContext (src/UnifierTSL/Logging/LogTrace/TraceContext.cs) - Distributed tracing context:
[StructLayout(LayoutKind.Explicit, Size = 40)]
public readonly struct TraceContext(Guid correlationId, TraceId traceId, SpanId spanId)
{
[FieldOffset(00)] public readonly Guid CorrelationId = correlationId; // 16 bytes
[FieldOffset(16)] public readonly TraceId TraceId = traceId; // 8 bytes (ulong)
[FieldOffset(32)] public readonly SpanId SpanId = spanId; // 8 bytes (ulong)
}Usage:
TraceContext trace = new(
Guid.NewGuid(), // Unique request ID
new TraceId((ulong)DateTime.UtcNow.Ticks), // Logical trace chain
new SpanId((ulong)Thread.CurrentThread.ManagedThreadId) // Operation span
);
logger.Log(LogLevel.Info, "Player authenticated", in trace,
metadata: stackalloc[] { new("PlayerId", playerId.ToString()) });ServerContext (src/UnifierTSL/Servers/ServerContext.cs) implements both ILoggerHost and ILogMetadataInjector:
public partial class ServerContext : RootContext, ILoggerHost, ILogMetadataInjector
{
public readonly RoleLogger Log;
public string? CurrentLogCategory { get; set; }
string ILoggerHost.Name => "Log";
public ServerContext(...) {
Log = UnifierApi.CreateLogger(this, overrideLogCore);
Log.AddMetadataInjector(injector: this); // Register self
}
void ILogMetadataInjector.InjectMetadata(scoped ref LogEntry entry) {
entry.SetMetadata("ServerContext", this.Name); // Auto-inject server name
}
}Per-Server Routing:
Server1.Log.Info("Player joined")
↓
LogEntry with metadata["ServerContext"] = "Server1"
↓
ConsoleLogWriter detects metadata, routes to Server1.Console
↓
Output in Server1's console window with server-specific colors
Overhead: Logging is allocation-light — LogEntry is stack-based, metadata is pool-backed. Actual overhead depends on how much metadata you attach, your formatter, and which sink you're writing to.
Memory: Metadata buffers grow on demand and get reused. Keep your metadata sets small to avoid resize churn.
Throughput: In practice, throughput is limited by your sink. Console output is much slower than buffered file writes. If you have production latency targets, benchmark with your actual sink and log volume.
-
Use metadata, not string interpolation:
// Bad: Creates string allocation logger.Info($"Player {playerId} joined"); // Good: Uses structured metadata logger.Info("Player joined", metadata: stackalloc[] { new("PlayerId", playerId.ToString()) });
-
Scope your log categories:
// Set category for block of related logs serverContext.CurrentLogCategory = "WorldGen"; GenerateWorld(); serverContext.CurrentLogCategory = null;
-
Add custom metadata injectors for correlation:
public class RequestIdInjector : ILogMetadataInjector { private readonly AsyncLocal<Guid> requestId = new(); public void InjectMetadata(scoped ref LogEntry entry) { if (requestId.Value != Guid.Empty) { entry.SetMetadata("RequestId", requestId.Value.ToString()); } } } logger.AddMetadataInjector(new RequestIdInjector());
-
Log exceptions properly:
try { DangerousOperation(); } catch (Exception ex) { logger.LogHandledException("Operation failed", ex, category: "DangerousOperation"); // Exception details auto-formatted in console output }
For more logging examples, see src/Plugins/TShockAPI/TShock.cs and src/UnifierTSL/Servers/ServerContext.cs.
ConfigRegistrarimplementsIPluginConfigRegistrar. In the built-in .NET host, plugin config root isPath.Combine("config", Path.GetFileNameWithoutExtension(container.Location.FilePath))(for exampleconfig/TShockAPI).CreateConfigRegistration<T>gives you aConfigRegistrationBuilderwhere you set defaults, serialization options, error policies (DeserializationFailureHandling), and external-change triggers.- You get back a
ConfigHandle<T>that lets youRequestAsync,Overwrite,ModifyInMemory, and subscribe viaOnChangedAsyncfor hot reload. File access is guarded byFileLockManagerto prevent corruption. - Launcher root config is separate from plugin config:
LauncherConfigManagerownsconfig/config.json, creates it when missing, and intentionally ignores the legacy root-levelconfig.json. - Startup precedence for launcher settings is
config/config.json-> CLI overrides -> interactive fallback for missing port/password, and the effective startup snapshot is persisted back toconfig/config.json. After startup, edits toconfig/config.jsonapply to the launcher settings that support reload. - Root-config hot reload applies
launcher.serverPassword,launcher.joinServer, additivelauncher.autoStartServers,launcher.listenPort(via listener rebind),launcher.colorfulConsoleStatus, andlauncher.consoleStatus.
ServerContextinherits USP'sRootContext, plugging Unifier services into the context (custom console, packet receiver, logging metadata). Everything that touches Terraria world/game state goes through this context.- The networking patcher (
UnifiedNetworkPatcher) detoursNetplaySystemContextfunctions to share buffers and coordinate send/receive paths across servers. - MonoMod
On.detours are a valid tool for niche/cold hooks. For common or contested hook points, prefer exposing/usingEventHubproviders so plugins can share handler-level ordering/filtering instead of stacking plugin-local detours. - Unifier directly uses TrProtocol packet structs/interfaces from the USP runtime (
INetPacket,IManagedPacket,ISideSpecific, etc.), andPacketSender/NetPacketHandlerfollow TrProtocol read/write contracts.
- The runtime boots through launcher entrypoints (
Program.cs) viaHandleCommandLinePreRun,PrepareRuntime,InitializeCore, andCompleteLauncherInitialization, then turns onStartRootConfigMonitoringafter the coordinator is live — these are internal startup APIs, not something your plugin calls. EventHubgives you access to all grouped event providers once init is done.EventHub.Launcher.InitializedEventfires when launcher arguments are finalized (including interactive fallback), right before the coordinator starts; root config file watching begins only afterUnifiedServerCoordinator.Launch(...)succeeds.PluginHostslazily sets up thePluginOrchestratorfor host-level interactions.CreateLogger(ILoggerHost, Logger? overrideLogCore = null)gives you aRoleLoggerscoped to your plugin, reusing the sharedLogger.UpdateTitle(bool empty = false)controls the window title based on coordinator state.VersionHelperandLogCore/Loggerprovide shared utilities (version info, logging core).
- Event payload structs live under
src/UnifierTSL/Events/*and implementIEventContent. The specialised interfaces (IPlayerEventContent,IServerEventContent) add context like server or player info. HandlerPriorityandFilterEventOptioncontrol invocation order and filtering.- Registration/unregistration helpers are thread-safe and allocation-light.
ModulePreloadInfo,ModuleLoadResult,LoadedModuledescribe module metadata and lifecycle.IPlugin,BasePlugin,PluginContainer,PluginInitInfo, andIPluginHostdefine plugin contracts and containers.- Configuration surface includes
IPluginConfigRegistrar,ConfigHandle<T>, andConfigFormatenumeration.
PacketSenderexposes fixed/dynamic packet send helpers plus server-side variants.NetPacketHandleroffersRegister<TPacket>,UnRegister<TPacket>,ProcessBytes, and packet callbacks overReceivePacketEvent<T>.LocalClientSenderwraps aRemoteClient, exposingKick,SendData, andClientmetadata.ClientPacketReceiverreplays or rewrites inbound packets.- Coordinator helpers provide
TransferPlayerToServer,SwitchJoinServerEvent, and state queries (GetClientCurrentlyServer,Serverslist).
RoleLoggerextension methods (seesrc/UnifierTSL/Logging/LoggerExt.cs) give you severity helpers:Debug,Info,Warning,Error,Success,LogHandledException.LogEventIdslists standard event identifiers for categorising log output.Logger.ReplayHistory(...)andLogCore.AttachHistoryWriter(...)let new sinks catch up from the in-memory history ring before they start receiving live writes.- Event providers expose
HandlerCount, and you can enumerateEventProvider.AllEventsfor diagnostics dashboards.
HandleCommandLinePreRunapplies pre-run language overrides and locks in the active Terraria culture.PrepareRuntimeparses launcher CLI overrides, loadsconfig/config.json, merges startup settings, and configures the durable logging backend.ModuleAssemblyLoader.Loadscansplugins/, stages assemblies, and handles dependency extraction.- Plugin hosts find eligible entry points and instantiate
IPluginimplementations. BeforeGlobalInitializeruns synchronously on every plugin — use it for cross-plugin service wiring.InitializeAsyncruns for each plugin; you get prior plugin init tasks so you can await your dependencies.InitializeCorewiresEventHub, finishes plugin host initialization, installs the launcher console host (TerminalLauncherConsoleHostby default), and applies the resolved launcher defaults (join policy + queued auto-start worlds).CompleteLauncherInitializationprompts for any still-missing port/password through the launcher console host, syncs the effective runtime snapshot, and firesEventHub.Launcher.InitializedEvent.UnifiedServerCoordinator.Launch(...)binds the shared listener, starts the configured worlds, and registers the live coordinator loops.StartRootConfigMonitoring()begins watchingconfig/config.json; thenProgram.Run()updates the title, logs startup success, and firesEventHub.Coordinator.Started.
A few notes on launcher args:
- Language precedence:
UTSL_LANGUAGEenv var is applied before CLI parsing and blocks later-lang/-culture/-languageoverrides. -server/-addserver/-autostartparse server descriptors duringPrepareRuntime; merge behavior is controlled by-servermerge/--server-merge/--auto-start-merge(replacedefault,overwrite,append) and the effective startup list is persisted.-joinserversets the launcher's low-priority default join mode (random|rnd|rorfirst|f) inside a permanent resolver; later root-config reloads can replace that mode without re-registering handlers.-logmode/--log-modeselects the durable log backend (txt,none, orsqlite).-colorful/--colorful/--no-colorfulcontrol vivid ANSI status rendering for interactive launcher terminals; unsupported terminals automatically fall back to the plain palette.UnifierApi.CompleteLauncherInitialization()prompts for any missing port/password through semantic readline specs (ghost text, rotating suggestions, live status lines) when the host is interactive, then firesEventHub.Launcher.InitializedEvent.Program.Run()launches the coordinator, enables root config monitoring, logs success, then firesEventHub.Coordinator.Started.
- Event handlers handle cross-cutting concerns — chat moderation, transfer control, packet filtering, etc.
- Config handles react to file changes, so you can tweak settings without restarting.
- The launcher root config watcher applies password changes, join-policy changes, additive auto-start worlds (hot-add only),
launcher.listenPortlistener rebinding,launcher.colorfulConsoleStatus, andlauncher.consoleStatus. - The coordinator keeps window titles updated, maintains server lists, replays join/leave sequences, and can swap the active listener without tearing down the process.
- Launcher and per-server consoles now exchange semantic prompt/render/status frames through
TerminalLauncherConsoleHost,RemoteConsoleService, and the console-client protocol, so reconnects can replay cached state instead of dropping back to plain text. - Logging metadata and the bounded history ring let you trace any log entry back to its server, plugin, or subsystem, and attach new sinks without losing recent context.
- Durable backends (
txt/sqlite) now run on a background consumer queue;nonebypasses durable history commits entirely to keep the hot path minimal.
PluginOrchestratorexposesShutdownAllAsyncandUnloadAllAsync. In the built-inDotnetPluginHost,ShutdownAsynchandles graceful plugin shutdown, whileUnloadPluginsAsyncperforms module/context unload.- Module reload and targeted unload work through loader APIs (
ModuleAssemblyLoader.TryLoadSpecific,ForceUnload) and plugin-loader ops (TryUnloadPlugin/ForceUnloadPlugin). - Plugin
DisposeAsynchooks intoModuleLoadContextunload via registered dispose actions.
- Event providers expose handler counts for observability — enumerate
EventProvider.AllEventsto build dashboards. PacketSender.SentPacket,NetPacketHandler.ProcessPacketEvent, and per-packetPacketProcessedcallbacks are great for traffic metrics and tracing.- Logging metadata injectors give you per-server/per-plugin tags for filtering in external sinks.
- Use event providers first — before adding MonoMod detours in your plugin, check if an
EventHubprovider already exists (or add one to the core so others can benefit too). - Stay within context boundaries — always go through
ServerContextand USP context APIs to avoid cross-server bugs. - Declare your dependencies — if you're shipping modules with native/managed deps, use
ModuleDependenciesAttributeso the loader can track and clean up properly. - No async in event handlers —
ref structargs can't be captured in closures or async methods. Grab what you need, then schedule async work separately. - Await your dependencies explicitly — use
PluginInitInfoto await prerequisite plugins instead of just hoping the order works out. - Use the built-in logging — create loggers via
UnifierApi.CreateLoggerso you get metadata injection and console formatting for free. AddILogMetadataInjectorfor correlation data. - Write tests around events and coordinator flows — simulate packet sequences, player joins, etc. USP contexts can run in isolation, which makes this pretty straightforward.
- Batch registrations at startup — event registration is thread-safe but not free. Use pooled buffers from the packet sender and keep allocations out of hot paths.
- Build monitoring plugins with hooks —
PacketSender.SentPacketand event filters let you observe traffic without touching the core runtime.