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:
- Forked Roslyn compiles
.sers→ standard.dll(IL bytecode) - Unity loads
.dllviaAssembly.Load(Mono JIT compiles IL on demand) - Host casts to shared interfaces
- 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 Approach | Forked C# Approach |
|---|---|
| Native cdylib (.so/.dll/.dylib) | Managed assembly (.dll) |
| P/Invoke / DllImport | Direct method calls |
| Vtable structs (LayoutKind.Sequential) | Interface dispatch |
unsafe / fixed / pointer pinning | Safe managed code |
| Blittable-only view structs (primitives) | Any C# struct (strings, arrays, references) |
| Manual string marshaling (ptr + len) | Native C# strings |
| C ABI compatibility concerns | Standard .NET assembly |
| ~15-30ns per call (P/Invoke) | ~2-3ns per call (interface dispatch) |
| Thread-local error codes | Normal C# exceptions |
sers_runtime native binary per platform | No native binary needed |
What's New / Different
| Dimension | Impact |
|---|---|
| View structs can have managed types | Strings, arrays, List |
| Exceptions instead of error codes | Host catches exceptions naturally. No se_last_error_message() pattern. |
| No per-platform native binary | One .dll works on Windows/Mac/Linux/mobile. IL2CPP handles native compilation. |
| Debugger integration | Standard C# debugging works. Set breakpoints in .sers scripts (if IDE maps to desugared C#). |
| NuGet ecosystem | Scripts can reference NuGet packages. Math libraries, data structures, etc. |
| GC instead of manual memory | Scripts 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
| Platform | Development Mode | Shipping Mode |
|---|---|---|
| Unity Editor | Assembly.Load (Mono/CoreCLR) | N/A |
| Desktop (Mono) | Assembly.Load | Assembly.Load |
| Desktop (IL2CPP) | Not possible | Pre-compiled .dll in project |
| Desktop (CoreCLR, Unity 6.8+) | AssemblyLoadContext.Load/Unload | AssemblyLoadContext or pre-compiled |
| iOS | Not possible | Pre-compiled (IL2CPP static) |
| Consoles | Not possible | Pre-compiled (IL2CPP static) |
| WebGL | Not possible | Pre-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
| Operation | Rust (current) | C# Managed | C# NativeAOT |
|---|---|---|---|
| Template method call | ~5-15ns (calli) | ~2-3ns (interface) | ~15-30ns (P/Invoke) |
| String passing | ptr+len marshal | Native string | ptr+len marshal |
| View struct access | unsafe pointer deref | ref access | unsafe pointer deref |
| Stat resolution (CPU-bound) | Native LLVM speed | JIT/.NET speed (~1.3-2x slower) | NativeAOT LLVM (~same) |
| GC pauses | None (no GC) | Yes (Gen0: <1ms, Gen2: 20-55ms at scale) | Yes (per-library GC) |
| Memory overhead per script lib | Minimal | ~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).