Skip to main content

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:

  1. Reads template<IBuilding> → looks up the IBuilding interface from the contracts reference
  2. Sees that IBuilding.CanBuild takes ref LocationView self → knows @this is LocationView
  3. Generates the class implementing the interface
  4. Adds the ref LocationView __this parameter to each method
  5. Replaces @this with __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:

FeatureWhat It DoesTouches
template<ISchema> Name { }Declare a template implementing a contract interfaceParser
@this / @from / @rootDesugar to ref parameter access (type inferred from interface)Lexer + desugaring
Override / inject mergingMerge template members across mod layersNew merge pass
.secs file handlingSeparate compilation context with sandboxDriver
Sandbox enforcementBlock dangerous namespaces in .secsBinder

What's NOT in the fork anymore:

  • view keyword → just a struct in contracts DLL
  • schema keyword → just an interface in contracts DLL
  • link keyword → can be a method in the interface or a helper
  • modifier keyword → can be a const with 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.