Skip to main content

C# Fork — Feature Tiers: Core, Nice-to-Have, Optional

The principle: only fork the compiler for things that CANNOT be done with standard C#. Everything else is a library, attribute, source generator, or runtime feature.


The One Feature That Requires A Fork

AST-Level Mod Merging (override / inject)

This is the reason to fork. Nothing else in the C# ecosystem can do this.

What it does:

  • Load .secs files from multiple mod layers
  • Parse all template definitions
  • Merge: override replaces members, inject adds new members
  • Type-check the merged result
  • Emit the final assembly

Why it can't be done without a fork:

  • Source generators can only ADD files, not modify existing source
  • Source generators cannot replace a method body in an existing class
  • Source generators cannot merge two partial class definitions from different compilation units at the AST level
  • Roslyn analyzers can diagnose but not transform
  • Post-compilation IL rewriting (Mono.Cecil, Fody) can modify IL but loses source-level debugging, is fragile, and cannot do type-checked merging

Why it matters: This is the killer differentiator. Paradox has file-level override (coarse, breaks compatibility). Harmony has runtime monkey-patching (fragile, no validation). SERS has compile-time, member-level, type-checked merging. Nobody else has this.

What the fork does:

  1. Parse .secs files from all layers (game + mods in playset order)
  2. Group template members by {schema, template_name, member_name}
  3. Apply override/inject rules (later layer wins)
  4. Produce a single merged AST
  5. Hand it to standard Roslyn phases (binding, type checking, IL emission)

The rest of Roslyn (type checking, IL generation, error reporting) runs unmodified on the merged result.


CORE — Must Be In The Fork

These features are significantly better as compiler features. They could technically be worked around, but the workarounds are ugly enough to hurt adoption.

1. Scope Access Syntax (@this, @from, @root)

Without fork:

// Ugly — modders write this everywhere
this.__thisView.Food += 10.0;
this.__fromView.Gold -= 50.0;
this.__rootView.Treasury += this.__rootView.TaxRate * 50.0;

With fork:

@this.Food += 10.0;
@from.Gold -= 50.0;
@root.Treasury += @root.TaxRate * 50.0;

The desugaring is trivial (lexer recognizes @this → emits field access on the generated __thisView ref). But the ergonomic difference is massive for modders who write this on every line.

Implementation: Lexer adds @this, @from, @root tokens. Parser desugars to ref field access. ~50 lines of compiler change.

2. Template Declaration Syntax

Without fork:

[SersTemplate("building")]
public class Farm : IBuilding
{
[SersConst] public const string DisplayName = "Farm";
[SersConst] public const double BuildCost = 50.0;
[SersModifier] public const double FoodProduction = 10.0;

public bool CanBuild(ref LocationView self) { return self.Gold >= 50; }
public void OnBuilt(ref LocationView self) { self.Food += 10; }
}

With fork:

template<building> Farm {
const string DisplayName = "Farm";
const double BuildCost = 50.0;
modifier FoodProduction = 10.0;

bool CanBuild() { return @this.Gold >= 50; }
void OnBuilt() { @this.Food += 10; }
}

The fork version is cleaner: no attributes, no explicit ref parameter, no manual interface implementation. The compiler generates the interface implementation, adds the ref parameter, wires up the scope fields.

Implementation: Parser recognizes template<schema> Name { ... }. Desugars to class + interface implementation + scope fields. Same approach as the Rust fork.

3. Override / Inject Syntax

Without fork: Not possible. This IS the fork.

With fork:

override template<building> Farm {
override const double BuildCost = 75.0;
override void OnBuilt() { @this.Food += 25.0; }
inject double IrrigationBonus() { return @this.Water * 2.0; }
}

4. .secs File Boundary

The fork must understand that .secs files are a different compilation context:

  • Different allowed APIs (sandbox — no System.IO, no System.Net, no System.Reflection)
  • Different entry point (no Main, no top-level statements)
  • Multi-layer merging (reads from playset-ordered directories)
  • References the contracts assembly but not arbitrary NuGet packages

Implementation: The fork's driver (the part that invokes Roslyn) handles file discovery, layer ordering, and API restriction. The parser/binder phases run on the merged result.

5. Sandbox Enforcement

The forked compiler rejects .secs code that:

  • References System.IO, System.Net, System.Reflection, System.Diagnostics.Process
  • Uses unsafe blocks
  • Uses DllImport / P/Invoke
  • Creates threads
  • Accesses environment variables

This is a compile-time guarantee, not a runtime sandbox. If it compiles, it's safe. Modders get a clear error: "error SERS001: System.IO.File is not available in .secs files"

Could be done with Roslyn analyzers instead? Yes, partially. But analyzers are suppressible and advisory. A fork makes it a hard error that cannot be bypassed.


