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
.secsfiles from multiple mod layers - Parse all template definitions
- Merge:
overridereplaces members,injectadds 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:
- Parse
.secsfiles from all layers (game + mods in playset order) - Group template members by
{schema, template_name, member_name} - Apply override/inject rules (later layer wins)
- Produce a single merged AST
- 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, noSystem.Net, noSystem.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
unsafeblocks - 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.
9. Link Keyword
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.
| Feature | Type | Effort |
|---|---|---|
Template syntax (template<S> Name { }) | CORE | Medium — parser + desugaring |
| Override/inject merging | CORE | High — the main fork work |
Scope syntax (@this, @from, @root) | CORE | Low — lexer + desugar |
.secs file handling | CORE | Medium — driver changes |
| Basic sandbox (block dangerous namespaces) | CORE | Low — binding phase check |
| Game-std library (direct C# calls) | OPTIONAL | Medium — 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
| Feature | Type | Effort |
|---|---|---|
| Stats + modifiers + resolution | OPTIONAL (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 system | OPTIONAL (library) | Low |
| Playset resolver (C# port) | OPTIONAL (library) | Medium |
| Entity queries | OPTIONAL (library) | Medium |
Output: Full game systems runtime in pure C#. Templates call game-std directly. No FFI boundary.
Phase 3: Polish
| Feature | Type | Effort |
|---|---|---|
View keyword (view Location { }) | NICE TO HAVE | Low |
Schema keyword (schema building { }) | NICE TO HAVE | Medium |
Modifier keyword (modifier X = 10) | NICE TO HAVE | Low |
| Link keyword + navigation | NICE TO HAVE | Medium |
| Decisions system | OPTIONAL (library) | Medium |
| AI / utility evaluation | OPTIONAL (library) | Medium |
| Localization + expressions | OPTIONAL (library) | Medium |
| Game concepts / encyclopedia | OPTIONAL (library) | Low |
Phase 4: Tooling & Distribution
| Feature | Type | Effort |
|---|---|---|
| VS Code extension / language server | TOOLING | High |
| CLI (build, bundle, doctor) | TOOLING | Medium |
| Unity Editor integration | TOOLING | High |
| NativeAOT shipping path | OPTIONAL | Medium |
| Asset Store packaging | DISTRIBUTION | Medium |
Phase 5: Advanced (Optional / Future)
| Feature | Type | Effort |
|---|---|---|
| Graph evaluator | OPTIONAL (library) | High |
| Programs (flow-graph execution) | OPTIONAL (library) | High |
| Burst auto-compilation for hot paths | NICE TO HAVE | Medium |
| Rust fork (C ABI for cross-engine) | SEPARATE PRODUCT | Very High |
What The Fork Actually Touches In Roslyn
Concrete Roslyn areas that need modification:
| Roslyn Component | Change | Size |
|---|---|---|
Lexer (Lexer.cs) | Add tokens: @this, @from, @root, template, view, schema, override template, inject, modifier, link | Small |
Parser (Parser.cs) | Parse new syntax nodes for template/view/schema/modifier/link declarations | Medium |
| Syntax trees | New SyntaxKind entries for the custom nodes | Small |
| Lowering / desugaring | Transform custom nodes into standard C# (class, interface, struct, attribute) before binding | Medium |
| Merge pass (new) | New phase between parsing and binding that merges template members across layers | High |
Binder (Binder.cs) | Namespace/type restriction checks for sandbox enforcement | Small |
| Driver (new) | .secs file discovery, playset layer ordering, multi-file merge orchestration | Medium |
| Diagnostics | Custom 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
AssemblyLoadContextload/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.