Skip to main content

Forked C# (Roslyn) — Unity Host Integration

How a forked Roslyn compiler's output would be called from Unity.


The Core Insight

A Roslyn fork desugars view/schema/template into standard C# constructs. The output is a standard .NET assembly (.dll) containing normal structs, interfaces, and classes. No FFI. No native code. No P/Invoke.

view Location { ... } → public struct LocationView { ... }
schema building { ... } → public interface IBuilding { ... }
template<building> Farm { ... } → public class Farm : IBuilding { ... }

By the time Roslyn's emitter runs, it's compiling normal C#. The forked keywords are gone.


Two Loading Modes

Development Mode: Managed Assembly Loading

The forked Roslyn compiles .sers files to a .dll. Unity loads it directly.

.sers files → Forked Roslyn → scripts.dll → Assembly.Load() → interface dispatch
// Host loads the compiled script assembly
var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath("scripts.dll");

// Discover all templates implementing IBuilding
foreach (var type in asm.ExportedTypes.Where(t => typeof(IBuilding).IsAssignableFrom(t)))
{
var building = (IBuilding)Activator.CreateInstance(type);

// Direct interface call — 2-3ns, no FFI, no unsafe, no marshaling
bool canBuild = building.CanBuild(ref location);
building.OnBuilt(ref location);
}

How it works:

  1. Forked Roslyn compiles .sers → standard .dll (IL bytecode)
  2. Unity loads .dll via Assembly.Load (Mono JIT compiles IL on demand)
  3. Host casts to shared interfaces
  4. Direct interface dispatch — ~2-3ns per call

Platform: Mono only. Does not work on IL2CPP (no runtime assembly loading).

Hot reload: Roslyn incremental compile takes 30-50ms for small changes. With CoreCLR AssemblyLoadContext unload support (Unity 6.8+), full hot-reload cycle under 100ms.

Shipping Mode: Pre-Compiled Assembly

For IL2CPP builds (iOS, consoles, WebGL), the script .dll is compiled ahead of time and included in the Unity project.

.sers files → Forked Roslyn → scripts.dll → included in Unity project → IL2CPP converts to C++

No runtime loading needed. IL2CPP treats the script assembly like any other project DLL — converts it to C++ and compiles to native. Interface dispatch still works at ~2-3ns.

This is how Unity already handles third-party DLLs. Drop a .dll in Assets/Plugins/, Unity includes it in the IL2CPP build. Nothing new here.

For mod loading on IL2CPP (runtime), an alternative: NativeAOT compiles the script .dll to a native .so/.dll/.dylib with C exports. Loaded via DllImport. Same pattern as the current Rust approach but starting from C# source.


Architecture

Shared Contracts

Three assemblies:

Sers.Contracts.dll — shared interfaces and view structs
├── IBuilding, IEvent, IDecision... (schemas → interfaces)
├── LocationView, CharacterView... (views → structs)
└── game-std API (script-callable engine functions)

scripts.dll — compiled game logic
├── Farm : IBuilding (templates → classes)
├── Market : IBuilding
└── ...

Host (Unity project) — references Contracts, loads scripts
├── SersEngine (manages script loading)
├── SersWorld (game state, subsystem registries)
└── game code

The contracts assembly is the bridge. Both host and scripts reference it. The host never needs to know about specific template classes — it discovers them via interface reflection at load time.

What the Host API Would Look Like

using Sers;

// Create engine and world
var engine = new SersEngine();
var world = engine.CreateWorld();

// Load scripts (development mode — compiles .sers, loads .dll)
world.LoadProject("/path/to/project");

// Load scripts (shipping mode — pre-compiled .dll already in project)
// No call needed, already loaded by Unity

// Discover templates
string[] buildings = world.GetTemplateNames<IBuilding>();

// Get a typed template instance
IBuilding farm = world.GetTemplate<IBuilding>("Farm");

// Call directly — no FFI, no unsafe, no vtable struct
bool canBuild = farm.CanBuild(ref location);
farm.OnBuilt(ref location);

// Query constants (still works via reflection on the loaded type)
double cost = world.GetConst<double>("building", "Farm", "BUILD_COST");
string name = world.GetConst<string>("building", "Farm", "DISPLAY_NAME");

Comparison: Current Rust Host API vs Forked C# Host API

Current (Rust cdylib via P/Invoke):

// Define vtable struct matching native layout
[StructLayout(LayoutKind.Sequential)]
struct BuildingVtable {
public SersTypedDispatch<LocationView, byte> can_build;
public SersTypedDispatch<LocationView> on_built;
}

// Get vtable pointer from native runtime
BuildingVtable vtable = world.GetVtable<BuildingVtable>("building", "Farm");

// Call through function pointer — requires unsafe, fixed, pinning
unsafe {
fixed (LocationView* ptr = &location) {
bool canBuild = vtable.can_build.Invoke(ptr) != 0;
vtable.on_built.Invoke(ptr);
}
}

Forked C# (managed assembly):

// Get typed instance
IBuilding farm = world.GetTemplate<IBuilding>("Farm");

// Call directly
bool canBuild = farm.CanBuild(ref location);
farm.OnBuilt(ref location);

What Goes Away

