C# Fork — Contracts DLL Architecture
Views and schemas are just regular C#. No compiler fork needed for them.
The Realization
In the Rust fork, views and schemas MUST be compiler features because Rust and C# are different languages. The view struct must exist in BOTH languages with matching memory layout.
In a C# fork, everything is C#. Host and scripts share the same type system. Views are just structs. Schemas are just interfaces. Put them in a contracts DLL that both sides reference. Done.
Architecture
Game.Contracts.dll ← regular C#, compiled by dotnet/Unity
├── structs (views)
├── interfaces (schemas)
└── helpers (game developer API for modders)
scripts.dll ← .secs files, compiled by forked Roslyn
├── templates implementing interfaces
└── references Game.Contracts.dll
Unity host project ← regular C#
├── references Game.Contracts.dll
├── references Sers.Runtime.dll
└── loads scripts.dll
Both host and scripts see the same types. No mapping. No marshaling. No blittability concerns (unless targeting NativeAOT).
What Game Developers Write (Regular C#)
Views — Just Structs
// Game.Contracts/Views/Location.cs — regular C#, regular IDE
public struct LocationView
{
public double Population;
public double Food;
public double Water;
public double Gold;
}
public struct CharacterView
{
public double Age;
public double Gold;
public double Prestige;
public string Name; // managed types are fine in managed path
public ulong DynastyId;
}
public struct EmpireView
{
public double Treasury;
public double TaxRate;
public double Stability;
}
No view keyword. No special attribute required. Just C# structs. The game developer creates them in Visual Studio or Rider with full IDE support, intellisense, refactoring — everything works out of the box.
Schemas — Just Interfaces
// Game.Contracts/Schemas/IBuilding.cs — regular C#
public interface IBuilding
{
bool CanBuild(ref LocationView self);
void OnBuilt(ref LocationView self);
double AiWeight(ref LocationView self);
}
// Three-scope schema — just add more ref parameters
public interface ITrade
{
bool CanTrade(ref LocationView self, ref LocationView from, ref EmpireView root);
void ExecuteTrade(ref LocationView self, ref LocationView from, ref EmpireView root);
}
public interface ICharacterEvent
{
bool Trigger(ref CharacterView self);
void Immediate(ref CharacterView self);
double MeanTimeToHappen(ref CharacterView self);
}
No schema keyword. The interface IS the schema. The ref parameters ARE the scope declarations. The compiler reads the interface to know:
- First ref parameter →
@this - Second ref parameter →
@from - Third ref parameter →
@root
Helpers — Game Developer's Exposed API
// Game.Contracts/Helpers/BuildingHelpers.cs — regular C#
public static class BuildingHelpers
{
public static bool HasResources(ref LocationView loc, double cost)
=> loc.Gold >= cost;
public static void ApplyFoodBonus(ref LocationView loc, double amount)
=> loc.Food += amount * (1.0 + loc.Water * 0.1);
}
public static class CombatHelpers
{
public static double CalculatePower(ref CharacterView c)
=> c.Prestige * 0.5 + c.Gold * 0.1;
}
These are normal C# static methods. Templates in .secs files can call them. Modders can use them but not change them. The game developer controls what API is exposed to modders by what they put in the contracts DLL.
What Modders Write (.secs files — forked Roslyn)
// buildings/farm.secs
template<IBuilding> Farm
{
const string DisplayName = "Farm";
const double BuildCost = 50.0;
const int MaxLevel = 3;
bool CanBuild()
{
return BuildingHelpers.HasResources(ref @this, BuildCost)
&& @this.Population >= 10;
}
void OnBuilt()
{
BuildingHelpers.ApplyFoodBonus(ref @this, 10.0);
}
double AiWeight()
{
return @this.Food < 50 ? 30.0 : 10.0;
}
}
What the fork desugars this to:
// Generated by forked Roslyn — standard C#
public class Farm : IBuilding
{
public const string DisplayName = "Farm";
public const double BuildCost = 50.0;
public const int MaxLevel = 3;
public bool CanBuild(ref LocationView __this)
{
return BuildingHelpers.HasResources(ref __this, BuildCost)
&& __this.Population >= 10;
}
public void OnBuilt(ref LocationView __this)
{
BuildingHelpers.ApplyFoodBonus(ref __this, 10.0);
}
public double AiWeight(ref LocationView __this)
{
return __this.Food < 50 ? 30.0 : 10.0;
}
}
The fork:
- Reads
template<IBuilding>→ looks up theIBuildinginterface from the contracts reference - Sees that
IBuilding.CanBuildtakesref LocationView self→ knows@thisisLocationView - Generates the class implementing the interface
- Adds the
ref LocationView __thisparameter to each method - Replaces
@thiswith__this
That's it. Standard C# class comes out.
Multi-Scope Example
// In contracts — regular C#
public interface ITrade
{
bool CanTrade(ref LocationView self, ref LocationView from, ref EmpireView root);
void ExecuteTrade(ref LocationView self, ref LocationView from, ref EmpireView root);
}
// In .secs — forked Roslyn
template<ITrade> FoodTrade
{
bool CanTrade()
{
return @from.Food >= 20.0 && @this.Gold >= 10.0;
}
void ExecuteTrade()
{
@from.Food -= 20.0;
@this.Food += 20.0;
@root.Treasury += @root.TaxRate * 20.0;
}
}
The compiler reads ITrade → 3 ref parameters → @this is LocationView, @from is LocationView, @root is EmpireView. All inferred from the interface.
Mod Override
// mods/balance/buildings/farm.secs
override template<IBuilding> Farm
{
override const double BuildCost = 75.0;
override double AiWeight()
{
// make AI value farms more when food is scarce
double w = 10.0;
if (@this.Food < 30) w += 40.0;
if (@this.Water > 0) w += 10.0;
return w;
}
}
The fork merges this with the base Farm — replaces BuildCost and AiWeight, keeps everything else.
What the Fork Actually Needs
With views and schemas as regular C# contracts, the fork shrinks to:
| Feature | What It Does | Touches |
|---|---|---|
template<ISchema> Name { } | Declare a template implementing a contract interface | Parser |
@this / @from / @root | Desugar to ref parameter access (type inferred from interface) | Lexer + desugaring |
| Override / inject merging | Merge template members across mod layers | New merge pass |
.secs file handling | Separate compilation context with sandbox | Driver |
| Sandbox enforcement | Block dangerous namespaces in .secs | Binder |
What's NOT in the fork anymore:
→ just a struct in contracts DLLviewkeyword→ just an interface in contracts DLLschemakeyword→ can be a method in the interface or a helperlinkkeyword→ can be amodifierkeywordconstwith an attribute, or just a const naming convention
The fork is now even smaller. Essentially: template declaration + scope sugar + AST merging + sandbox.
Why This Is Better
For game developers:
- Write views and schemas in the tool they already know (VS, Rider)
- Full IDE support (intellisense, refactoring, debugging) — no custom tooling needed
- Use any C# feature they want in contracts (generics, inheritance, extension methods)
- Views can have managed types (strings, lists) — no blittability constraint unless targeting NativeAOT
- Contracts DLL is a normal NuGet-publishable package
For modders:
- Only learn template syntax and
@this/@from/@root - Everything else is regular C#
- Intellisense on
@this.shows the struct members from the contracts DLL - Call game developer's helpers directly
- Override constants without writing any code
For the product:
- Smaller fork surface = easier to maintain against upstream Roslyn
- Views and schemas evolve independently of the fork
- Game developers can ship contracts DLL updates without updating the fork
- The fork version can lag behind Roslyn releases without blocking game development
Host Loading
// Unity host — regular C#
var context = new AssemblyLoadContext("scripts", isCollectible: true);
var asm = context.LoadFromAssemblyPath("scripts.dll");
// Discover all buildings
var buildings = asm.ExportedTypes
.Where(t => typeof(IBuilding).IsAssignableFrom(t))
.Select(t => (IBuilding)Activator.CreateInstance(t))
.ToList();
// Use them
foreach (var b in buildings)
{
if (b.CanBuild(ref location))
b.OnBuilt(ref location);
}
// Hot reload: unload and reload
context.Unload();
context = new AssemblyLoadContext("scripts", isCollectible: true);
asm = context.LoadFromAssemblyPath("scripts_v2.dll");
// re-discover templates...
Standard .NET. No custom loading mechanism. AssemblyLoadContext (CoreCLR) handles load/unload cleanly.
Discovery / Registration
For template constants and metadata, two options:
Option A: Reflection (simple, works immediately)
var type = typeof(Farm);
var displayName = (string)type.GetField("DisplayName").GetValue(null);
var buildCost = (double)type.GetField("BuildCost").GetValue(null);
Option B: Source generator (zero reflection, faster) A source generator in the host project reads the contracts interfaces and generates a typed registry:
// Auto-generated
public static class BuildingRegistry
{
public static IReadOnlyList<BuildingEntry> All { get; }
public record BuildingEntry(
string Name,
IBuilding Instance,
string DisplayName,
double BuildCost,
int MaxLevel);
}
Option A for prototyping, Option B for shipping.