NICE TO HAVE — Better In The Fork, But Can Live Without

6. View Keyword

Without fork:

[SersView]
[StructLayout(LayoutKind.Sequential)]
public struct LocationView {
public double Population;
public double Food;
}

With fork:

view Location {
double Population;
double Food;
}

The fork adds public, StructLayout, and the attribute automatically. Saves boilerplate. But the attribute version works fine — game developers (not modders) write views, and they can handle the verbosity.

Verdict: Nice to have. Put it in the fork if it's cheap. Views are defined in .cs files (not moddable), so this is for game developer convenience, not modder experience.

7. Schema Keyword

Without fork:

[SersSchema("building", scopes: new[] { typeof(LocationView) })]
public interface IBuilding {
bool CanBuild(ref LocationView self);
void OnBuilt(ref LocationView self);
double AiWeight(ref LocationView self);
}

With fork:

schema building {
this: Location;

bool CanBuild();
void OnBuilt();
double AiWeight();
}

The fork version hides the ref parameter (it's implied by the scope declaration) and generates the interface. Cleaner, but schemas are also defined in .cs files by game developers, not modders.

Verdict: Nice to have. Makes schema definitions cleaner and ensures scope declarations are formal rather than attribute-based.

8. Modifier Keyword

Without fork:

[SersModifier("FoodProduction")]
public const double Modifier_FoodProduction = 10.0;

With fork:

modifier FoodProduction = 10.0;

Small ergonomic win. Modders override modifiers frequently, so cleaner syntax matters.

Verdict: Nice to have. Improves the Tier 1 (data-only) modding experience.

Without fork:

[SersLink("owner", typeof(CharacterView))]
public CharacterView Owner => __link_owner;

With fork:

link owner: Character;
// then: @this.owner.Gold -= 10;

The fork desugars link navigation to ref access through the scope chain, same as the Rust fork.

Verdict: Nice to have. Entity relationship navigation is common in strategy games.


OPTIONAL — Library / Runtime / Source Generator

These do NOT need the compiler fork. They're runtime features, host-side libraries, or source generator outputs.

10. Game Systems Runtime (stats, modifiers, events, decisions, AI, etc.)

Pure C# library. No compiler involvement.

// Sers.Runtime — pure C# library
public class StatsRuntime { ... }
public class EventsRuntime { ... }
public class DecisionsRuntime { ... }
public class AiRuntime { ... }
public class VariableStore { ... }
public class SaveLoadManager { ... }

Game developers reference this library. Templates call its APIs via game-std.

In the Rust product this is a native cdylib. In the C# product it's just a C# assembly. No FFI boundary.

11. Game-Std (Script API)

Pure C# library that templates call. In the Rust product, game-std uses AtomicPtr callbacks across FFI. In the C# product, it's just direct method calls.

// Sers.GameStd — script-side API
public static class Stats {
public static double Resolve(TargetId target, StatId stat)
=> World.Current.Stats.Resolve(target, stat);
}

public static class Events {
public static void Fire(string key, string scope, ulong entityId)
=> World.Current.Events.Fire(key, scope, entityId);
}

public static class Variables {
public static void Set(ulong entityId, string name, double value)
=> World.Current.Variables.Set(entityId, name, value);
}

No AtomicPtr. No callback lookup. No dynamic dispatch. Direct calls in the same managed runtime.

12. Template Discovery / Registry

Source generator or runtime reflection. When the host loads a script assembly, it scans for types with [SersTemplate] attributes and builds a registry.

// Source generator produces:
public static class SersRegistry {
public static IReadOnlyDictionary<string, Type[]> Templates => _templates;
// auto-discovered from compiled assembly
}

13. Playset Resolution (mod loading, ordering, conflict detection)

Pure C# library. Already exists as sers-resolver in Rust. Rewrite in C# or keep as-is (it produces a resolved layer list, the forked compiler consumes it).

14. CLI Toolchain (build, bundle, doctor, playset commands)

Standalone tool. Could be C# (dotnet tool) or keep the existing Rust CLI. Orchestrates the forked compiler, resolution, bundling.

15. Localization

Pure C# library. TOML loading, key-value lookup, expression resolution, layer merging. No compiler involvement.

16. Save/Load

Pure C# library. Serialize runtime state (variables, modifiers, events, decisions, AI) to a binary blob and back.

17. Analyzer / IDE Support

A Roslyn analyzer (or forked OmniSharp / language server) that understands .secs files:

  • Syntax highlighting for template, view, schema, @this, etc.
  • Autocomplete on scope fields (@this. shows view members)
  • Error squiggles for sandbox violations
  • Go-to-definition across layers

This is separate from the compiler fork but critical for developer experience. Could be a VS Code extension, Rider plugin, or both.


Implementation Priority

Phase 1: The Minimum Viable Fork

Get something compiling and running. Smallest possible fork surface.

FeatureTypeEffort
Template syntax (template<S> Name { })COREMedium — parser + desugaring
Override/inject mergingCOREHigh — the main fork work
Scope syntax (@this, @from, @root)CORELow — lexer + desugar
.secs file handlingCOREMedium — driver changes
Basic sandbox (block dangerous namespaces)CORELow — binding phase check
Game-std library (direct C# calls)OPTIONALMedium — rewrite from Rust
One working example (hello-world equivalent)Low

Output: Forked Roslyn compiles .secs files with templates, merges overrides, produces a .dll that Unity loads via Assembly.Load.

Phase 2: Game Systems

FeatureTypeEffort
Stats + modifiers + resolutionOPTIONAL (library)High
Variables (typed, entity-scoped, timed)OPTIONAL (library)Medium
Events (two-phase, MTTH, cooldowns)OPTIONAL (library)High
Save/load (unified)OPTIONAL (library)Medium
Update/tick systemOPTIONAL (library)Low
Playset resolver (C# port)OPTIONAL (library)Medium
Entity queriesOPTIONAL (library)Medium

Output: Full game systems runtime in pure C#. Templates call game-std directly. No FFI boundary.

Phase 3: Polish

FeatureTypeEffort
View keyword (view Location { })NICE TO HAVELow
Schema keyword (schema building { })NICE TO HAVEMedium
Modifier keyword (modifier X = 10)NICE TO HAVELow
Link keyword + navigationNICE TO HAVEMedium
Decisions systemOPTIONAL (library)Medium
AI / utility evaluationOPTIONAL (library)Medium
Localization + expressionsOPTIONAL (library)Medium
Game concepts / encyclopediaOPTIONAL (library)Low

Phase 4: Tooling & Distribution

FeatureTypeEffort
VS Code extension / language serverTOOLINGHigh
CLI (build, bundle, doctor)TOOLINGMedium
Unity Editor integrationTOOLINGHigh
NativeAOT shipping pathOPTIONALMedium
Asset Store packagingDISTRIBUTIONMedium

Phase 5: Advanced (Optional / Future)

FeatureTypeEffort
Graph evaluatorOPTIONAL (library)High
Programs (flow-graph execution)OPTIONAL (library)High
Burst auto-compilation for hot pathsNICE TO HAVEMedium
Rust fork (C ABI for cross-engine)SEPARATE PRODUCTVery High

What The Fork Actually Touches In Roslyn

Concrete Roslyn areas that need modification:

Roslyn ComponentChangeSize
Lexer (Lexer.cs)Add tokens: @this, @from, @root, template, view, schema, override template, inject, modifier, linkSmall
Parser (Parser.cs)Parse new syntax nodes for template/view/schema/modifier/link declarationsMedium
Syntax treesNew SyntaxKind entries for the custom nodesSmall
Lowering / desugaringTransform custom nodes into standard C# (class, interface, struct, attribute) before bindingMedium
Merge pass (new)New phase between parsing and binding that merges template members across layersHigh
Binder (Binder.cs)Namespace/type restriction checks for sandbox enforcementSmall
Driver (new).secs file discovery, playset layer ordering, multi-file merge orchestrationMedium
DiagnosticsCustom error codes for SERS-specific errors (sandbox violations, merge conflicts, scope mismatches)Small

Total: ~5-8 files modified in Roslyn, ~2-3 new files added. The vast majority of Roslyn (type system, IL emission, optimization, error recovery) runs completely unmodified.

Metalama (the commercial Roslyn fork) proves this is maintainable. They track upstream within 3 weeks of each Microsoft release with a "shallow fork" approach — minimal modifications, maximum reuse of standard Roslyn.


CoreCLR Timeline Alignment

Unity's CoreCLR migration:

  • Unity 6.7 (mid-2026): Experimental CoreCLR desktop player
  • Unity 6.8 (late 2026): CoreCLR becomes default. Mono removed.
  • CoreCLR brings: AssemblyLoadContext (proper load/unload), modern .NET APIs, better JIT

This means:

  • Phase 1-2 (build during 2026): Target Mono (Assembly.Load works). Ship when CoreCLR lands.
  • Phase 3-4 (2027): CoreCLR is default. Full AssemblyLoadContext load/unload for hot reload and mod management.
  • NativeAOT shipping path: Only needed for IL2CPP platforms (mobile/console). Desktop uses CoreCLR managed path.

The C# fork product launches into a Unity ecosystem that has just moved to CoreCLR — perfect timing for a managed scripting/modding solution that doesn't need native plugins for desktop.