SECS Roslyn Fork — Implementation Plan
Repository Setup
Fork Structure
secs-workspace/
├── src/ existing — engine runtime
│ ├── SECS.Core/ typed IDs, hashing, shared primitives
│ ├── SECS.Schema/ WorldSchema and schema row types
│ ├── SECS.Kernel/ deterministic semantic kernel
│ ├── SECS.Runtime/ runtime coordinator
│ └── SECS.Compiler.Contracts/ compiler-emitted contract types
├── secs-roslyn/ forked Roslyn submodule
│ └── (forked from dotnet/roslyn, latest)
├── examples/
│ ├── valenar-v2/ reference colony-builder example
│ └── character-trainer-v2/ smaller training-loop example
└── docs/
The VS Code extension and LSP server live inside the secs-roslyn fork itself — Roslyn already contains the Workspaces and LSP infrastructure, so the extension is built from the same codebase rather than being a separate repo.
Git Setup
Fork: Create a private fork of dotnet/roslyn on GitHub. Fork from latest. Create a secs branch — this is the working branch where all SECS modifications go.
Submodule: secs-roslyn/ is the Roslyn submodule in this workspace. Configure it to track the secs branch. Full clone — no shallow, we want the complete history for tracing how upstream implemented features like record.
Branch strategy:
secs— working branch with all SECS modificationsupstream/main— tracks dotnet/roslyn for periodic merges- Upstream merges happen deliberately (quarterly or when needed), not continuously
Target Framework
- Roslyn fork toolchain: follows
secs-roslyn/global.json(currently .NET 10.0.108 after the 2026-05-31 upstream sync) - secsc distribution target: RID-scoped tool bundle. The bundle ships
secsc, a matched Roslyn compiler driver, target reference assemblies, SECS reference assemblies, andsecsc.bundle.json; repository-localsecsc.dllinvocations must pass--toolchain-rootexplicitly. - Engine libraries: v2 core/runtime/schema/kernel/compiler-contract assemblies target netstandard2.1 where they must remain Unity-compatible; adapters and examples target net8.0.
- Compiler output: configurable target — the game developer chooses their target framework. The compiler emits code against the v2 compiler/runtime contracts, so output is compatible with any .NET runtime that supports the selected target.
What We're Adding to Roslyn
Current Lowering Baseline
The compiler target is the current v2 design/runtime plus the compiler-emitted Generated/ output under examples/valenar-v2/Generated/obj/**/SecsGenerated (emitted by secsc from Content/; the only retained checked-in .cs under Generated/ is the 21-line ValenarHostIds.cs host failure-reason surface). Valenar is the reference example, not the generic language vocabulary. The generic compiler must bind whatever scopes, contracts, methods, events, activities, policies, and fields the game declares.
Post-Wave-7 behavior concepts have been carried into the v2 design/runtime contract. The current compiler-oracle shape is the v2 WorldSchema emitted under examples/valenar-v2/Generated/obj/**/SecsGenerated.
The most important correction to this plan is that template is not a standalone first feature. Even an empty template<C> T { } needs the structural world already bound: scope declarations, contract declarations, owner-and-signature method ids, FNV-1a-64 hashes, and module registration. Roslyn work must therefore start with a structural declaration prepass before or alongside template-shell lowering.
Second correction: the callable/type foundation is no longer SecsValueKind plus scalar-only template fields. The compiler target is C# source type binding plus generated SecsTypeRef metadata as described in docs/design/07-structured-template-data-and-callables.md: structs, records, enums, arrays, chosen C# collection-shaped values, structured template fields, typed query returns, typed template commands, and generated scope command/query facades. Runtime metadata and template query/command dispatch now use typed registrations; SecsValue remains only for command payload storage and explicitly erased dynamic/tooling adapters, not normal template or scope callable ABI.
"Generated C#" in this plan means the ordinary C# syntax tree that SECS lowers to, not a requirement that the compiler writes .cs files during a normal build. The primary pipeline is .secs syntax trees -> merged SECS syntax tree -> lowered C# SyntaxTrees -> Roslyn emit -> DLL. A deterministic --emit-generated-cs diagnostic mode should write those lowered trees under an obj/ artifact directory for review and comparison with today's compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated output, but physical files are not the architectural boundary.
Implementation status (2026-07-04): The compiler path now covers the structural shell, Phase 1.5 packaging/cache/runtime invocation, the data-declaration batch, the executable behavior batch, the first event/activity/policy orchestration slice, and the Phase-3a source-set merge. .secs discovery keeps SECS files out of the normal C# parser, lowers backed declarations to generated syntax trees, supports secsc check and secsc build over ordered --base / --expansion / --mod content packages (with --order, --strict-mod-ops, --out, and --emit-generated-cs), and is pinned by tests/SECS.Compiler.Smoke; the live Valenar build compiles the full examples/valenar-v2/Content tree (113 .secs files) through this path. The current backed language surface includes scopes, root-scope contracts, templates, scalar template values, top-level field declarations, scalar scope fields, walks_to, typed scoped collections with generated lifecycle hooks, simple channels, formula-backed channels, channel modifiers, generated formula delegates, lifecycle contract methods, template command bodies, systems, on_actions, pulse/on_action events, event options, activities, the backed FromActor policy subset, and the six mod operations through the compile-time source-set merge (SecsDeclarationMerger runs between parsing and binding: inject and its try / _or_create variants patch scalar template-field value slots, replace and its variants substitute whole declarations of any backed kind, and --strict-mod-ops promotes unmatched try operations to the SECS0620 warning). conflict-report.json emission, broad expression lowering, structured fields, string field values, template-intrinsic channel sources, non-FromActor policy selectors, unsupported policy decisions, and Endgate full Generated/ replacement remain future work.
Source keyword whitelist and grammar status
The compiler must keep source syntax, diagnostics, generated stand-ins, and runtime ABI in lockstep. This table is the whitelist for committed source grammar categories; anything outside it is either ordinary C# collected for metadata, a member/body form valid only in a containing SECS declaration, or a runtime/generated concept that is not a source keyword.
| Category | Committed source forms | Status |
|---|---|---|
| SECS declarations | scope, contract, template, channel, field, modifier, system, event, on_action, activity, policy | Top-level SECS declaration forms. field is valid as top-level template-field metadata and as a template member assignment form. |
| Member forms inside declarations | root_scope, activation, deactivation, context, query, method, phase =, frequency =, tags =, track_prev =, reads, propagates_to, provides, mode, need, selector, rule | Valid only in the declaration/member contexts that own them; they are not free-standing top-level keywords. |
| Mod operations | inject, replace, try inject, try replace, inject_or_create, replace_or_create | Prefix a normal SECS declaration and lower through the slot-merge pipeline. The old single override keyword is not committed source syntax. |
| Ordinary C# collected for metadata | struct, record, enum, static readonly TagId, PhaseDeclaration.Create(...), TickRate.*(...) | Parsed as C#; the SECS binder harvests type/tag/phase/cadence metadata from these shapes. Top-level tag, phase, and tick_rate declarations are not SECS keywords. |
| Body expression/statement forms | resolve, template_field, increment, add_modifier, fire on_action, save_scope_as, foreach, any, every, count, random, first, has_tag, has_template, has_contract, registry[...] | SECS-aware forms inside method/formula bodies. Arbitrary direct event firing is not committed; only fire on_action ... is in the current source contract. |
| Formula receiver keywords | self.Field (own-target host-field read, ADR-0007), owner.resolve(Channel) / owner.Field (binding-owner reads, ADR-0008) | Valid only inside formula expressions. self reads context.Target directly (no walk); owner reads context.Owner. Both lower to existing IFormulaReadContext reads; owner reads record the OwnerChannel / OwnerHostField read-set kinds. |
| Runtime/generated concepts, not source keywords | formula delegates, triggers, CandidateBuilderId, SelectorSource.FromCollection, SecsTypeRef, ActivityRequestOrigin, ContractDeclaration.IsRegistryOnly, bridge prev-tick reads | These are generated/runtime ABI concepts. A formula top-level keyword is not committed; formulas are lifted from dynamic channel sources and dynamic modifier effects. |
New Contextual Keywords and Source Forms
| Surface | Role | Notes |
|---|---|---|
struct, record, enum | Declares C# value types that participate in SECS metadata | Ordinary C# declarations collected by the SECS binder. Lowers to generated C# value types plus SecsTypeDeclaration[]; referenced by generated SecsTypeRef metadata. |
scope | Declares host-owned entity shapes, fields, collections, walks, and host-exposed methods | Lowers to ScopeDeclaration[], ScopeFieldDeclaration[], and ScopeMethodDeclaration[]. |
contract | Declares a template API and root scope | Lowers to ContractDeclaration[], ContractMethodDeclaration[], and optional ContextProfileDeclaration[]. |
template | Declares a template bound to a contract | Lowers to a static class plus TemplateEntry. |
field | Declares template-definition data | Top-level field metadata lowers to TemplateFieldDeclaration[] with SecsTypeRef; template assignments lower to typed scalar or structured accessors. |
channel | Declares channel metadata or an own-root template intrinsic source | Top-level channels lower to ChannelDeclaration[]; template-body channels lower to activation-time channel-source registration. |
modifier | Declares reusable channel/template-field effects | Lowers to ModifierDeclaration[] plus formula/trigger helpers when dynamic. |
system | Declares a scheduled tick procedure | Lowers to a class implementing ITickSystem and a SystemRegistration. |
event | Declares a SECS event | At declaration level, lowers to a SecsEvent subclass plus EventEntry. C# event remains legal inside ordinary C# class bodies. |
on_action | Declares an extension point | Metadata-only in committed source syntax (OnActionId, ScopeId, ProvidedScopes, SelectionMode); effects live in subscriber events (early priority guaranteed base behavior) or at the fire on_action site. No effect body, chain, or fallback syntax in committed source. |
activity | Declares a class-style actor-driven activity (Wave 1 replacement for the removed behavior keyword) | Lowers to a SecsActivity subclass with typed IActivityArgs<TSelf> args (Wave 3) and a declarative Preview returning EffectPlan (Wave 2). Old allow / effect block syntax is prototype-only and is not the compiler target. |
policy | Declares a utility-AI policy with needs, selectors, and rules (Wave 4) | Lowers to a SecsPolicy subclass plus per-rule body delegates and NeedTargetExpr discriminated-union targets (Wave 5). |
inject | Partial slot patch — replaces listed semantic slots, retains the rest | SECS mod operation; not C# member override. |
replace | Full declaration replacement — discards the old declaration body | SECS mod operation. |
try inject | Patch if target exists; no-op if missing | Compatibility patches across DLC/loadout combinations. |
try replace | Full replacement if target exists; no-op if missing | Same use-case as try inject but broader replacement. |
inject_or_create | Inject if target exists; otherwise create as a new declaration | Useful for mod-additive modifiers and templates. |
replace_or_create | Replace if target exists; otherwise create | Total-conversion and broad compatibility layers. |
query / method | Declares contract callable signatures | query is read-only and may return any bound source type that lowers to SecsTypeRef; method void is command-producing and must return void. |
@<declared-scope-name>, @root, @this, etc. | Declared scope sigils plus built-in frame references | @Location / @Settlement are Valenar examples; the compiler binds whatever scope names the game declares. These are expression forms, not a single @scope keyword. |
resolve, template_field, increment, add_modifier, fire on_action, save_scope_as, foreach | Lowered expression forms inside bodies | Parsed as SECS-aware expressions/statements over normal C#-style bodies. Arbitrary direct event firing remains future work. |
New Syntax Nodes
Each SECS construct needs a Roslyn syntax representation that can survive merge, binding, diagnostics, and lowering:
ScopeDeclarationSyntax, with field, collection,walks_to, and scope-method members.ContractDeclarationSyntax, withroot_scope, lifecycle bindings,query/methodsignatures, andcontextprofiles.SecsTypeDeclarationSyntaxfor C#struct,record, andenumdeclarations, plus binding of C# array and collection type syntax to recursiveSecsTypeRefmetadata.TemplateDeclarationSyntax, including template fields, own-root intrinsic channels, contract query/method bodies, modifiers, and transition edges.TemplateFieldDeclarationSyntax/TemplateFieldAssignmentSyntax.SecsChannelDeclarationSyntaxfor top-level channel metadata and template-body channel sources.ModifierDeclarationSyntaxand modifier effect syntax.SystemDeclarationSyntax.SecsEventDeclarationSyntax.OnActionDeclarationSyntax.SecsActivityDeclarationSyntax(Wave 1 — renamed from the priorSecsActionDeclarationSyntax).SecsPolicyDeclarationSyntax(Wave 4 — needs/selectors/rules with rule-body delegates andNeedTargetExprtargets).ScopeSigilExpressionSyntax, saved-scope expressions, contract-call expressions, and SECS-aware invocation/iteration forms.ModOperationDeclarationSyntax— wraps anyinject,replace,try inject,try replace,inject_or_create, orreplace_or_createoperation around a standard SECS declaration syntax node. Carries theModOperationKindenum and the inner declaration's identity/body. Replaces the oldOverrideDeclarationSyntax.
Lowering Targets (Matching Current Engine API)
Each SECS node lowers to ordinary C# syntax trees matching the current Generated/ contract. Rows that use Valenar names are examples of the generic lowering shape, not compiler-reserved vocabulary.
| SECS Construct | Lowers To (matching current engine) |
|---|---|
struct / record / enum declarations | Generated ordinary C# types plus SecsTypeDeclaration[]. Every reference to these source types in fields/callables lowers to SecsTypeRef metadata. |
scope <declared-scope> { ... } | ScopeDeclaration with ParentScopeIds, ScopeFieldDeclaration[], ScopeMethodDeclaration[], and H.* hashes. |
int SomeQuery(ulong templateId); / LocationFacts Facts(); on a scope | ScopeMethodDeclaration keyed by scope:<scope-name>.<Method>(<params>):<return>; read-only calls lower to generated typed facades such as LocationScopeQueries.BuildingCount(host, location, templateId) over ISecsHostReads.CallScopeQuery<TArgs,TResult>(...). Erased adapters are only dynamic/tooling boundaries. |
void SomeCommand(<declared-scope> target, int amount); on a scope | ScopeMethodDeclaration with ReturnType = SecsTypeRef.Void and ParameterTypes = [SecsTypeRef.EntityInScope(...), SecsTypeRef.Int]; call sites lower through generated typed command facades such as CharacterScopeCommands.AdvanceLead(tick, character, target, amount) over the active command context / ISecsHostCommands, then flush after the source statement. Scope-typed parameters use declared scope names. |
contract C { root_scope S; query bool CanX(); method void OnY(); } | ContractDeclaration with query/method id arrays and lifecycle bindings, plus one ContractMethodDeclaration row per callable signature. |
context SomeContext { @SomeScope; @root; } | ContextProfileDeclaration owned by the contract; expands to an ordered context chain for template_field(...). SomeScope stands for a source-declared scope name; it is not engine vocabulary. |
template<C> T { ... } | Static class T with TemplateEntry { TemplateId, Name, ContractId, RootScopeId, Activate, StructuredFields, Queries, Methods, GetField* }, registered from SecsModule.Initialize. |
field int SomeField = 10 inside a template | Typed scalar GetFieldInt switch on the template entry, with top-level field metadata using SecsTypeRef.Int. |
field FeaturePlacementProfile Placement = new(...) inside a template | Structured immutable template data accessor returning concrete FeaturePlacementProfile, plus top-level field metadata using SecsTypeRef.Record(H.FeaturePlacementProfile). |
channel int Food = 5 inside a template | cmds.RegisterChannelSource(scope.Owner, scope.Root, H.ChannelFood, 5) inside Activate; legal only when the channel belongs to the contract root scope. |
channel int Food { return ...; } inside a template | cmds.RegisterDynamicChannelSource(scope.Owner, scope.Root, H.ChannelFood, H.Formula_TFood) plus a formula delegate and registry.RegisterFormula(...); cross-scope effects use named modifiers instead. |
bool SomeQuery() { ... } on a template whose contract declares it | Typed contract query implementation returning concrete bool, registered by contract owner+signature id with metadata ReturnType = SecsTypeRef.Bool. CanBuild is only Valenar vocabulary; there is no special template activation/can-activate hook. |
FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input) { ... } | Typed read-only query returning a declared record. Used by feature generation as local scoring only; global budgets/spacing/conflicts are applied by systems after query results are collected. |
void SomeMethod() { ... } on a template whose contract declares it | Generic contract method implementation with ITemplateCommandContext and typed argument structs/adapters, registered in TemplateEntry.Methods as TemplateCommand<TArgs> by contract owner+signature id. Generated bodies use context.Scope, context.Host, context.Commands, and context.FlushCommands() after command-producing source statements. Lifecycle bindings call this same generic method surface; there is no raw-entity world-parameter method ABI and no erased argument ABI for normal generated calls. |
@SomeScope.SomeField | Declared scope walk from the current ScopeFrame, then typed host read such as host.ReadInt(...) selected by the declared scope-field type. Valenar example: @Location.Fertility. |
@SomeScope.resolve(SomeChannel) | Declared scope walk, then typed channel resolution (host.ResolveChannel* in formulas/triggers or ctx.ChannelResolver.Resolve*Fast in tick bodies). Valenar example: @Settlement.resolve(Morale). |
self.SomeField (formula) | DIRECT host-field read on the formula's own target with NO scope walk: context.Reads.ReadHostField{Int32|Int64|Double}(context.Target, H.ScopeField...). Distinct from @Scope.Field (which always emits a walk prelude + ScopeWalk read row). FK-anchored against the formula's own root scope. Records a FormulaReadKind.HostField row. See ADR-0007. |
owner.resolve(SomeChannel) (formula) | Channel read on the binding owner: context.Reads.ReadChannelDouble(context.Owner, H.Channel...). Records a FormulaReadKind.OwnerChannel read row. See ADR-0008. |
owner.SomeField (formula) | Host-field read on the binding owner: context.Reads.ReadHostField{Int32|Int64|Double}(context.Owner, H.ScopeField...). Records a FormulaReadKind.OwnerHostField read row. See ADR-0008. |
template_field(SomeTemplate, SomeField, SomeContext) | Base/effective template-field read with the contract-owned context profile expanded to an explicit ordered entity chain. Return type is the field's SecsTypeRef; additive/multiplicative effective reads are numeric scalar only, while structured fields are whole-value exact-type only. |
modifier X { SomeChannel += 5; field SomeField *= 80%; } | ModifierDeclaration with ModifierEffectTargetKind.Channel or TemplateField, typed effect values, stacking/reapply/decay/tag metadata. |
target.add_modifier X while cond duration N | CommandBuffer.AddModifier(owner, target, H.X, H.Trigger_Cond, N, captured0, captured1); stored triggers use TriggerDelegate(target, captured0, captured1, host). |
system SomeSystem { ... } | Class implementing ITickSystem with Frequency, Phase, Execute(TickContext), a static SystemRegistration, and statement-level ctx.FlushCommands() after command-producing source statements. |
event SomeEvent { ... } | Sealed class deriving from SecsEvent, with Condition(EventQueryContext) (read-only), Execute(EventContext) (command-producing), optional BuildOptions(EventOptions options, EventQueryContext context) (read-only), option handlers Action<EventContext> (command-producing), and static EventEntry Registration. The read-only vs command-producing split is structurally enforced post-Wave C; see docs/adr/ad-0004-structural-read-only-execution-context-types.md and docs/design/10-host-secs-execution-boundary.md § 13.4. |
on_action SomeOnAction { ... } | OnActionDeclaration metadata (OnActionId, ScopeId, ProvidedScopes, SelectionMode) only; no source-authored effect delegate. |
activity SomeActivity { ... } | Sealed class deriving from SecsActivity (Wave 1), using ActivityQueryContext for read-only methods (CanStart, IsTargetValid, Preview) and ActivityRunContext for command-producing lifecycle methods (OnStart, OnUpdate, OnComplete, OnStop, OnCancel, OnFail). The pre-Wave-C ActivityContext type is deleted; no [Obsolete] alias exists. Typed args declared as a record struct implementing IActivityArgs<TSelf> (Wave 3) and serialized through ActivityArgsBlob. Preview returns EffectPlan with PredictedEffect rows (Wave 2). |
policy SomePolicy { ... } | Sealed class deriving from SecsPolicy (Wave 4), with Needs : IReadOnlyList<NeedDeclaration>, Selectors : IReadOnlyList<SelectorDeclaration>, Rules : IReadOnlyList<RuleDeclaration>, and EvaluateRule(RuleId, PolicyQueryContext) -> RuleDecision dispatching by rule id. The pre-Wave-C PolicyRunContext type is deleted; no [Obsolete] alias exists. Need targets use `NeedTargetExpr.Literal |
foreach entity in Contract | ctx.InstanceStore.AllByContract(H.ContractId) or AllByContractForTick(...) for distributed system frequencies. |
fire on_action X target target | Saved-scope validation + ctx.Events.FireOnAction(H.X, target, ctx). |
inject template Farm { field GoldCost = 8; } | Slot-level partial patch: the template_field:GoldCost slot is replaced; all other slots are retained. Emitted C# reflects the merged winner only. |
replace template Farm { ... } | Full declaration replacement: the old body is discarded and the new body becomes the declaration. |
try inject / try replace | Silent no-op when the target identity is absent; otherwise behaves as inject / replace. |
inject_or_create / replace_or_create | Inject/replace if target exists; create new declaration if absent. Body must satisfy full declaration requirements on the create path. |
Formula and trigger delegates remain lower-level callbacks with raw EntityHandle parameters because channel resolution and binding updates run outside a normal contract scope frame. Template activation and queries use ScopeFrame directly; template command methods use ITemplateCommandContext, which carries the same scope frame plus the command surface. No generated template or contract callable may use ad hoc world-parameter signatures.
Generated Command-Flush Contract
Generated template contract methods, system bodies, event bodies, and activity bodies flush after every command-producing source statement:
increment,add_modifier, and void scope-method calls lower to the command surface and thenctx.FlushCommands()/context.FlushCommands().save_scope_aswrites the saved-scope frame directly;fire on_actiondispatches throughEventDispatcherafter the required scopes are present.- A later statement, later subscriber, later activity lifecycle callback, or later loop iteration sees writes from earlier command-producing statements in the same tick.
- Runtime dispatchers still flush or discard pending commands at body boundaries as a safety net, but the compiler must not rely on end-of-body batching for ordinary behavior bodies.
- Template contract
method voidbodies useITemplateCommandContext, so they follow the same statement-level flush contract as systems, events, and activities (policy rule bodies are read-only — seedocs/design/10-host-secs-execution-boundary.md § 13). TemplateActivateremains a generated intrinsic-channel-source registration method that receivesCommandBuffer; the runtime validates activation channel-source targets and flushes activation at the boundary.
Current Engine Types the Compiler Targets
The current compiler target is the v2 contract surface under src/SECS.Core,
src/SECS.Schema, src/SECS.Compiler.Contracts, src/SECS.Kernel, and
src/SECS.Runtime, with executable reference shapes in
examples/valenar-v2/Generated/. Keep this section aligned with
docs/specs/sp-world-schema-contract.md,
docs/runtime/rn-kernel-contract.md,
docs/runtime/rn-kernel-runtime-layers.md, and
docs/design/10-host-secs-execution-boundary.md rather than reconstructing
type tables in this roadmap.
Runtime context contract. See
docs/adr/ad-0004-structural-read-only-execution-context-types.mdanddocs/design/10-host-secs-execution-boundary.md§ 13.4. The compiler must preserve the structural split between read-only query bodies and command-producing bodies. Generated bodies must access read-only state through read-only context/facade types and must emit command-producing bodies through the runtime command surface with statement-level flushes.SECS0803(CommandInReadOnlyContext) is the shipped compiler guard that rejects command-producing operations inside read-only source expressions.
Diagnostic Code Catalog
A consolidated table of every SECS diagnostic code. Shipped codes are the DiagnosticDescriptors declared in secs-roslyn/src/Compilers/CSharp/Portable/SECS/Syntax/SecsDiagnosticDescriptors.cs and raised by the fork's parser, binder, source-set merger, or command line; the remaining rows are reservations for later compiler/analyzer phases. The v2 runtime ships no diagnostic-code enums. A reservation must never reuse a shipped number; re-allocate the reservation in this catalog before its phase lands.
| Code | Owner | Severity | Diagnoses | Status |
|---|---|---|---|---|
| SECS0001 | Parser | Error | Invalid SECS syntax (structural parse error; message carries the parser text) | Shipped (SyntaxError) |
| SECS0030 | Parser | Error | Removed Wave-1 keyword (legacy activity declaration); use activity (see Phase 4 Reserved diagnostic codes below) | Reserved (Wave 1) |
| SECS0031 | Parser | Error | Removed Wave-1 keyword (legacy policy declaration); use policy (see Phase 4 Reserved diagnostic codes below) | Reserved (Wave 1) |
| SECS0032 | Parser | Error | Uncommitted SECS-looking source form or keyword (formula top-level declaration, old override, top-level tag, top-level phase / tick_rate, arbitrary direct fire EventName); ordinary C# metadata collection remains allowed | Shipped (UnsupportedSourceForm) |
| SECS0033 | Parser | Error | Mod-operation form outside the Phase-3a surface (inject of a non-template kind, inject of a non-scalar template member, a non-template _or_create, or a qualified / string-literal target) | Shipped (UnsupportedModOperationForm) |
| SECS0034 | Command line | Error | Invalid SECS content package manifest (secs.package.json) | Shipped (InvalidContentPackageManifest) |
| SECS0101 | Binder | Error | Duplicate scope field on a scope | Shipped (DuplicateScopeField) |
| SECS0102 | Binder | Error | Unsupported scope field type (current slice: int, long, float, double, bool) | Shipped (UnsupportedScopeFieldType) |
| SECS0103 | Binder | Error | Scope declares a duplicate walks_to target | Shipped (DuplicateScopeWalk) |
| SECS0104 | Binder | Error | Unknown walks_to target scope | Shipped (UnknownWalkTarget) |
| SECS0105 | Binder | Error | Invalid scoped collection type; use ScopedList<TElement> or ScopedDictionary<TKey, TElement> | Shipped (InvalidScopedCollectionType) |
| SECS0201 | Binder | Error | Reference to an unknown scope declaration (contract.root_scope and other scope references) | Shipped (UnknownScope) |
| SECS0202 | Binder | Error | template<Contract> references an unknown contract declaration | Shipped (UnknownContract) |
| SECS0203 | Binder | Error | Duplicate template field on a template | Shipped (DuplicateTemplateField) |
| SECS0205 | Binder | Error | Unsupported field type (current slice: int, long, float, double, bool) | Shipped (UnsupportedFieldType) |
| SECS0206 | Binder | Error | Unsupported template field initializer (simple scalar literals only) | Shipped (UnsupportedTemplateFieldInitializer) |
| SECS0207 | Binder | Error | Template field assignment has no matching top-level field declaration | Shipped (UnknownTemplateField) |
| SECS0208 | Binder | Error | Template field declared type does not match assigned type | Shipped (TemplateFieldTypeMismatch) |
| SECS0211 | Parser | Error | Invalid template body declaration | Shipped (InvalidTemplateBodyDeclaration) |
| SECS0212 | Binder | Error | Undeclared tag in tags = ... or structural predicate | Reserved |
| SECS0301 | Binder | Error | Unsupported channel type (current slice: int, long, float, double, bool) | Shipped (UnsupportedChannelType) |
| SECS0302 | Binder | Error | Unsupported channel kind (supported: Base, Accumulative, Contributed) | Shipped (UnsupportedChannelKind) |
| SECS0303 | Binder | Error | Channel references an unknown source scope | Shipped (UnknownChannelSourceScope) |
| SECS0304 | Binder | Error | Channel references an unknown source field | Shipped (UnknownChannelSourceField) |
| SECS0305 | Binder | Error | Channel type does not match its source field type | Shipped (ChannelSourceTypeMismatch) |
| SECS0306 | Binder | Error | Unsupported literal for a channel property | Shipped (UnsupportedChannelLiteral) |
| SECS0401 | Binder | Error | Modifier references an unknown target channel | Shipped (UnknownModifierTargetChannel) |
| SECS0402 | Binder | Error | Unsupported modifier expression | Shipped (UnsupportedModifierExpression) |
| SECS0403 | Binder | Error | Unsupported modifier target (current slice supports channel effects only) | Shipped (UnsupportedModifierTarget) |
| SECS0404 | Binder | Error | Unsupported modifier stacking value (valid: single, unique, stackable) | Shipped (UnsupportedModifierStacking) |
| SECS0410-SECS0415 | Registry static analysis | Warning | Activity static-analysis band (unknown tag, non-positive duration, unknown actor/target scope, non-positive cost, policy-visible activity missing Preview()) | Reserved — no shipped owner in the live tree |
| SECS0420 | Parser | Error | inject activity X sets replace-only architectural slot | Reserved (per-slot activity/policy inject is future Phase 3 work) |
| SECS04xx (future) | Binder + lowering | Error | Duplicate EventOption.Key within one event's final merged option list after mod operations | Reserved for stable option key protocol (see docs/design/04-behavior.md, 10-host-secs-execution-boundary.md § 20.3, and FUTURE_WORK § 1.3). Must be enforced before any PendingChoiceSave or ResolveChoiceByKey lowering. |
| SECS0501 | Binder | Error | Contract references an unknown method | Shipped (UnknownContractMethod) |
| SECS0502 | Binder | Error | Duplicate contract method on a contract | Shipped (DuplicateContractMethod) |
| SECS0503 | Binder | Error | Template implements a method its contract does not declare | Shipped (UnknownTemplateMethod) |
| SECS0504 | Parser | Error | Unsupported template method statement | Shipped (UnsupportedTemplateMethodStatement) |
| SECS0505 | Binder | Error | Template method references an unknown modifier | Shipped (UnknownTemplateMethodModifier) |
| SECS0506 | Binder | Error | Template method references a scope unreachable from the contract root scope | Shipped (UnknownTemplateMethodScope) |
| SECS0507 | Binder | Error | Template callable has no matching contract query/method signature | Shipped (TemplateCallableSignatureMismatch) |
| SECS0601 | Binder | Error | FNV-1a-64 collision between distinct canonical identities | Shipped (HashCollision) |
| SECS0602 | Binder | Error | Duplicate declaration without inject/replace | Shipped (DuplicateDeclaration) |
| SECS0603 | Merger | Error | inject/replace target missing | Shipped (ModOperationTargetMissing) |
| SECS0604 | Merger | Error | Operation kind invalid for declaration kind | Shipped (ModOperationKindInvalid) |
| SECS0605 | Merger | Error | Unknown or unsupported slot in a mod operation body | Shipped (UnknownModSlot) |
| SECS0606 | Merger | Error | Attempt to replace the additive-closed walks_to edge set | Shipped (ClosedScopeWalkReplacement) |
| SECS0607 | Merger | Error | walks_to references undeclared scope after merge | Shipped (UndeclaredMergedScopeWalk) |
| SECS0608 | Merger | Error | Host-capable expansion references a source set outside load-order ancestry | Shipped (HostExpansionAncestryViolation) |
| SECS0609 | Merger | Error | Third-party data-only mod adds host-backed structural surface | Shipped (DataOnlyModHostSurface) |
| SECS0610 | Merger | Error | Ambiguous short identity reference in mod operation | Shipped (AmbiguousModOperationTarget) |
| SECS0611 | Merger | Error | replace declaration missing required slot | Shipped (ReplaceMissingRequiredSlot) |
| SECS0612 | Merger | Error | Duplicate slot in same declaration / inject body | Shipped (DuplicateModSlot) |
| SECS0613 | Merger | Error | phase / frequency refers to value not registered in scheduler / catalog | Compiler-planned |
| SECS0614 | Merger | Error | Targeted child-row operation matched zero targetable rows | Shipped (ChildRowSelectorNoMatch) |
| SECS0615 | Merger | Error | Targeted child-row operation matched multiple targetable rows | Shipped (ChildRowSelectorAmbiguous) |
| SECS0616 | Merger | Error | Child-row selector is not activation-constant or not bindable | Shipped (ChildRowSelectorNotBindable) |
| SECS0620 | Merger | Warning | try inject / try replace matched no target under --strict-mod-ops (silent no-op without the flag; never an error, never aborts the merge) | Shipped (TryModOperationNoMatch) |
| SECS0701 | Binder | Error | Unknown type name in a field, callable signature, struct/record field, array element, collection generic argument, or template/scope reference | Shipped (UnknownType) |
| SECS0702 | Binder | Error | Value initializer does not match the declared type | Shipped (InitializerTypeMismatch) |
| SECS0703 | Binder | Error | method declaration has a non-void return type; use query for read-only typed returns | Shipped (NonVoidMethod) |
| SECS0704 | Binder | Error | Query body performs a command-producing operation | Compiler-planned |
| SECS0705 | Merger | Error | Callable mod patch changes parameter or return type; body replacements may replace implementation only | Shipped (CallableSignatureChangedByMod) |
| SECS0706 | Binder | Error | Runtime template-field modifier attempts additive/multiplicative effect on a non-numeric structured field | Compiler-planned |
| SECS0800 | Merger | Warning | Two or more mod operations write the same slot; the last write in load order wins | Shipped (ModSlotConflict) |
| SECS0801 | Binder | Error | Unsupported behavior expression on an event/activity/policy declaration | Shipped (UnsupportedBehaviorExpression) |
| SECS0802 | Binder | Error | Unknown behavior reference (event/activity/policy declaration references an unknown identity) | Shipped (UnknownBehaviorReference) |
| SECS0803 | Binder | Error | Command-producing operation in a read-only expression (conditions, source_formula, can_start, and command argument expressions may only read state) | Shipped (CommandInReadOnlyContext) |
| SECS0804 | Binder | Error | Unsupported formula expression (allowed: resolve(Channel), self.Field, owner.resolve(Channel), owner.Field, owner.Scope.Field, @Scope.resolve(Channel), @Scope.Field, the numeric builtins pow/trunc/min/max/clamp, numeric/bool literals, the supported operators, and the conditional operator) | Shipped (UnsupportedFormulaExpression) |
| SECS0805 | Binder | Error | Formula references an unknown channel | Shipped (UnknownFormulaChannel) |
| SECS0806 | Binder | Error | Formula references an unknown scope | Shipped (UnknownFormulaScope) |
| SECS0807 | Binder | Error | Formula references an unknown field | Shipped (UnknownFormulaField) |
| SECS0808 | Binder | Error | Formula reads a bool host field (formulas evaluate in the double domain and may only read numeric host fields) | Shipped (NonNumericFormulaHostField) |
| SECS0810-SECS0812 | Save/load runtime | Error | Activity restore validation band (unknown activity id, args schema mismatch, zero lane id) | Reserved — the v2 restore paths throw InvalidOperationException without diagnostic-code identifiers |
| SECS0820-SECS0821 | Save/load runtime | Error | Slot restore validation band (unknown slot kind, element type mismatch) | Reserved — v2 ScopeSlotStore.RestoreFrom throws InvalidOperationException without diagnostic-code identifiers |
| SECS0822 | Binder | Error | ScopedList<T> indexed access attempt; use ScopedDictionary<TKey, TElement> for keyed access | Shipped (ListCollectionIndexerUnsupported) |
| SECS0830 | Analyzer | Error | Previous-tick bridge/API read on channel without track_prev = true | Doc-only analyzer reservation |
| SECS0840 | Binder | Error | Two scoped collections on the same scope derive the same lifecycle hook method name | Shipped (CollectionHookNameCollision) |
| SECS0910-SECS0914 | Registry static analysis | Warning | Policy static-analysis band (unknown need channel, unknown selector activity, conflicting need weight signs, unknown selector tag, unknown actor scope) | Reserved — no shipped owner in the live tree |
| SECS1001 | Binder | Error | System references an unknown foreach scope | Shipped (UnknownSystemScope) |
| SECS1002 | Parser | Error | Unsupported system statement | Shipped (UnsupportedSystemStatement) |
| SECS1003 | Binder | Error | Unsupported system expression | Shipped (UnsupportedSystemExpression) |
| SECS1004 | Binder | Error | System references an unknown field | Shipped (UnknownSystemField) |
| SECS1005 | Binder | Error | System references an unknown channel | Shipped (UnknownSystemChannel) |
Diagnostic-code numbering ranges:
SECS00xx— parser and command-line errors (structural syntax, source keyword whitelist guard, mod-operation form guard, package manifest validation) plus the Wave 1 removed-keyword reservations (SECS0030/SECS0031).SECS01xx— scope binder errors (scope fields,walks_toedges, scoped collections).SECS02xx— template/contract structural binder errors (unknown scope/contract, template fields, template body) plus the reservedSECS0212.SECS03xx— channel binder errors (type, kind, source scope/field, literals).SECS04xx— modifier binder errors (SECS0401-SECS0404) plus the reserved activity static-analysis and closed-slot bands (SECS0410-SECS0415,SECS0420).SECS05xx— contract/template callable binder errors.SECS06xx— identity and merge errors: hash collision and duplicate declaration (SECS0601/SECS0602), the source-set merger band (SECS0603-SECS0616), and the strict-mod-ops warning (SECS0620);SECS0613remains compiler-planned.SECS07xx— binder/merger errors for the structured-types and callables surface fromdocs/design/07-structured-template-data-and-callables.md.SECS0701-SECS0703andSECS0705are shipped;SECS0704andSECS0706remain compiler-planned.SECS0705straddles the binder/merger boundary because it constrains mod patches of callable signatures.SECS08xx— behavior/formula binder errors (SECS0801-SECS0808), the merge slot-conflict warning (SECS0800), the list-indexer and collection-hook errors (SECS0822,SECS0840), and the reserved save/load and analyzer bands (SECS0810-SECS0812,SECS0820-SECS0821,SECS0830).SECS09xx— reserved policy static-analysis band (SECS0910-SECS0914).SECS10xx— system binder errors.
Mod-operation / slot / source-boundary status crosswalk
This crosswalk is limited to diagnostics that sit on the source/compiler/runtime boundary. "Compiler-shipped" means the fork's parser, merger, or binder raises the code today. "Compiler-planned" means later compiler work must emit the code. "Doc-only reservation" means the number is allocated here so future analyzer work cannot collide with shipped codes; the v2 runtime ships no diagnostic-code enums.
| Code(s) | Current allocation status | Shipped surface today | Test coverage today | Boundary note |
|---|---|---|---|---|
SECS0032 | Compiler-shipped | UnsupportedSourceForm (parser) | Fork parser tests (SecsParserTests) + SECS.Compiler.Smoke | Source keyword whitelist guard; rejects uncommitted SECS source forms while allowing ordinary C# metadata collection. |
SECS0033 | Compiler-shipped | UnsupportedModOperationForm (parser) | Fork parser/merger tests + smoke ModMerge fixtures | Parse-time rejection of mod-operation forms outside the Phase-3a surface, so the merger only ever observes supported wrappers. |
SECS0034 | Compiler-shipped | InvalidContentPackageManifest (command line) | SECS.Compiler.Smoke package/cache tests | secs.package.json validation for ordered content packages. |
SECS0601 | Compiler-shipped | HashCollision (binder) | Fork binder tests (SecsBinderTests) | Deterministic canonical-id collision check before binding / lowering. |
SECS0602 | Compiler-shipped | DuplicateDeclaration (binder) | Fork binder/merger tests | Duplicate identity without a mod operation, checked on the merged declaration set. |
SECS0603-SECS0612, SECS0614-SECS0616 | Compiler-shipped | SecsDeclarationMerger merge band (see main table for per-code identifiers) | SecsDeclarationMergerTests (52 tests) + smoke ModMerge packages and phase3a-* fixtures | Merge-time operation, slot, source-boundary, and child-row validation from 06 § 12. |
SECS0613 | Compiler-planned | None | No compiler tests yet | Scheduler/catalog value check from 06 § 12. |
SECS0620 | Compiler-shipped | TryModOperationNoMatch (merger, warning) | StrictModOpsWarnsForUnmatchedTryInject* smoke tests | Warn-only under --strict-mod-ops; silent no-op otherwise; never aborts the merge. |
SECS0705 | Compiler-shipped | CallableSignatureChangedByMod (merger) | Fork merger tests + phase3a-callable-body-replace fixture | Callable mod patches replace implementation only; signatures are closed. |
SECS0800 | Compiler-shipped | ModSlotConflict (merger, warning) | Fork merger tests | Warning conflict report; later load-order write wins. |
SECS0810-SECS0812, SECS0820-SECS0821 | Doc-only reservation | None | None keyed to these codes | Save/load restore validation; the v2 restore paths throw InvalidOperationException without diagnostic-code identifiers. |
SECS0822 | Compiler-shipped | ListCollectionIndexerUnsupported (binder) | Fork binder tests | ScopedList<T> indexed access binder error. |
SECS0830 | Doc-only analyzer reservation | None | No analyzer tests yet | Prev-tick bridge/API read requires track_prev = true; helper shape remains deliberately narrow. |
SECS0840 | Compiler-shipped | CollectionHookNameCollision (binder) | Fork binder tests + collection-lifecycle-hooks smoke fixture | Colliding generated collection lifecycle hook names, caught at compile time. |
The shipped codes are exactly the descriptors in SecsDiagnosticDescriptors.cs; all remaining rows in this crosswalk are compiler/analyzer reservations, and no reservation may reuse a shipped number.
This catalog is the single source of truth for diagnostic-code allocation. The phase-specific Diagnostics subsections in docs/design/00-overview.md § Diagnostic code convention and the per-doc Diagnostics tables (01–09) describe each code's full semantics; this table catalogs which codes exist, where they live in the compiler today, and which phase reserves the unshipped ones.
Signature Id Rules
Every callable user/game method uses ordinary C#-style source syntax, but generated ids are owner-and-signature hashes:
- Scope method canonical text:
scope:<scope-name>.<Method>(<param-type-refs>):<return-type-ref>. - Contract query/method canonical text:
contract:<contract-name>.<Method>(<param-type-refs>):<return-type-ref>. - Examples from Valenar:
scope:Location.BuildingCount(ulong):int,scope:Character.AdvanceLead(Location,int):void,contract:Building.CanBuild():bool,contract:Building.OnBuilt():void. - The canonical text is built from the declared owner, source method name, source parameter
SecsTypeRefs, and source returnSecsTypeRef. Zero-argument signatures use empty parentheses in the hash input;NoArgsappears only in generated constant names. - Scope-typed entity parameters use the source scope name in the canonical text and
SecsTypeRef.EntityInScope(H.ScopeName)in declaration metadata. - Structured parameters and returns use canonical type names from the
SecsTypeRef, e.g.FeaturePlacementInputandFeaturePlacementResultincontract:Feature.EvaluatePlacement(Location,FeaturePlacementInput):FeaturePlacementResult. - Hash constants use the generated naming convention shown in
Generated/Hashes.cs; Valenar examples includeH.Scope_Location_BuildingCount_TemplateId_IntandH.Contract_Building_OnBuilt_NoArgs_Void. - Never hash or emit bare callable names. Every scope method, contract query, and contract method belongs to its scope or contract owner.
- Source calls still bind by normal name/signature resolution. The hash id is the emitted runtime key.
The Phase 1 compiler MUST normalize to the namespace-qualified form
<namespace>:<kind>/<name> for all hash inputs. The first
compiler-emitted Hashes.cs baseline is authoritative; the canonical-name
field on each declaration is the source of truth, and the hash is a derived
value.
What the Compiler Generates (Module Initialization)
The compiler produces a SecsModule class as part of the lowered C# syntax tree, matching the v2 WorldSchema assembly shape in the compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/SecsModule.g.cs. The minimum current shape is broader than the old channels/modifiers/templates list:
public static class SecsModule
{
public static void Initialize(SecsRegistry registry)
{
registry.RegisterTypes(Declarations.Types);
registry.RegisterPhases(Declarations.Phases); // Wave 30+: phase declarations
registry.RegisterTickRates(Declarations.TickRates); // Wave 30+: cadence declarations
registry.RegisterScopes(Declarations.Scopes);
registry.RegisterScopeFields(Declarations.ScopeFields);
registry.RegisterScopeMethods(Declarations.ScopeMethods);
registry.RegisterCollections(Declarations.Collections); // Phase 2+: typed ScopedList/ScopedDictionary fields
registry.RegisterTemplateFields(Declarations.TemplateFields);
registry.RegisterChannels(Declarations.Channels);
registry.RegisterFormula(H.Formula_TSomeChannel, Formula_TSomeChannel.Evaluate, readsChannels: []);
registry.RegisterFormulaContribution(H.Formula_TSomeChannel, H.SomeChannel);
registry.RegisterModifiers(Declarations.Modifiers);
registry.RegisterContracts(Declarations.Contracts);
registry.RegisterContractMethods(Declarations.ContractMethods);
registry.RegisterContextProfiles(Declarations.ContextProfiles);
registry.RegisterOnActions(OnActionDeclarations.All);
// Tags — registered named so structural predicates and tooltips can address them.
registry.RegisterTag(H.Tag_SomeTag, "SomeTag");
// Validate dependency graph — catches formula/channel/scope cycles at registration time.
var errors = registry.ValidateDependencies();
if (errors.Count > 0)
throw new InvalidOperationException(
$"SECS dependency validation failed:\n{string.Join("\n", errors)}");
registry.RegisterTemplate(new TemplateId(T.Id), T.Entry);
// Valenar example; default-template generation is owned by a later phase.
registry.RegisterDefaultTemplate(H.Location, new TemplateId(BareLocation.Id));
registry.RegisterTemplateTransitions(Declarations.TemplateTransitions);
// Runtime collection bridge for selectors backed by definition-reference slots.
registry.RegisterCandidateBuilder(SomeBuilder.Id, SomeBuilder.Build);
registry.RegisterTrigger(H.Trigger_SomeCondition, Trigger_SomeCondition.Evaluate);
registry.RegisterActivities(Declarations.Activities); // Wave 1
registry.RegisterPolicies(Declarations.Policies); // Wave 4
// Mod operations are merged at compile time by the Phase 3 source-set
// merge; the emitted module registers only the merged winners and has
// no runtime mod-registration surface.
}
public static readonly SystemRegistration[] Systems = [ /* ... */ ];
public static readonly EventEntry[] Events = [ /* ... */ ];
public static readonly SecsActivity[] Activities = [ /* ... */ ]; // Wave 1 — replaces the legacy behavior array
public static readonly SecsPolicy[] Policies = [ /* ... */ ]; // Wave 4
}
Phase 0 baseline: the active compiler-oracle module is the compiler-emitted
examples/valenar-v2/Generated/obj/**/SecsGenerated/SecsModule.g.cs. Phase 1+ implementation should match the v2WorldSchemaassembly shape there for executable v2 output. The example above is the abridged structural skeleton; the emitted file shows the current schema registration ordering and second-pass callback shape.
For Phase 1, the required checkpoint is the structural subset of that shape, not the full Valenar module. A minimal empty-template source set must still lower to:
registry.RegisterTypes(Declarations.Types);
registry.RegisterScopes(Declarations.Scopes);
registry.RegisterScopeFields(Declarations.ScopeFields);
registry.RegisterScopeMethods(Declarations.ScopeMethods);
registry.RegisterTemplateFields(Declarations.TemplateFields);
registry.RegisterChannels(Declarations.Channels);
registry.RegisterContracts(Declarations.Contracts);
registry.RegisterContractMethods(Declarations.ContractMethods);
registry.RegisterContextProfiles(Declarations.ContextProfiles);
registry.RegisterTemplate(T.Id, T.Entry);
Everything else in the full module shape — formulas, formula contributions, modifiers, on-actions, default templates, transitions, triggers, systems, events, activities, policies, and mods — can be empty or deferred until the phase that owns that construct. The important point is that template shell lowering is not valid unless the registry has already seen the type catalog, scope graph, callable signatures, hashes, and contract metadata the shell references.
ContractDeclaration[] alone is never a correct contract lowering target. Each contract query/method id listed in QueryMethodIds or MethodIds must have a matching ContractMethodDeclaration row using SecsTypeRef, and each lifecycle binding must target a declared parameterless void method. Likewise, scope methods are structural declarations even when their implementation lives in the host: ScopeMethodDeclaration[] is what lets the binder type-check calls and what lets generated call sites pick typed read-only host calls vs command-producing method forwarding.
Implementation Phases
Phase 0: Orientation & Build
Goal: Fork builds successfully, can compile a normal .cs file with the forked Roslyn.
Status: Complete for the current workspace baseline: secs-roslyn/ is present as the Roslyn submodule and builds. Phase 1 structural-shell work has landed on top of that baseline, and the compiler now emits the backed language surface for Valenar's Generated/ output (see the implementation-status note in "Current Lowering Baseline"). Broad language expansion and full Generated/ replacement for the unbacked surface remain future work.
- Fork dotnet/roslyn, create
secsbranch - Build the compiler and verify it works on a trivial C# file
- Add as submodule in secs-workspace
- Document build instructions in
secs-roslyn/BUILDING-SECS.md
Key areas of the Roslyn codebase:
| Area | What it does | Why we touch it |
|---|---|---|
src/Compilers/CSharp/Portable/Syntax/ | Syntax node definitions | Adding SECS node types |
src/Compilers/CSharp/Portable/Parser/ | Parser (LanguageParser.cs) | Adding SECS keyword parsing |
src/Compilers/CSharp/Portable/Binder/ | Semantic analysis | Teaching the binder about SECS types |
src/Compilers/CSharp/Portable/Lowering/ | Desugaring transforms | Lowering SECS nodes to standard C# |
src/Compilers/CSharp/Portable/Symbols/ | Symbol table | SECS identities in the symbol table |
src/Workspaces/ | IDE/LSP support | Making LSP aware of .secs files |
Phase Scope Rule
Each phase below implements only committed current syntax and runtime shapes. Open questions are not parser, binder, lowering, CLI, LSP, or test commitments until the design docs settle them. If an open question is mentioned inside a phase, the phase must either use today's committed spelling/shape or explicitly reject/defer the alternative.
Phase 1: Structural Prepass + Template Shell
Goal: Parse enough SECS structure to lower an empty template for any declared contract, emit hashes/declaration tables/module registration as C# syntax trees, and compile to DLL.
Status: Implemented. The Phase 1 structural slice — scope, contract root_scope, and template<Contract> shells with hashes, declaration tables, and module registration — is complete, and the later batches recorded in the implementation-status note under "Current Lowering Baseline" have landed on top of it: fields, channels, channel modifiers, systems, events, activities, the backed FromActor policy subset, the backed expression slice, and the Phase-3a mod-operation merge all ship today (registry_only is accepted in contract bodies). Structured type metadata and the other items listed as future work in that note remain blocked behind later phases.
Phase 1 is intentionally not "template only". A template shell cannot be correct until the compiler has already bound the contract's root_scope, generated owner-and-signature ids for callable methods, and emitted the structural arrays the registry validates at startup.
- Add or consume typed ID strong types from the v2 contract assemblies. The full set required by the modding/merge architecture (per
docs/design/06-overrides-and-modding.md § 4.1) is:TemplateId,ModifierId,ChannelId,TagId,PhaseId,TickRateId,ScopeId,ContractId,SystemId,EventId,OnActionId,ActivityId,ActivityRunId,ActivityLaneId,PolicyId,DomainId,NeedId,SelectorId,RuleId,SlotKindId. All arereadonly record structs wrappingulong. The compiler and generated code must not allow accidental cross-type mixing.TemplateIdis also the canonical key type forScopedDictionary<TemplateId, T>(Phase 2). - Study how
recordwas added — trace the full path:SyntaxKind, parser method, syntax node definition, lowering pass. - Add syntax for structural declarations:
SecsTypeDeclarationSyntaxfor C#struct,record, andenumdeclarations. Phase 1 must bind C# source type syntax recursively intoSecsTypeRefmetadata for scalar, scope entity, template reference, enum, struct, record, array, and supported collection forms; do not lower callable signatures throughSecsValueKind.ScopeDeclarationSyntaxforscope,walks_to, fields, collection names, and scope-method signatures. Collections in Phase 1 are name-only compile-time members used for hashes andforeachbinding only; typedScopedList<T>/ScopedDictionary<TKey, T>declarations andCollectionDeclaration[]emission are Phase 2 work (see Step 3 ofdocs/design/08-collections-and-propagation.md).ContractDeclarationSyntaxforroot_scope, lifecycle bindings,query/methodsignatures, andcontextprofiles. The committed lifecycle source spelling for now isactivation SomeMethod;;on_activate SomeMethod;is an open spelling alternative, not a Phase 1 parser target.- Top-level
channelandfielddeclaration syntax, enough to populate declaration metadata and hash ids.
- Emit deterministic FNV-1a-64
H.*constants for every declared identifier. Hash inputs must be canonical identity strings of the form<namespace>:<kind>/<name>(e.g.valenar:template/farm,valenar:channel/population), not bare display names. This prevents unrelated mods from accidentally colliding on common names. FNV-1a-64 collision between two distinct canonical identities is diagnosticSECS0601. Perdocs/design/06-overrides-and-modding.md § 4.2. - Run a structural binding pass before template lowering:
- Every source type reference in fields, callable signatures, struct/record fields, array elements, collection generic arguments, template references, and scope entity references must resolve to a built-in type or
SecsTypeDeclaration. - Every contract
root_scopemust resolve to a declared scope. - Every
query/methodsignature must lower to aContractMethodDeclarationrow withSecsTypeRefreturn/parameter metadata and owner-and-signature hash.querymay return structured types;methodmust returnvoid. - Every contract method id referenced by
ContractDeclaration.QueryMethodIds,MethodIds, orLifecycleBindingsmust have matching metadata. Lifecycle binding spelling is syntax only; emitted metadata always mapsContractLifecycleIds.Activationto the declared parameterless void method id. - Every scope method signature must lower to a
ScopeMethodDeclarationrow with return and parameterSecsTypeRefs. - Every context profile entry must resolve through the contract root scope and declared
walks_tograph.
- Every source type reference in fields, callable signatures, struct/record fields, array elements, collection generic arguments, template references, and scope entity references must resolve to a built-in type or
- Emit and register in the lowered C# tree:
SecsTypeDeclaration[]ScopeDeclaration[]ScopeFieldDeclaration[]ScopeMethodDeclaration[]TemplateFieldDeclaration[]ChannelDeclaration[]ContractDeclaration[]ContractMethodDeclaration[]ContextProfileDeclaration[]
- Add
TemplateDeclarationSyntaxand lowertemplate<C> T { }for any declared contractC:- Reject undeclared contract names; do not assume
Building,Location, or any Valenar contract/root names. - Static class
T TemplateEntrywithTemplateId,Name,ContractId,RootScopeId, and an emptyActivate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)registry.RegisterTemplate(T.Id, T.Entry)inSecsModule.Initialize
- Reject undeclared contract names; do not assume
- Emit
SecsModule.Initializein the Phase 1 structural order listed above: scopes/fields/methods/channels first, then contracts/contract methods/context profiles, then template entries. - Verification: compile a small generic
.secssource set with declared enum/record/struct types, a declared scope, a scope method signature, a contract with a structured-return query and lifecycle method signature, a context profile, and an empty template; inspect--emit-generated-csoutput or the decompiled DLL forSecsTypeDeclaration[],ScopeFrame,TemplateEntry,ScopeMethodDeclaration[],ContractDeclaration[],ContractMethodDeclaration[]withSecsTypeRef,ContextProfileDeclaration[], and registration. Do not start broad golden-test infrastructure in this phase.
Phase 2: Template Members, Scope Frames, and Expressions
Goal: Lower the practical template surface on top of Phase 1 structure: fields, own-root channels, formula registration, contract query/method bodies, and scope-aware read/write expressions.
Status: Partial — the backed slice ships. Scalar template-field assignments with typed scalar accessors, scalar scope fields, walks_to edges, typed ScopedList<T> / ScopedDictionary<TKey, T> collections with generated lifecycle hooks (SECS0105 / SECS0822 / SECS0840 enforced), formula-backed channels with generated formula delegates and self / owner receiver reads, lifecycle contract methods, typed template queries, and template command bodies (backed statement subset) are implemented. Structured fields, string field values, template-intrinsic channel sources, effective template-value reads (template_field(...) context expansion), and broad expression lowering remain future work.
Valenar reference example:
template<Building> Farm
{
channel int FoodOutput
{
return (int)(5 * @Location.Fertility / 33);
}
field int GoldCost = 10;
bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext);
}
}
Valenar's Building, Farm, Location, and Settlement names are examples only. The lowering is generic over whatever contract root scope and walks_to graph the source declares.
- Add template-field assignments and typed scalar/structured accessors. Scalar fields use
GetFieldInt/GetFieldLong/GetFieldFloat/GetFieldDouble/GetFieldBool; structured fields emit concrete immutable value accessors keyed byTemplateFieldDeclaration.ValueType = SecsTypeRef.*. - Add template-body channel declarations:
- Static own-root channels lower to
cmds.RegisterChannelSource(scope.Owner, scope.Root, ...)inActivateafter compile-time scope validation. - Dynamic own-root channels lower to
cmds.RegisterDynamicChannelSource(scope.Owner, scope.Root, ...), a formula delegate,registry.RegisterFormula(...), andregistry.RegisterFormulaContribution(...). - Cross-scope channel effects are rejected as template channels and must be expressed as named modifiers.
- Static own-root channels lower to
- Add
ScopeFrame-based lowering for contract query and method bodies:- Queries return their declared concrete type through typed query registrations/adapters; erased dynamic dispatch validates against
SecsTypeRef. - Void methods use
TemplateCommand<TArgs>/TemplateCommandDelegate<TArgs>andITemplateCommandContext. - Method/query ids are looked up through
ContractMethodDeclaration[], not hard-coded names. - Implementing a query or method that is not declared by the template's contract is a compiler error; omitting an implementation follows the current runtime default/no-op behavior.
- Queries return their declared concrete type through typed query registrations/adapters; erased dynamic dispatch validates against
- Add scope sigils and expression lowering:
@SomeScope.SomeField-> declared scope walk from the current frame, then a typed host read using the generated scope/field hash constants when a walk is required.@SomeScope.resolve(SomeChannel)-> declared scope walk from the current frame, then typed channel resolution.- Read-only scope methods -> generated typed query facades over
ISecsHostReads.CallScopeQuery<TArgs,TResult>. template_field(...)-> base/effective template-field reads with explicit context chain orContextProfileDeclarationexpansion.
- Add command-producing expression lowering in template contract methods (
increment,add_modifier, void scope methods) throughITemplateCommandContext.Commands; void scope methods use generated typed command facades over the active command context. Emitcontext.FlushCommands()after each command-producing source statement. - Add the feature-placement typed query pattern as the structured-data smoke test:
field FeaturePlacementProfile Placement = new(...)plusquery FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input). The compiler must keep this query read-only; global budgets, spacing, uniqueness, and conflicts are applied by generated/host systems after collectingFeaturePlacementResultvalues. - Bind tag identities from plain C#
static readonly TagIdvalues such aspublic static readonly TagId Food = TagId.Create("valenar:tag/food");, and addtags = T1, T2;template/activity-body syntax plus policy selectorfrom tags = T1, T2binding. Top-leveltag <Name>;is rejected per06-overrides-and-modding.md § 19.4 Tag identities stay plain C#; there is noTagDeclaration[]array. Generated output must register collected tags withregistry.RegisterTag(id, name)so structural predicates, validation, and tooling share one registry-backed tag catalog. Lowertags = ...inside a template body to theTagsinitializer on the generatedTemplateEntry; lowertags = ...inside an activity body toSecsActivity.Tags; lowerconsider activities from tags = ...tonew SelectorSource.FromTags(new[] { H.Tag_* }). Emit/analyze unknown tag references against the registered catalog. Emit the three structural predicate operators (has_tag,has_template,has_contract) as(FilterKind, hash)pairs usable inpropagates_to whereclauses (Phase 4) and aggregate channelChildren.Where(...)expressions (Phase 4). Current Valenar provenance covers template tags only; executable Valenar provenance for activity tags and policyfrom tagsis deferred to a scoped tag-fixture wave that lands.secs, Generated, and tests together. Live Valenar fixtures for propagationhas_tagand aggregate.Where(has_tag ...)also remain deferred until a real gameplay use exists, with generic runtime tests covering the APIs meanwhile. Undeclared tag diagnostics (SECS0212) are part of the same binding pass. Short tag aliases may resolve only when exactly one declared tag in the merged catalog exports that alias; if multiple tags share the alias, the compiler must reject the use with a dedicated future/reserved tag-alias ambiguity diagnostic (diagnostic number intentionally unassigned until alias binding is committed). Do not implement short-alias binding until that ambiguity behavior and diagnostic routing are committed. - Add typed collection declarations in
scopebodies. Replace the Phase 1 name-onlycollectionplaceholder withScopedList<T>andScopedDictionary<TKey, T>field syntax, lowering each to aCollectionDeclarationentry (ParentScopeId,FieldHash,Kind,KeyType,ElementScopeId). AddCollectionDeclaration[]registration toSecsModule.Initialize. EmitH.<ParentScope>_<FieldName>hash constants for the anonymous collection scope identity. Add theGetChildByTemplate/WalkChildrenbridge contract toISecsHostReadsas the host-side iteration surface. VerifyScopedDictionary<TemplateId, T>key-type checking (TemplateIdfrom Step 1) and rejectScopedList<T>indexed access withSECS0822. Closes Open Question #5 —collection Name of ScopeTypeis superseded by typedScopedList<T>/ScopedDictionary<TKey, T>. - Auto-generate anonymous-collection lifecycle hook method ids. For each
ScopedList<T>/ScopedDictionary<TKey, T>field declared on a scope, emitOn{FieldName}Added(T child)andOn{FieldName}Removed(T child)asContractMethodDeclarationentries on the parent scope's contract. If the contract does not declare matching method bodies, the engine silently skips dispatch — no warning. EmitSECS0840when two collection fields on the same scope produce colliding hook names.
Phase 3: Mod Operation & Merger
Authority:
docs/design/06-overrides-and-modding.mdis the authoritative design document for everything in this phase. When this plan and that doc conflict, the doc wins.
Goal: Parse the six mod operation keywords, build the slot-based merge pipeline, and emit a single merged AST that the Phase 1/2 binder/lowerer sees as one compiled game.
Status: Phase-3a slice shipped. SecsDeclarationMerger (secs-roslyn/src/Compilers/CSharp/Portable/SECS/Binding/SecsDeclarationMerger.cs) runs the compile-time source-set merge between parsing and binding: it applies mod operations against a declaration catalog in load order across the ordered base / expansion / mod source sets and hands the existing binder a merged declaration array in which no mod-operation wrapper survives. In the shipped slice, inject / try inject / inject_or_create patch scalar template_field value slots on templates, and replace / try replace / replace_or_create substitute whole declarations of any currently-backed kind (scope, contract, template, channel, modifier, system, on_action, event, activity, policy) while validating per-kind required slots (SECS0611). Missing targets raise SECS0603 for inject / replace, no-op for the try forms (warn-only SECS0620 under --strict-mod-ops), and create for the _or_create forms. Forms outside this surface are rejected at parse time with SECS0033. Coverage: SecsDeclarationMergerTests (52 tests) in the fork plus the Content/ModMerge packages and phase3a-* fixtures in tests/SECS.Compiler.Smoke. Remaining Phase 3 work: per-slot inject beyond scalar template fields against the full slot schema in 06 § 8 (06 § 8.10 enumerates 25 activity slots, 06 § 8.11 enumerates 6 policy slots), conflict-report.json emission, and --strict-conflicts.
The old single override Name { ... } keyword is replaced by explicit database operations per 06 § 3.
Operation syntax
Six operations are parsed; create is implicit (a normal declaration):
inject template Farm { field GoldCost = 8; }
replace template Farm { ... }
try inject template Farm { ... }
try replace system OldGrowthSystem { ... }
inject_or_create modifier GrainMarketBonus { ... }
replace_or_create template<Building> Lighthouse { ... }
ModOperationDeclarationSyntax wraps any of these six prefixes around a standard SECS declaration syntax node, carrying ModOperationKind and the inner declaration node.
SlotKey structure
Do not combine declaration and slot hashes with XOR. Use the structured key from 06 § 7.3:
public readonly record struct ModSlotKey(
DeclarationKind DeclarationKind,
ulong DeclarationId,
int SlotKind, // backed by the ActivitySlotKind / PolicySlotKind enums; cast to int for cross-kind storage
ulong ChildId); // 0UL when the slot is whole-declaration; non-zero when keying per-row (NeedId, RuleId, cost id, etc.)
Examples: (Activity, H.ActivityScoutNearbyLead, (int)ActivitySlotKind.Duration, 0UL), (Policy, H.PolicyCharacterSurvival, (int)PolicySlotKind.Needs, H.NeedCharacterSurvivalStayAlive). The compiler emits these constants with the kind as a name prefix and no underscore separator (H.PolicyCharacterSurvival from valenar:policy/CharacterSurvival, H.NeedCharacterSurvivalStayAlive from need:valenar:policy/CharacterSurvival.StayAlive); see docs/decisions/ADR-0003.
Implementation pipeline (Steps 1-7, per 06 § 16)
This is the core differentiator. The merger runs seven steps:
- Parse source sets independently. Each source set (base, expansion, each mod in load order) is parsed to its own syntax tree and source-set-local diagnostics. No cross-set dependencies at parse time.
- Build declaration catalog. Walk all source trees; record each declaration's kind, name, canonical id (
<namespace>:<kind>/<Name>, the declared name reproduced verbatim in source PascalCase — seedocs/decisions/ADR-0003), typed id, source set, load order, file path, source span, declaration AST node, and slot schema. - Extract semantic slots. Each declaration kind uses its schema (per
06 § 8). Asystemyields(system_phase, null),(system_frequency, null),(system_execute_body, null). Atemplateyields onetemplate_fieldslot per field, onetemplate_dynamic_channel_formulaper dynamic channel, onetemplate_query_methodper query, etc. The full slot table is in06 § 8.1-8.8— reference that doc; do not duplicate it here. - Apply operations in load order. For each source set in resolved load order:
create→ SECS0602 if identity exists;inject→ write listed slots (SECS0603 if target missing);replace→ replace full declaration (SECS0603 if target missing);try inject/try replace→ silent no-op if target missing;inject_or_create/replace_or_create→ inject/replace if exists, create if not. Every slot write is appended to slot history. The current value is the last write. - Produce merged AST / merged IR. The merged result retains source provenance per slot: source-set id, load order, source path, source span, syntax node payload, typed semantic payload. Type errors in mod slot bodies must point to the mod's source line, not generated code. This is the primary benefit of the Roslyn fork.
- Bind and validate merged result. Run normal C#/SECS binding after merge. Catches unknown identifiers, type mismatches, invalid channel operations, illegal scope walks, bad contract methods, dependency cycles, read-set errors, and all diagnostics in
06 § 12. - Lower once. The lowerer sees one merged world and emits the
.g.csfamily that today'ssecscalready writes underobj/**/SecsGenerated(Hashes.g.cs,Scopes.g.cs,Contracts.g.cs,Templates.g.cs,Channels.g.cs,Modifiers.g.cs,Formulas.g.cs,Systems.g.cs,Events.g.cs,Activities.g.cs,Policies.g.cs,SecsModule.g.cs, and the rest of the family listed intools/secsc/README.md), plusGame.Merged.dllandconflict-report.json(conflict-report.jsonemission is future work and is not written today).
Slot schema
Every declaration kind has a schema (per 06 § 8) defining: allowed members, slot identifiers, cardinality, base/inject/replace requirements, merge rule, diagnostics, lowering target, and conflict-report kind. Adding a new language feature should mean adding a new schema entry, not writing one-off merge code.
Conflict detection
A slot is conflicted when 2+ mods write the same SlotKey. One mod writing is clean. Two or more mods on the same slot = load order matters; the shipped merger raises the SECS0800 warning, and conflict-report.json emission remains future work. Conflicts are not compiler errors by default; --strict-conflicts (future) turns them into errors. Strict modes per 06 § 13.
Re-merge on reorder: Syntax trees are already parsed. Re-walk the operation list in new load order. No re-parsing. Fast enough for interactive mod manager UI.
Canonical diagnostics
Diagnostics SECS0601–SECS0616 are defined in 06 § 12. The merger must emit these at the source line that caused each diagnostic.
Architectural-slot rejection (replace-only slots)
Four activity slots — activity_actor_scope, activity_target_scope,
activity_lane, and activity_args_schema — and two policy slots (policy_actor_scope, policy_domain) are replace-only per 06 § 8.10 / § 8.11. The shipped Phase-3a slice cannot violate this: inject against any non-template declaration kind is rejected at parse time with SECS0033, so no per-slot activity/policy inject exists yet. When per-slot activity/policy inject lands, the parser must reject inject against these slots at parse time so the user sees the error pinned to the offending source line. Reserved diagnostic id for that parser-side rejection:
SECS0420(error) —inject activity X { actor ... | target ... | lane ... | (typed-args header) }cannot set replace-only architectural slotsactivity_actor_scope/activity_target_scope/activity_lane/activity_args_schema. Usereplace activityto change architectural identity. Sub-clusterSECS0420is reserved here so it does not collide with the reserved activity static-analysis band atSECS0410-0415.
The activity-args schema slot lowers from typed-args activities (activity Build(BuildStructureArgs args) { ... }) to an ExpectedArgsSchemaId override on the generated SecsActivity subclass. Untyped activities omit the override and inherit the runtime default 0UL ("accept any blob"); see Phase 4 below.
Structural merge rules
Host-capable source sets (base game plus official expansions/DLC that ship matching host code) may add new scopes, scope fields, collections, scope methods, and walks_to edges. Third-party mods are data-only by default: they consume the exported host-capable scope graph and cannot add host-backed structural surfaces (SECS0609). Contracts are closed surfaces; existing contract method lists, lifecycle bindings, and root_scope are not mod-extended (06 § 8.7). Types are additive but closed under inject/replace for existing exported types (06 § 8.8).
Signature stability
Scope and contract callable ids are regenerated from canonical owner+signature text after merge. Body replacements keep the same method id; additions of new scope methods or new contracts get new ids. Do not emit name-only method hashes.
Expansion/mod boundary
Official expansions are processed as part of the effective base layer before third-party mods and may update both host code and .secs declarations. Third-party mods do not ship host bridge DLLs under the committed runtime model.
Raw C# boundary
Phase 3 is committed for SECS constructs and mod-added SECS declarations. override class is not a committed Phase 3 feature; per 06 § 10 (open question), raw C# class override semantics are unsettled. Do not implement or depend on it here.
Phase 4: Modifiers, Systems, Events, On-Actions, Activities, and Policies
Goal: Complete the committed SECS syntax surface that executes during ticks or dispatches.
Status: Partial — the backed behavior surface ships. The fork emitter (secs-roslyn/src/Compilers/CSharp/Portable/SECS/Emit/SecsCSharpEmitter.cs) lowers channel modifiers (including propagates_to structural filters), formula delegates, systems, on_actions, pulse and on_action events with event options, activities (including typed activity args), and the backed FromActor policy subset; the live Valenar Generated/ output is emitted through these paths. Non-FromActor policy selectors, unsupported policy decisions, template-field modifier effects, and broad behavior-body expression coverage remain future work.
Contracts are no longer a Phase 4 afterthought; the structural subset lands in Phase 1 because templates depend on it. Phase 4 adds the remaining behavior constructs that consume those declarations. Activities (Wave 1, replacing the prior action keyword) and policies (Wave 4) ship in this phase alongside the older modifier/system/event/on-action surfaces. Their runtime targets — SecsActivity, EffectPlan, IActivityArgs<TSelf>, SecsPolicy, NeedTargetExpr, RuleDecision — are already shipped and exercised by hand-written stand-ins; Phase 4 emits the same shape from .secs source.
ModifierDeclarationSyntax->ModifierDeclaration[]with channel/template-fieldModifierEffect[],Stacking,MaxStacks,ReApplyMode,Decay, tags, dynamic formula effects, and triggers. Emit thePropagationClausefield whenpropagates_to children [where ...]is present:PropagationMode,PropagationFilter,FilterTags. The shipped binder rejects non-structuralpropagates_to wherepredicates (anything other thanhas_tag,has_template,has_contract) withSECS0801(UnsupportedBehaviorExpression). Virtual binding materialization inModifierBindingStorefollows the five propagation rules indocs/design/08-collections-and-propagation.md § Part 2: owner preservation (Rule 1), stacking keyed by(modifier_id, owner_id)on the collection (Rule 2), lifecycle mirroring the source binding (Rule 3), structural-onlywherepredicates (Rule 4), and HardOverride closer-scope-wins priority (Rule 5).- Add
AggregateChannelSourceas a new channel source kind. In top-levelchanneldeclarations addressedon Parent.FieldName, lowersource = Children.Sum/Min/Max/Average/Count/Where(...)expressions toAggregateChannelSourcemetadata inChannelDeclaration. RegisterDirtySetdependencies on child channel changes and on child join/leave events so that aggregate channel cache entries are invalidated when collection membership changes. Only structural predicates (has_tag,has_template,has_contract) are valid in aggregate.Where(...); channel-based and arbitrary lambda predicates are rejected. - Add
track_prev = trueas aChannelDeclarationproperty. When set, the engine snapshots the resolved channel value at end-of-tick intoPrevTickSnapshotStore. The committed runtime/generated read shape is the host bridgeReadPrevTick*family; standaloneprev_tick(...)syntax and a committedctx.PrevTick(...)lowering are not part of the source contract. ReserveSECS0830for the future binder/analyzer check that rejects prev-tick bridge/API reads on channels whose declarations do not havetrack_prev = true. SystemDeclarationSyntax-> class implementingITickSystem, staticSystemRegistration, slot recognition forphase = X;andfrequency = Y;inside thesystembody, load-distributedAllByContractForTickfor distributed frequencies, and statement-levelctx.FlushCommands()after command-producing source statements. Top-level phase and cadence values are plain C# helper classes, e.g.Phases.Growth = PhaseDeclaration.Create(...)andCadence.Daily = TickRate.Days(1), not SECS declarations. The compiler type-checksphase = ...;as aPhaseDeclarationexpression andfrequency = ...;as aTickRateexpression, then emits the resolvedSystemRegistrationvalues.- Add the
registry[id]ambient accessor. In systemExecutebodies and formula bodies, lowerregistry[expr]tocontext.Registry.GetTemplate(expr). Return type isTemplateEntry?(nullable). Emit a compile error whenregistry[id]is used outside a context that hasTickContextavailable (e.g., modifier effect bodies run in the resolution path, not the system path). Contract-typed uses (registry[recipeId]where the declared type isTemplateEntry<Recipe>) allow field access validated against the template's contract. SecsEventDeclarationSyntax-> sealed class deriving fromSecsEvent, withCondition(EventQueryContext)(read-only),Execute(EventContext)(command-producing), optionalBuildOptions(EventOptions options, EventQueryContext context)(read-only), option handler methods takingEventContext(command-producing), and staticEventEntry Registration. The read-only / command-producing context split is structurally enforced post-Wave C; the compiler MUST emitEventQueryContextforConditionandBuildOptions, neverEventContext. The pre-Wave-C single-EventContextshape is rejected. Rejected stale shapes: interface-based events and the contextless one-parameter options builder.OnActionDeclarationSyntax->OnActionDeclarationmetadata only:OnActionId,Name,ScopeId,ProvidedScopes, andSelectionMode. Source-authored on-action effect bodies, fallback, and chain syntax are not committed; guaranteed effects lower as ordinary subscriber events with priority. Reserved runtime fallback/chaining fields stay inert metadata and must not become live compiler-emitted dispatch behavior until the syntax and mod semantics are recommitted.SecsActivityDeclarationSyntax(Wave 1, replacing the prior legacy behavior declaration type) -> class deriving fromSecsActivity, usingActivityQueryContextfor read-only methods (CanStart,IsTargetValid,Preview) andActivityRunContextfor command-producing lifecycle methods (OnStart,OnUpdate,OnComplete,OnStop,OnCancel,OnFail). The pre-Wave-CActivityContexttype is deleted; compiler emission MUST useActivityQueryContextfor the read-only methods (constructed runtime-side via theActivityExecutor.CreateQueryContextfactory) and MUST NOT emit references to the deleted type. Typed activity args declared asrecord structimplementingIActivityArgs<TSelf>(Wave 3) and serialized viaActivityArgsBlob(SchemaId, Payload)with hex/base64 wire shape. When the source declares typed activity args (e.g.activity Build(BuildStructureArgs args) { ... }), the compiler MUST emit anExpectedArgsSchemaIdoverride returningdefault(BuildStructureArgs).SchemaIdso the executor's boundary schema check (Wave 2 of the Behavior Refactor) rejects mismatched blobs before any lifecycle hook runs. Untyped activities omit the override and inherit the default0UL("accept any blob"). The compiler MUST also emit the activity'sLaneOccupiedPolicyoverride only when the source declares a non-default lane-occupied policy; the runtime default isActivityLanePolicy.Rejectand onlyRejectis currently implemented —QueueandPreemptthrowNotImplementedExceptionat start time per the no-silent-fallback tenet.PreviewreturnsEffectPlanofPredictedEffectrows (Wave 2) carrying optionalModifierId(Wave 7) forPredictionOp.Modifyprojection. Note: deleted per-activity AI-weight hooks stay removed — utility-AI scoring is handled byPolicyExecutoragainstEffectPlans. Old allow/effect block syntax is prototype-only and must be rejected unless the design docs recommit it.
Hand-stand-in batch wrappers. The compiler may emit a single wrapper class that delegates to data-driven definition records rather than one
SecsActivitysubclass per activity for content-heavy families with shared body shape. Both shapes must be supported by the runtime registry —SecsActivityis duck-typed byActivityId, not by class identity.
SecsPolicyDeclarationSyntax(Wave 4) -> class deriving fromSecsPolicywithNeeds : IReadOnlyList<NeedDeclaration>,Selectors : IReadOnlyList<SelectorDeclaration>,Rules : IReadOnlyList<RuleDeclaration>, andEvaluateRule(RuleId, PolicyQueryContext) -> RuleDecisiondispatching by rule id. The pre-Wave-CPolicyRunContexttype is deleted; compiler emission MUST usePolicyQueryContext(constructed runtime-side via thePolicyExecutor.CreatePolicyQueryContextfactory) and MUST NOT emit references to the deleted type.NeedDeclarationcarries(NeedId, Name, ChannelId, NeedTargetExpr Target, NeedCurveKind Curve, Threshold, Weight);NeedTargetExpris a discriminated union (Literal | ChannelRef | Formula) —Formulaevaluates at scoring time viaRegistry.GetFormulaInt(Wave 7).SelectorSourceis a discriminated union (AllRegistered | FromTags | FromCollection(SlotKindId Slot, CandidateBuilderId Builder));FromCollectionis the runtime/lowering form used when a selector is backed by stored definition references inScopeSlotStore, and the bridge keyed byBuilderreads those references and emits typed-argsActivityRequestvalues. This is compiler/runtime support rather than standalone source vocabulary.RuleDecisionis a discriminated union of seven cases:Continue / Complete / Fail / Wait / Call(activityId, target, args) / CallBest(selectorId) / CancelChild.foreach entity in Contract->ctx.InstanceStore.AllByContract(H.ContractId)orAllByContractForTick(...).foreach child in parent.Collection->ctx.Host.GetChildren(parent, H.CollectionId)+ indexed loop.increment,add_modifier, and void scope-method calls -> command lowering plus statement-level flushes in system/event/activity bodies; template contract methods already use theITemplateCommandContextrule from Phase 2.save_scope_asandfire on_action-> saved-scope / dispatcher lowering with provided-scope validation.remove_modifierremains a deferred source keyword even though runtime helpers exist; do not make it part of Phase 4 unlessdocs/design/05-expressions.mdis updated first.eventdisambiguation: SECSeventat declaration level, C#eventinside ordinary class bodies.- Reserved diagnostic codes for removed legacy keywords (Wave 1 of the SECS Behavior Refactor): Parser-level errors. Lex/parse phase. Distinct from the reserved registry static-analysis bands SECS0410-SECS0415 (activity warnings) and SECS0910-SECS0914 (policy warnings). The reservation is locked here so the codes do not collide with future Phase 4 diagnostics.
SECS0030(error) — removed legacy activity keyword. Fires on removed activity-keyword declarations and matching removed slot identifiers in mod operations. Replacement guidance is indocs/design/04-behavior.md § Activitiesanddocs/decisions/ADR-0002-behavior-vocabulary.md.SECS0031(error) — removed legacy policy keyword. Fires on removed policy-keyword declarations. Replacement guidance is indocs/design/04-behavior.md § Policiesanddocs/design/09-ai-policies-and-activities.md.
Phase 4 verification should use narrow behavior smoke sources rather than broad golden suites: one pulse event, one on-action subscriber event with a provided saved scope, one metadata-only on-action declaration, one class-style activity that calls a void scope method and flushes, and one minimal policy with a need + selector + rule. Inspect lowered C# or decompiled output for SecsEvent, EventEntry, BuildOptions(EventOptions options, EventQueryContext context), OnActionDeclaration[], SecsActivity, ActivityQueryContext, ActivityRunContext, EffectPlan with PredictedEffect rows, IActivityArgs<TSelf> typed arg structs, SecsPolicy with NeedDeclaration / SelectorDeclaration / RuleDeclaration arrays and an EvaluateRule(RuleId, PolicyQueryContext) dispatch, and explicit FlushCommands() after command-producing source statements. Verify that the deleted pre-Wave-C types ActivityContext and PolicyRunContext do not appear in any lowered output.
Phase 5: secsc CLI Roadmap
Goal: Single binary wrapping the forked compiler with SECS-specific commands.
The implemented command surface is secsc check and secsc build over ordered content packages: exactly one --base <dir>, repeatable --expansion <dir> and --mod <dir>, --order <id,id,...> (re-orders only the mod sets by manifest package id), --strict-mod-ops, --out <dll> (required for build), --emit-generated-cs <dir>, --generated-namespace <ns>, --reference / -r <dll>, --csc <path>, and --toolchain-root <dir>. tools/secsc/README.md documents the full flag semantics. The table below is the later CLI roadmap, not the implemented command surface.
| Command | What it does |
|---|---|
secsc build --base Content/ --out Game.Content.dll | Parse → merge → lower → compile → Game.Content.dll (implemented) |
secsc build --base Content/ --mod A --mod B --order A,B --out Game.Merged.dll | Parse → merge in order → lower → compile → Game.Merged.dll (implemented) |
secsc validate --base Content/ --mod A --mod B | Parse → merge → binder diagnostics → report (no DLL) |
secsc conflicts --base Content/ --mod A --mod B --order A,B | Parse → merge → conflict report JSON only |
secsc calc --template <template> --scope <scope>.<field>=<value> --resolve <channel> | Load engine, register template, seed mock scope fields, resolve a channel |
secsc new "ModName" --game-path /path | Scaffold mod project |
secsc pack --mod-path ./ModName | Package mod into .secsmod archive |
secsc export-sdk --game-path /path | Extract ModSDK (base .secs source, definitions.json, tools) |
secsc lsp | Start language server |
secsc watch --base Content/ | File watcher, re-validates on save |
Ships as a RID-scoped tool bundle — no .NET SDK required on the target machine.
The manifest beside secsc is the authority for the matched Roslyn driver,
target reference pack, SECS reference assemblies, target framework, RID, and
compiler identity.
Phase 6: LSP & VS Code Extension
Goal: First-class editor experience for .secs files.
Built on Roslyn's existing Workspace and LSP infrastructure inside the fork.
LSP server (secsc lsp):
- Diagnostics (error squiggles from full compiler pipeline)
- IntelliSense for channel names, template names, scope paths, modifier names
- Go-to-definition: jump from an
inject/replaceoperation to the target base game definition - Hover info: show the base and winning slot payload when hovering a mod operation
- Find all references: which mods write the same declaration or slot
- Semantic highlighting for SECS keywords
VS Code extension:
- TextMate grammar extending C# grammar with SECS keywords (syntax highlighting without LSP)
- LSP client connecting to
secsc lsp - Packaged as
.vsix, included in game's mod tools - Standard LSP — works with any editor (JetBrains, Vim, Emacs)
Phase 7: Mod Manager UI
Goal: Player-facing conflict resolution tool.
- Consumes
secsc conflictsJSON output - Drag-to-reorder mod list → re-runs merge with new order (instant, no re-parsing)
- Per-slot diff: base value vs each mod's version, winner highlighted
- "Create Patch" button: scaffolds compatibility mod
- Technology: Avalonia (cross-platform .NET desktop) or the game developer builds their own UI consuming the JSON
- Can be embedded in game launcher or run standalone
Phase 8: Runtime Script Console
Goal: Paradox-style runtime console — evaluate SECS expressions against live game state.
// Example session using Valenar names.
> @Location.Fertility
33
> template_field(Farm, GoldCost, BuildCostContext)
10
> foreach entity in Settlement { print entity.resolve(Population) }
Entity#1: 42
> inspect Farm
FoodOutput: 5 (dynamic: 5 * @Location.Fertility / 33)
GoldCost: 10 (field)
...
Uses Roslyn's CSharpScript API. The console parses SECS expressions, lowers them (same lowering pass the compiler uses), and feeds the lowered C# to CSharpScript.EvaluateAsync() with the live game registry and an explicit ScopeFrame / TickContext as globals.
SECS provides the evaluation engine; the game developer provides the UI shell (ImGui overlay, in-game terminal, etc.).
Compilation Flow
Current command contract:
secsc check --base <BaseDir> [--expansion <Dir> ...] [--mod <Dir> ...] [--order <id,id,...>] [--strict-mod-ops] [--toolchain-root <dir>]
secsc build --base <BaseDir> [--expansion <Dir> ...] [--mod <Dir> ...] [--order <id,id,...>] [--strict-mod-ops] [--generated-namespace <Namespace>] --out <path>/Content.dll [--emit-generated-cs <path>/Generated] [--toolchain-root <dir>]
The current package, cache, generated-output, and runtime bundle layout is documented in tools/secsc/README.md. The broader command examples below are target architecture for later compiler/tooling phases.
Game Developer (no mods)
secsc build --base Content/ --out Game.Content.dll
│
├── Parse all .secs files → AST
├── Structural prepass → scopes, contracts, method signatures, hashes
├── Validate (references, types, scope walks, cycles, callable signatures)
├── Lower SECS nodes → ordinary C# SyntaxTrees (matching current Generated/ pattern)
├── Add SecsModule.Initialize + Systems[] + Events[] + Activities[] + Policies[] to the lowered trees
├── Roslyn binder + emitter → IL
└── Output: Game.Content.dll
Normal builds do not need to materialize .cs files. secsc build ... --emit-generated-cs <dir> is a diagnostic/review mode that writes the lowered C# trees to disk so the output can be inspected against the current generated-output contract.
Player with Mods
Game Launcher
│
├── 1. Discover mods (read mod.json manifests)
├── 2. Resolve load order (dependencies, before/after constraints)
│
├── 3. Validate + detect conflicts (fast, no compilation)
│ secsc validate --base Content/ --mod A --mod B --order A,B
│ Parse ALL .secs → structural prepass → merge per-slot → conflict report
│ Player sees conflict grid, adjusts load order
│
├── 4. Compile merged result
│ secsc build --base Content/ --mod A --mod B --order A,B --out Game.Merged.dll
│ Merge ALL .secs into ONE AST → recompute structural ids → lower to C# SyntaxTrees → compile → Game.Merged.dll
│
└── 5. Launch game
Game loads Game.Merged.dll
SecsModule.Initialize(registry) — one call, fully merged
Game runs — zero mod awareness at runtime
What Ships with the Game
Game/
├── game.exe
├── Game.Content.dll compiled base content
├── SECS.Runtime.dll runtime coordinator (netstandard2.1)
├── SECS.Core.dll shared types (netstandard2.1)
├── SECS.Schema.dll schema contract (netstandard2.1)
│
├── Content/
│ └── base/ source .secs files (modder reference)
│ ├── common/ (channels, modifiers, scopes, contracts)
│ ├── templates/
│ ├── systems/
│ ├── events/
│ ├── on_actions/
│ ├── activities/
│ ├── policies/
│ └── mods/
│
├── Tools/
│ ├── secsc/ compiler tool bundle (self-contained, no SDK needed)
│ │ ├── secsc
│ │ ├── secsc.bundle.json
│ │ ├── compiler/
│ │ └── refs/
│ ├── secs-lang.vsix VS Code extension
│ └── definitions.json machine-readable catalog for tooling
│
├── ModSDK/
│ ├── mod-template/ scaffold project
│ └── MODDING.md game-specific modding guide
│
└── Mods/
└── (player-installed mod folders with .secs source)
Namespace Rules (Compiler-Enforced)
SECS Constructs
- Flat global registry.
template<SomeContract> SomeTemplateisSomeTemplateeverywhere. - Identity is the canonical string
<namespace>:<kind>/<name>(e.g.valenar:template/farm), hashed toulongwith FNV-1a-64. This prevents accidental collisions on common names across mods. inject template Farm/replace template Farmmatch by canonical identity, not by C# namespace. Short names are resolved through the exported SDK/identity table; ambiguous short names areSECS0610.- No namespace qualifier is needed or allowed on bare SECS construct declarations.
Raw C# Code in .secs Files
- Mandatory namespace isolation.
- Base game: developer declares their own namespace (
MyGame.Helpers). - Mods: compiler auto-prefixes with mod name if no namespace declared.
class HelperinExampleMod->ExampleMod.Helper
- Prevents collisions between mods that happen to use the same class names.
Raw C# Class Replacement
- Deferred/open.
docs/design/06-overrides-and-modding.mdexplicitly leaves namespace-scopedoverride class Helpersemantics unsettled. - Phase 3 is committed only for SECS construct operations (
inject,replace, and the four variants). Raw C# helpers are namespace-isolated and compiled after the SECS merge; replacing raw C# class bodies is future design work. - Do not use raw C# class override as evidence for the committed SECS mod-operation model or as a Phase 3 deliverable.
Mod Manifest Format
{
"id": "example_mod",
"version": "1.2.0",
"name": "Example Mod",
"author": "ModderName",
"game_version": ">=1.0.0",
"dependencies": [],
"load_after": ["example_dependency"],
"load_before": [],
"tags": ["balance", "buildings"],
"content_roots": ["Content/"]
}
Risk Mitigations
| Risk | Mitigation |
|---|---|
| Roslyn build is complex / slow | Document build steps in Phase 0. CI caches. Build only the C# compiler project, not full Roslyn solution. |
| Upstream Roslyn changes break fork | Merge upstream quarterly, deliberately. Track release notes. |
| SECS syntax evolves during development | Each keyword is isolated — changing channel syntax doesn't affect system parsing. |
.secs files with broken C# in method bodies | Roslyn handles natively — diagnostics point at exact line in .secs file. |
| Large Roslyn submodule | Full clone is ~3GB. One-time cost. Needed for tracing record implementation. |
event keyword collision with C# | Context-based disambiguation: SECS event at declaration level, C# event inside class bodies. Same pattern as record. |
Resolved Decisions
- File extension:
.secs - Compiler target: the Roslyn fork currently builds with the .NET SDK pinned by
secs-roslyn/global.json(10.0.108 after the 2026-05-31 upstream sync). Engine targets netstandard2.1. Compiler output targets whatever the game targets (configurable). - Hot reload: Not needed. Game requires restart after recompilation.
- Runtime console: Yes (Phase 8). Uses Roslyn's
CSharpScriptAPI with SECS lowering. - Mod-operation merge: Compiler pass (pre-binding). Merge-first, compile-once.
- Conflict detection: Byproduct of merge pass. Structured JSON output consumed by mod manager UI.
- No runtime parsing: Mods ship
.secssource to the compiler/launcher, not independent runtime DLLs. The compiler merges base content plus enabled mods in load order, lowers the merged tree, and emits oneGame.Merged.dllthat the game loads with no.secsparsing and no per-mod DLL awareness at runtime. - Host extension boundary: base game and official expansions are host-capable source sets; third-party mods are data-only unless a future explicit mod-state/host-extension mechanism is designed.
- secsc distribution: RID-scoped tool bundle with
secsc.bundle.json, bundled Roslyn driver, target reference assemblies, and SECS reference assemblies. Repository-localsecsc.dllinvocations pass--toolchain-rootexplicitly.
Open Questions
Items in this section are explicit non-goals for the phase checklists above until their home design docs settle them.
- Namespace isolation for SECS constructs: Currently flat/global (Paradox-style). May revisit if collision becomes a problem at scale.
- Unity integration: For Unity consumers, assembly loading and
.asmdefconsiderations exist but are not the primary target. - Lifecycle binding spelling:
activation SomeMethod;vs a possibleon_activate SomeMethod;is still open indocs/design/01-world-shape.md; Valenar'sactivation OnBuilt;is only an example. The emitted shape is settled:ContractLifecycleIds.Activation -> H.Contract_<Contract>_<Method>_.... - Collection declaration typing: Resolved.
docs/design/08-collections-and-propagation.mdsupersedes the barecollectionkeyword with typedScopedList<T>andScopedDictionary<TKey, T>field syntax inscopebodies, lowering toCollectionDeclaration[]. Implemented in Phase 2. - Bare template defaults:
BareLocation's defaultSlots = 4lacks a source declaration; seedocs/design/02-templates.md. The compiler must not invent hidden Valenar defaults. - Event option descriptions/localization: Generated event choices have descriptions by hand today;
docs/design/04-behavior.mdleaves the source/localization convention open. - Activity cost metadata: Class-style activities (Wave 1 replacement for the removed behavior keyword) are committed, but high-level inspectable cost metadata is open in
docs/design/04-behavior.md. - Contract-call helper names:
docs/design/05-expressions.mdmarks helper names such ascontract_query<T>/contract_methodas target spellings, not final API names. The ABI shape is committed: template id, callable id,ScopeFrame, args, query read boundary, and template command context. - Raw C# class overrides:
docs/design/06-overrides-and-modding.mdleavesoverride class Helperopen. Until that design is settled, the compiler plan commits only to namespace isolation for raw C# helpers and the six SECS mod operations (inject,replace,try inject,try replace,inject_or_create,replace_or_create) for SECS constructs. - Engine-owned mod state: There is no committed generic storage system that lets data-only mods add persistent per-entity fields, collections, walks, or host methods without host code. If this is desired later, design it explicitly instead of treating host-backed scope declarations as magically moddable.
remove_modifiersource keyword: Runtime helpers exist, but the.secskeyword is not committed; seedocs/design/05-expressions.md. Do not include it in compiler phase scope until that doc changes.