Current Rust ApproachForked C# Approach
Native cdylib (.so/.dll/.dylib)Managed assembly (.dll)
P/Invoke / DllImportDirect method calls
Vtable structs (LayoutKind.Sequential)Interface dispatch
unsafe / fixed / pointer pinningSafe managed code
Blittable-only view structs (primitives)Any C# struct (strings, arrays, references)
Manual string marshaling (ptr + len)Native C# strings
C ABI compatibility concernsStandard .NET assembly
~15-30ns per call (P/Invoke)~2-3ns per call (interface dispatch)
Thread-local error codesNormal C# exceptions
sers_runtime native binary per platformNo native binary needed

What's New / Different

DimensionImpact
View structs can have managed typesStrings, arrays, List, references — not just primitives. Opens up richer data models.
Exceptions instead of error codesHost catches exceptions naturally. No se_last_error_message() pattern.
No per-platform native binaryOne .dll works on Windows/Mac/Linux/mobile. IL2CPP handles native compilation.
Debugger integrationStandard C# debugging works. Set breakpoints in .sers scripts (if IDE maps to desugared C#).
NuGet ecosystemScripts can reference NuGet packages. Math libraries, data structures, etc.
GC instead of manual memoryScripts have garbage collection. No borrow checker. Simpler for modders but GC pauses at scale.

The Scope System in C#

@this/@from/@root desugar differently but achieve the same result:

Rust (current):

// @this.food += 10.0 desugars to:
unsafe { &mut *self.__this_view }.food += 10.0;

C# (forked Roslyn):

// @this.food += 10.0 desugars to:
this.__this_view.Food += 10.0;
// where __this_view is a ref field or ref property on the template class

Or with ref struct and Span<T> patterns:

// Template struct holds refs, not raw pointers
public ref struct FarmContext {
public ref LocationView This;
public ref LocationView From;
public ref EmpireView Root;
}

C# ref semantics replace Rust's raw pointers. The compiler validates scope access the same way — the schema declares which scopes exist and their types.


Mod Loading on Each Platform

PlatformDevelopment ModeShipping Mode
Unity EditorAssembly.Load (Mono/CoreCLR)N/A
Desktop (Mono)Assembly.LoadAssembly.Load
Desktop (IL2CPP)Not possiblePre-compiled .dll in project
Desktop (CoreCLR, Unity 6.8+)AssemblyLoadContext.Load/UnloadAssemblyLoadContext or pre-compiled
iOSNot possiblePre-compiled (IL2CPP static)
ConsolesNot possiblePre-compiled (IL2CPP static)
WebGLNot possiblePre-compiled (IL2CPP static)

For runtime mod loading on IL2CPP platforms: NativeAOT path. Compile .sers.dll → NativeAOT → native .so. Load via DllImport. Same as current Rust approach but C# source. Each NativeAOT library carries its own GC (~3-5MB overhead).

For desktop with CoreCLR (Unity 6.8+, late 2026): Full managed assembly loading with proper unload via AssemblyLoadContext. This is the cleanest path for desktop mod loading — load/unload mod assemblies at runtime, no native code needed.


Runtime Subsystems

The game systems (stats, modifiers, events, decisions, AI, etc.) would be pure C#:

// Stats runtime — no FFI, direct C# API
public class StatsRuntime {
public double ResolveStat(TargetId target, StatId stat) { ... }
public StatExplanation ExplainStat(TargetId target, StatId stat) { ... }
}

// Scripts call directly — no AtomicPtr callback pattern needed
public static class GameStd {
// Direct method calls, not function pointer lookups
public static double ResolveStat(TargetId target, StatId stat)
=> World.Current.Stats.ResolveStat(target, stat);
}

The entire game-std AtomicPtr/callback/dynamic-lookup infrastructure goes away. Scripts call engine APIs directly because everything is in the same managed runtime. No FFI boundary.


Performance Comparison

OperationRust (current)C# ManagedC# NativeAOT
Template method call~5-15ns (calli)~2-3ns (interface)~15-30ns (P/Invoke)
String passingptr+len marshalNative stringptr+len marshal
View struct accessunsafe pointer derefref accessunsafe pointer deref
Stat resolution (CPU-bound)Native LLVM speedJIT/.NET speed (~1.3-2x slower)NativeAOT LLVM (~same)
GC pausesNone (no GC)Yes (Gen0: <1ms, Gen2: 20-55ms at scale)Yes (per-library GC)
Memory overhead per script libMinimal~5-10MB (.NET runtime share)~3-5MB (own GC + runtime)

The trade-off: Managed C# is simpler and faster for dispatch (~2-3ns vs 5-15ns) but has GC pauses. At small-medium scale (thousands of entities), GC is fine. At CK3 scale (100K+ entities), Gen2 pauses can break frame budgets.


Summary

A forked Roslyn produces standard .dll files. Unity loads them like any other assembly. The host calls scripts through interfaces at 2-3ns per call with zero unsafe code. The entire P/Invoke layer, vtable machinery, and per-platform native binaries go away for desktop/editor.

For IL2CPP shipping: pre-compile the .dll into the project. IL2CPP handles it. For runtime mod loading on IL2CPP: NativeAOT fallback (same pattern as current Rust, but starting from C#).

The C# developer experience is dramatically simpler: no unsafe, no marshaling, no function pointers, no per-platform binaries. The cost is GC pauses at extreme scale and loss of engine-agnosticism (tied to .NET ecosystem).