SECS Roslyn Fork — Implementation Plan
Repository Setup
Fork Structure
secs-workspace/
├── src/ existing — engine runtime
│ ├── SECS.Abstractions/ shared types (netstandard2.1)
│ ├── SECS.Engine/ runtime engine (netstandard2.1)
│ └── SECS.Localization/ YAML localization (netstandard2.1)
├── secs-roslyn/ forked Roslyn submodule
│ └── (forked from dotnet/roslyn, latest)
├── examples/
│ ├── valenar/ reference colony-builder example
│ └── character-trainer/ 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.105) - secsc distribution target: unresolved packaging choice — self-contained binary vs dotnet tool
- Engine libraries (SECS.Abstractions, SECS.Engine): netstandard2.1 — Unity compatible
- Compiler output: configurable target — the game developer chooses their target framework. The compiler emits code referencing
SECS.Abstractions.dll(netstandard2.1), so the output is compatible with any .NET runtime that supports netstandard2.1
What We're Adding to Roslyn
Current Lowering Baseline
The compiler target is the current design/runtime plus the hand-written stand-ins under examples/valenar/Generated/. 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 baseline: the runtime now ships activities (Wave 1), EffectPlan previews (Wave 2), typed activity args (Wave 3), policies + utility AI scoring (Wave 4), runtime per-actor slots (Wave 5), the full mod-operation runtime for activities and policies (Wave 6), and warn-only registry static analysis (Wave 7). Every one of these surfaces is already implemented in src/SECS.Engine/ and exercised by hand-written stand-ins under examples/valenar/Generated/. The compiler's job is to emit the same shape the stand-ins emit today.
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 examples/valenar/Generated/ stand-ins, but physical files are not the architectural boundary.
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. |
| 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; effects live in subscriber events or the firing site. |
activity | Declares a class-style actor-driven activity (Wave 1 rename of the prior action 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.Food_Channel, 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.Food_Channel, 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). |
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(EventContext), Execute(EventContext), optional BuildOptions(EventOptions options, EventContext context), and static EventEntry Registration. |
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 ActivityContext for read-only methods (CanStart, IsTargetValid, Preview) and ActivityRunContext for command-producing lifecycle methods (OnStart, OnUpdate, OnComplete, OnStop, OnCancel, OnFail). 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, PolicyRunContext) -> RuleDecision dispatching by rule id. 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
| Engine Type | Location | Used For |
|---|---|---|
TemplateEntry | src/SECS.Engine/Instances/TemplateEntry.cs | Template id/name, ContractId, RootScopeId, Activate, typed query registrations, methods, typed scalar accessors, and future structured field accessors. |
TemplateId | src/SECS.Abstractions/Templates/TemplateId.cs (new) | Strong-type wrapper for template-id ulong hashes; the canonical key type for keyed scoped collections. Zero-cost record struct. |
ScopeFrame | src/SECS.Abstractions/Scopes/ScopeFrame.cs | Runtime frame for template/contract functions: Root, Owner / This, and Current. |
TemplateActivationDelegate | src/SECS.Abstractions/Delegates.cs | void(ScopeFrame, ISecsHostReads, CommandBuffer). |
| Typed template query registration | src/SECS.Abstractions/Delegates.cs, src/SECS.Engine/SecsRegistry.cs, src/SECS.Engine/TemplateActivator.cs | Concrete generated query methods return their declared type (bool, FeaturePlacementResult, etc.); erased dynamic adapters validate against SecsTypeRef. |
TemplateCommandDelegate<TArgs> / TemplateCommand<TArgs> | src/SECS.Abstractions/Delegates.cs | void(ITemplateCommandContext, in TArgs); command-producing methods remain void with typed argument structs/adapters and statement-level flushes. |
ITemplateCommandContext / TemplateCommandContext | src/SECS.Abstractions/Interfaces/ITemplateCommandContext.cs, src/SECS.Engine/TemplateCommandContext.cs | Command-producing template method context: Scope, Host, Commands, FlushCommands(), and host void-method forwarding. |
Formula*Delegate | src/SECS.Abstractions/Delegates.cs | Typed formula callbacks: (EntityHandle target, EntityHandle captured0, EntityHandle captured1, ISecsHostReads host) -> T. |
TriggerDelegate | src/SECS.Abstractions/Delegates.cs | bool(EntityHandle target, EntityHandle captured0, EntityHandle captured1, ISecsHostReads host). |
ScopeDeclaration / ScopeFieldDeclaration / ScopeMethodDeclaration | src/SECS.Abstractions/Scopes/ScopeDeclaration.cs | Scope graph, host fields, and host-exposed callable signatures. Callable signatures use SecsTypeRef; scoped entity parameters are represented directly as SecsTypeRef.EntityInScope(...). |
SecsTypeRef / SecsTypeDeclaration | src/SECS.Abstractions/SecsTypeMetadata.cs | Generated type metadata for scalar values, scope/template refs, structs, records, enums, arrays, and C# collection-shaped values. |
TemplateFieldDeclaration | src/SECS.Abstractions/Templates/TemplateFieldDeclaration.cs | Top-level template-field metadata and SecsTypeRef/clamps. Scalar runtime accessors exist today; structured accessors are the remaining generated C# emission work. |
ChannelDeclaration | src/SECS.Abstractions/Channels/ChannelDeclaration.cs | Channel metadata (ChannelKind, scalar type, clamps, source binding). |
ModifierDeclaration / ModifierEffect | src/SECS.Abstractions/Modifiers/ | Modifier metadata and channel/template-field effects. |
CollectionDeclaration | src/SECS.Abstractions/Scopes/CollectionDeclaration.cs (new) | Typed scope-bound collection metadata: ParentScopeId, FieldHash, Kind (List/Dictionary), KeyType, ElementScopeId. Registered via registry.RegisterCollections(Declarations.Collections). |
ContractDeclaration | src/SECS.Abstractions/Contracts/ContractDeclaration.cs | Runtime contract dispatch/lifecycle table. |
ContractMethodDeclaration | src/SECS.Abstractions/Contracts/ContractMethodDeclaration.cs | Compiler/tooling signature metadata for contract queries and methods using SecsTypeRef return/parameter types. |
ContextProfileDeclaration | src/SECS.Abstractions/Contexts/ContextProfileDeclaration.cs | Named ordered context chains for effective template-field reads. |
ITickSystem / SystemRegistration | src/SECS.Engine/Pipeline/ | Generated systems and host pipeline registration. |
SecsEvent / EventContext / EventOptions / EventEntry | src/SECS.Engine/Events/ | Generated event classes, per-fire context, player-choice options, event registration metadata. |
OnActionDeclaration | src/SECS.Engine/Events/OnActionDeclaration.cs | Metadata-only on-action declarations. |
SecsActivity / ActivityContext / ActivityRun / ActivityRunContext | src/SECS.Engine/Activities/ | Wave 1 — class-style activities and active execution state. Replaces the prior SecsAction / ActionContext / ActionRun / ActionRunContext types. |
EffectPlan / PredictedEffect / PredictionOp / PredictionConfidence / EffectPlanner | src/SECS.Engine/Activities/ | Wave 2 — declarative preview pipeline. Preview methods return an EffectPlan of PredictedEffect rows with Add/Set/Multiply/Modify ops, optional ModifierId (Wave 7), Probability, and Confidence. |
IActivityArgs<TSelf> / ActivityArgsBlob | src/SECS.Engine/Activities/ | Wave 3 — typed activity args via IActivityArgs<TSelf> (instance interface — netstandard2.1 has no static abstract members). ActivityArgsBlob(SchemaId, Payload) is the schema-tagged byte payload; wire shape is (SchemaIdHex, PayloadBase64) to avoid 2^53 precision loss. |
IMethodPreview | src/SECS.Engine/Activities/IMethodPreview.cs | Wave 5 — declarative-preview hook for template-method bodies. TemplateEntry.MethodPreviews is the per-template dictionary; UseItemActivity.Preview consults it for items' OnUsed effects. |
SecsPolicy / PolicyExecutor / RuleDecision / NeedDeclaration / SelectorDeclaration / RuleDeclaration / NeedTargetExpr / NeedCurves / CandidateBuilder / CandidateBuilderId | src/SECS.Engine/Policies/ | Wave 4 — utility-AI policy runtime. RuleDecision is a discriminated union (Continue / Complete / Fail / Wait / Call(activityId, target, args) / CallBest(selectorId) / CancelChild). NeedTargetExpr is a Wave 5 discriminated union (`Literal |
ScopeSlotStore / SlotKindId / PolicySlotKinds / SlotSnapshot | src/SECS.Engine/Slots/ | Wave 5 — per-actor mutable scope-keyed list storage. Throws on uninitialized read, double initialize, type mismatch. Keyed by (EntityHandle scope, SlotKindId kind). Surfaces Read<T> / Initialize<T> / Append / Insert / Replace / RemoveAt<T> / Contains / RemoveAllForScope. Wave 8 adds Snapshot() / Restore(snapshots) / EnumerateSlotKeys() for save/load with hard validation (SECS0820 unknown kind / non-empty store, SECS0821 element type mismatch). |
ModRegistry / ModSlotKey / ActivityMod / PolicyMod / ActivityModPatch / PolicyModPatch / MergedActivity / MergedPolicy / ModOperationKind / DeclarationKind / ActivitySlotKind / PolicySlotKind / ActivityBodyDelegates / ModDiagnostic / ModDiagnosticCode | src/SECS.Engine/Modding/ | Wave 6 — full mod-operation runtime. Implements all 7 ops (Create / Inject / Replace / TryInject / TryReplace / InjectOrCreate / ReplaceOrCreate) for activities and policies. ModSlotKey(DeclarationKind, DeclarationId, SlotKind, ChildId) is the structured slot key. SecsRegistry.FinalizeModRegistration() accumulates mods, finalizes once, replaces declaration maps with merged views. Runtime-shipped mod diagnostics are SECS0602, SECS0603, SECS0604, SECS0611, SECS0800, and SECS0801; the other 06xx source-set / slot-schema diagnostics are compiler-owned Phase 3 reservations. This is the lowering target Phase 3 emits. Slot schema enumerated in 06 § 8.10 (25 activity slots — Wave 6 added activity_args_schema) and 06 § 8.11 (6 policy slots). |
RegistryDiagnostic / RegistryDiagnosticCode / RegistryDiagnostics / Severity | src/SECS.Engine/Diagnostics/ | Wave 7 — warn-only registry static-analysis surface. Severity enum (Error / Warning / Info / Hint) used by both ModDiagnostic and RegistryDiagnostic. SecsRegistry.Validate() returns RegistryDiagnostics with codes SECS0303 (formula readsChannels over-approximation), SECS0410-0414 (activity static analysis), SECS0415 (policy-visible activity inherits the default EffectPlan.Empty Preview; emitted for selector sources AllRegistered and FromTags — FromCollection is not statically checked because the candidate builder yields activities at runtime), SECS0910-0914 (policy static analysis). Wave 8 reserves codes SECS0810-0812 (activity restore validation) and SECS0820-0821 (slot restore validation) on the same enum, but those failures are unrecoverable for the affected run and are surfaced as InvalidOperationException from ActivityRunStore.Restore and ScopeSlotStore.Restore — they are not collected by Validate(). When the compiler matures, the static-analysis checks can move to parse-time analyzers without changing call sites. |
SecsRegistry | src/SECS.Engine/SecsRegistry.cs | Central registry for structural declarations, formulas, triggers, modifiers, contracts, templates, transitions, on-actions, activities (Wave 1), policies (Wave 4), mods (Wave 6), and validation. New entry points: RegisterActivityMod / RegisterPolicyMod (Wave 6), FinalizeModRegistration() (Wave 6), Validate() (Wave 7). |
CommandBuffer / TickContext | src/SECS.Abstractions/Commands/, src/SECS.Engine/Pipeline/TickContext.cs | Buffered command writes, command flushing, channel resolver access, events, saved scopes, and activity/policy/system/event execution context. TickContext.SlotStore (Wave 5) and TickContext.Registry (Wave 6) are required init members. |
EntityHandle / erased value carrier | src/SECS.Abstractions/ | Entity identity plus command payload storage and explicitly erased dynamic/tooling adapters. Known generated template/scope call sites use concrete C# types and typed args structs validated by SecsTypeRef; they do not use an erased SecsValue callable ABI. |
ActivityRequest | src/SECS.Engine/Activities/ActivityRequest.cs | Carries (ActivityId, Actor, Target, Args, Origin) from caller to executor (Wave 2). |
ActivityCandidate | src/SECS.Engine/Activities/ActivityCandidate.cs | (Request, Preview, PolicyScore, ScoreConfidence, Status) — the unit policy scoring sees (Wave 2). |
ActivityRequestOrigin | src/SECS.Engine/Activities/ActivityRequestOrigin.cs | Enum {Unknown, Player, Policy, System, Event, Mod} — provenance of the request (Wave 2). |
CandidateStatus | src/SECS.Engine/Activities/CandidateStatus.cs | Enum {Available, Filtered, Selected, Started, Rejected} — candidate lifecycle (Wave 2). |
ActivityLanePolicy | src/SECS.Engine/Activities/ActivityLanePolicy.cs | Enum {Reject, Queue, Preempt} — only Reject is implemented; Queue / Preempt throw NotImplementedException at start time (Wave 2). |
CandidateBuilderId / CandidateBuilderDelegate | src/SECS.Engine/Policies/CandidateBuilder.cs | Runtime bridge for collection-backed selectors: (actor, slot, tick) -> IReadOnlyList<ActivityRequest>. Turns stored definition references into typed-args requests; not a standalone .secs declaration. |
PolicyDispatcher / PolicyDispatchResult | src/SECS.Engine/Policies/PolicyDispatcher.cs | Reference dispatch loop processing RuleDecision cases. Result enum {Continue, Complete, Fail} (Wave 3). |
ScoreBreakdown / ScoreContribution | src/SECS.Engine/Policies/ScoreBreakdown.cs | Per-need explainability: (NeedId, NeedName, Urgency, Relevance, Matched, Weight, WeightedContribution) (Wave 5). |
PreviewDriftRecorder / PreviewDriftRecord / PreviewDriftEntry | src/SECS.Engine/Activities/PreviewDriftRecorder.cs | Opt-in telemetry: snapshots channel values at run start, compares to actuals at completion (Wave 5). |
Diagnostic Code Catalog
A consolidated table of every SECS diagnostic code reserved across phases. Codes that appear in RegistryDiagnosticCode or ModDiagnosticCode enums are runtime-shipped; the others are future-compiler reservations.
| Code | Phase | Severity | Diagnoses | Status |
|---|---|---|---|---|
| SECS0030 | Phase 4 (parser) | Error | Removed Wave-1 keyword (legacy activity declaration); use activity (see Phase 4 Reserved diagnostic codes below) | Reserved (Wave 1) |
| SECS0031 | Phase 4 (parser) | Error | Removed Wave-1 keyword (legacy policy declaration); use policy (see Phase 4 Reserved diagnostic codes below) | Reserved (Wave 1) |
| SECS0032 | Parser / source keyword whitelist | 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) | Compiler-planned source-boundary guard; ordinary C# metadata collection remains allowed |
| SECS0212 | Phase 2 (binder) | Error | Undeclared tag in tags = ... or structural predicate | Reserved |
| SECS0303 | Wave 7 (registry static analysis) | Warning | Formula readsChannels over-approximation | Shipped (FormulaReadSetOverApproximation) |
| SECS0410 | Wave 7 (registry static analysis) | Warning | Activity references unknown tag id | Shipped (ActivityUnknownTag) |
| SECS0411 | Wave 7 (registry static analysis) | Warning | Activity declares non-positive duration | Shipped (ActivityNonPositiveDuration) |
| SECS0412 | Wave 7 (registry static analysis) | Warning | Activity references unknown actor scope | Shipped (ActivityUnknownActorScope) |
| SECS0413 | Wave 7 (registry static analysis) | Warning | Activity references unknown target scope | Shipped (ActivityUnknownTargetScope) |
| SECS0414 | Wave 7 (registry static analysis) | Warning | Activity cost amount non-positive after merge | Shipped (ActivityNonPositiveCost) |
| SECS0415 | Wave 5 (registry static analysis) | Warning | Policy-visible activity missing Preview() override | Shipped (PolicyVisibleActivityMissingPreview) |
| SECS0420 | Phase 3 (mod parser) | Error | inject activity X sets replace-only architectural slot | Reserved (Wave 6) |
| SECS0601 | Phase 1 (structural prepass) | Error | FNV-1a-64 collision between distinct canonical identities | Compiler-planned |
| SECS0602 | Phase 3 (merger) | Error | Duplicate declaration without inject/replace | Shipped (DuplicateDeclaration) |
| SECS0603 | Phase 3 (merger) | Error | inject/replace target missing | Shipped (TargetMissing) |
| SECS0604 | Phase 3 (merger) | Error | Operation kind invalid for declaration kind | Shipped (OperationNotValid) |
| SECS0605 | Phase 3 (merger) | Error | Unknown slot in inject body | Compiler-planned |
| SECS0606 | Phase 3 (merger) | Error | Attempt to override / replace additive-closed scope edge set | Compiler-planned |
| SECS0607 | Phase 3 (merger) | Error | walks_to references undeclared scope after merge | Compiler-planned |
| SECS0608 | Phase 3 (merger) | Error | Host-capable expansion references a source set outside load-order ancestry | Compiler-planned |
| SECS0609 | Phase 3 (merger) | Error | Third-party data-only mod adds host-backed structural surface | Compiler-planned; no runtime enum by design |
| SECS0610 | Phase 3 namespace rules | Error | Ambiguous short identity reference in mod operation | Compiler-planned |
| SECS0611 | Phase 3 (merger) | Error | replace declaration missing required slot | Shipped (ReplaceMissingRequiredSlot) |
| SECS0612 | Phase 3 (merger) | Error | Duplicate slot in same declaration / inject body | Compiler-planned |
| SECS0613 | Phase 3 (merger) | Error | phase / frequency refers to value not registered in scheduler / catalog | Compiler-planned |
| SECS0614 | Phase 3 (merger) | Error | Targeted child-row operation matched zero targetable rows | Compiler-planned |
| SECS0615 | Phase 3 (merger) | Error | Targeted child-row operation matched multiple targetable rows | Compiler-planned |
| SECS0616 | Phase 3 (merger) | Error | Child-row selector is not activation-constant or not bindable | Compiler-planned |
| SECS0800 | Phase 3 (mod runtime) | Warning | Two or more mod writes to same slot | Shipped (SlotConflict) |
| SECS0801 | Phase 3 (mod runtime) | Error (Wave 6) | Inject targets a replace-only architectural slot | Shipped (InjectClosedSlot) — promoted from Warning to Error in Wave 6. Also rejected at parse time by SECS0420. |
| SECS0802 | Phase 4 (modifier parser) | Error | Lambda or channel-based predicate in propagates_to where clause (must be structural: has_tag, has_template, has_contract) | Reserved (renumbered from SECS0801 in Wave B11 to free SECS0801 for the runtime InjectClosedSlot shipped enum). |
| SECS0803 | Compiler/analyzer handoff | Error | Command-producing call appears in a read-only body (event Condition, activity IsVisible, policy rule Decision, etc.) | Doc-only analyzer reservation; no runtime enum |
| SECS0804 | Compiler/analyzer handoff | Error | Generated code reaches ISecsHostCommands without a command-producing context | Doc-only analyzer reservation; no runtime enum |
| SECS0810 | Wave 8 (save/load runtime) | Error | Saved ActivityRunState.ActivityId not present in current registry | Shipped (ActivityRestoreUnknownActivity) — surfaced as InvalidOperationException from ActivityRunStore.Restore. |
| SECS0811 | Wave 8 (save/load runtime) | Error | Saved ActivityArgsBlob.SchemaId does not match the activity's ExpectedArgsSchemaId | Shipped (ActivityRestoreSchemaMismatch) — surfaced as InvalidOperationException from ActivityRunStore.Restore. |
| SECS0812 | Wave 8 (save/load runtime) | Error | Saved ActivityRunState.Lane is the zero / None lane id | Shipped (ActivityRestoreUnknownLane) — surfaced as InvalidOperationException from ActivityRunStore.Restore. |
| SECS0820 | Wave 8 (save/load runtime) | Error | Saved SlotKindId is the zero kind id, or ScopeSlotStore.Restore was called on a non-empty store | Shipped (SlotRestoreUnknownKind) — surfaced as InvalidOperationException from ScopeSlotStore.Restore. |
| SECS0821 | Wave 8 (save/load runtime) | Error | Saved slot element does not match the snapshot's declared ElementType | Shipped (SlotRestoreTypeMismatch) — surfaced as InvalidOperationException from ScopeSlotStore.Restore. |
| SECS0822 | Phase 2 (binder) | Error | ScopedList<T> indexed access attempt | Compiler-planned (renumbered from SECS0820 in Wave 8 to free 0820 for the slot save/load runtime band) |
| SECS0830 | Phase 4 (binder/analyzer handoff) | Error | Previous-tick bridge/API read on channel without track_prev = true | Doc-only analyzer reservation |
| SECS0840 | Phase 2 (binder) | Error | Two collection fields on same scope produce colliding hook names | Shipped (CollectionHookNameCollision) |
| SECS0910 | Wave 7 (registry static analysis) | Warning | Policy need references unknown channel id | Shipped (PolicyNeedUnknownChannel) |
| SECS0911 | Wave 7 (registry static analysis) | Warning | Policy selector references unknown activity id | Shipped (PolicySelectorUnknownActivity) |
| SECS0912 | Wave 7 (registry static analysis) | Warning | Two policy needs with conflicting weight signs on same channel | Shipped (PolicyNeedWeightConflict) |
| SECS0913 | Wave 7 (registry static analysis) | Warning | Policy selector FromTags references unknown tag id | Shipped (PolicySelectorUnknownTag) |
| SECS0914 | Wave 7 (registry static analysis) | Warning | Policy actor scope id has no registered scope | Shipped (PolicyUnknownActorScope) |
Diagnostic-code numbering ranges:
SECS00xx— parser-level errors (Phase 4 lex/parse, Wave 1 reservations, source keyword whitelist guard).SECS02xx— Phase 2 binder errors (template members, scope frames, expressions).SECS03xx— formula-level warnings (registry static analysis).SECS04xx— activity-level errors and warnings (parser + registry).SECS06xx— Phase 3 merger errors (mod operation parse/merge).SECS08xx— Phase 3 mod runtime warnings + (post-Wave-6) closed-slot errors + compiler/analyzer boundary reservations + (Wave 8) save/load runtime errors. Sub-bands:0800mod runtime warnings,0801closed-slot runtime error,0802propagation parser reservation,0803-0804host-boundary analyzer reservations,0810-0812activity restore validation,0820-0821slot restore validation,0822-0840Phase 2 / Phase 4 binder and analyzer reservations plus shipped collection-hook validation.SECS09xx— policy-level warnings (registry static analysis).
Mod-operation / slot / source-boundary status crosswalk
This crosswalk is limited to diagnostics that sit on the source/compiler/runtime boundary. "Runtime-shipped" means an existing runtime enum value or thrown diagnostic string owns the code today. "Compiler-planned" means Phase 1-4 compiler work must emit the code when the parser/merger/binder exists. "Doc-only reservation" means the number is allocated in the design docs so analyzer work cannot collide with runtime codes, but no runtime enum should be added.
| Code(s) | Current allocation status | Runtime surface today | Test coverage today | Boundary note |
|---|---|---|---|---|
SECS0032 | Compiler-planned | None | No compiler tests yet | Source keyword whitelist guard; rejects uncommitted SECS source forms while allowing ordinary C# metadata collection. |
SECS0601 | Compiler-planned | None | No compiler tests yet | Deterministic canonical-id collision check before binding / lowering. |
SECS0602 | Runtime-shipped + compiler-planned | ModDiagnosticCode.DuplicateDeclaration | tests/SECS.Engine.Tests/ModRegistryTests.cs (CreateActivityDuplicateTargetRaisesDuplicateDeclarationDiagnostic) | Phase 3 compiler emits the same code for duplicate declarations before lowering. |
SECS0603 | Runtime-shipped + compiler-planned | ModDiagnosticCode.TargetMissing | ModRegistryTests.InjectActivityMissingTargetRaisesDiagnostic | Included here as the companion missing-target operation code. |
SECS0604 | Runtime-shipped + compiler-planned | ModDiagnosticCode.OperationNotValid | tests/SECS.Engine.Tests/ModRegistryTests.cs (InvalidActivityOperationRaisesOperationNotValidDiagnostic) | Runtime covers activity/policy operation-kind mismatches; source-set checks remain compiler-owned. |
SECS0605-SECS0608 | Compiler-planned | None | No compiler tests yet | Slot-schema and host-capable expansion checks from 06 § 12. |
SECS0609 | Compiler-planned; no runtime enum by design | None | No compiler tests yet | Data-only mods cannot add scopes, scope fields, scoped collections, scope methods, walks_to, existing contract method lists, or on-action provides entries. ModRegistry never sees those source declarations. |
SECS0610 | Compiler-planned | None | No compiler tests yet | Ambiguous short-name binding against exported source-set / SDK identity tables. |
SECS0611 | Runtime-shipped + compiler-planned | ModDiagnosticCode.ReplaceMissingRequiredSlot | tests/SECS.Engine.Tests/ModRegistryTests.cs (ReplaceActivityWithoutCreateBaseRaisesMissingRequiredSlotDiagnostic) | Runtime validates activity/policy replace bases; compiler must validate every declaration schema. |
SECS0612-SECS0616 | Compiler-planned | None | No compiler tests yet | Duplicate-slot, scheduler/catalog, and targeted child-row diagnostics from 06 § 12. |
SECS0800 | Runtime-shipped | ModDiagnosticCode.SlotConflict | ModRegistryTests.TwoModsWriteSameSlotEmitConflictDiagnostic, DemoModIntegrationTests | Warning conflict report; later load-order write wins. |
SECS0801 | Runtime-shipped | ModDiagnosticCode.InjectClosedSlot | Multiple ModRegistryTests closed-slot assertions | Parser-side precheck uses SECS0420; SECS0801 remains the runtime finalization error. |
SECS0802 | Compiler-planned / doc-only reservation | None | No compiler tests yet | propagates_to where must use structural predicates only. |
SECS0803-SECS0804 | Doc-only analyzer reservation | None | No analyzer tests yet | Host execution-boundary analyzer checks; no runtime enum should be added. |
SECS0810-SECS0812 | Runtime-shipped | RegistryDiagnosticCode.ActivityRestore* plus ActivityRunStore.Restore exceptions | tests/SECS.Engine.Tests/ActivityRunStoreRestoreValidationTests.cs asserts SECS0810, SECS0811, and SECS0812 restore failures | Save/load restore validation, not compiler source diagnostics. |
SECS0820-SECS0821 | Runtime-shipped | RegistryDiagnosticCode.SlotRestore* plus ScopeSlotStore.Restore exceptions | ScopeSlotStoreSnapshotTests | Save/load restore validation, not compiler source diagnostics. |
SECS0822 | Compiler-planned | None | No compiler tests yet | ScopedList<T> indexed access binder error; moved to avoid colliding with runtime SECS0820. |
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 | Runtime-shipped + compiler-planned | RegistryDiagnosticCode.CollectionHookNameCollision plus SecsRegistry.RegisterCollections exception | LifecycleHookCollisionTests | Runtime catches generated collision; compiler should preempt when it owns hook generation. |
No numeric collision exists across the runtime enums and compiler/doc reservations above: runtime mod codes occupy 0602, 0603, 0604, 0611, 0800, and 0801; registry/runtime restore and hook codes occupy 0810-0812, 0820-0821, and 0840; all remaining rows in this crosswalk are compiler/analyzer reservations.
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 runtime 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.
Hand-stand-in canonical-name drift. The current
examples/valenar/Generated/Hashes.cswas authored across multiple waves and uses three different canonical-name schemes:
- Bare lowercase (
"settlement","food") — early scope/channel hashes.- Prefixed lowercase (
"activity_scoutnearbylead","policy_charactersurvival") — Wave 1+ behavior hashes.- Namespace-qualified (
"valenar:tag/disease","valenar:phase/phasegrowth") — Wave 30+ tag/phase hashes.The Phase 1 compiler MUST normalize to the namespace-qualified form
<namespace>:<kind>/<name>for all hash inputs. The hand-stand-in's mixed scheme is grandfathered: changing it now would invalidate every saved game, test snapshot, and Generated cross-reference. The first compiler-emitted re-baseline ofHashes.cswill be the authoritative form; until then, 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 examples/valenar/Generated/SecsModule.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 registration is opt-in. Test harnesses install demo mods explicitly;
// the default game module emits empty Activity/PolicyMod arrays and may
// skip Finalize when there are no mods to merge.
if (host.UseMods)
{
foreach (var mod in Declarations.ActivityMods) registry.RegisterActivityMod(mod); // Wave 6
foreach (var mod in Declarations.PolicyMods) registry.RegisterPolicyMod(mod); // Wave 6
registry.FinalizeModRegistration(); // Wave 6
}
}
public static readonly SystemRegistration[] Systems = [ /* ... */ ];
public static readonly EventEntry[] Events = [ /* ... */ ];
public static readonly SecsActivity[] Activities = [ /* ... */ ]; // Wave 1 — replaces the prior `SecsAction[] Actions` array
public static readonly SecsPolicy[] Policies = [ /* ... */ ]; // Wave 4
public static readonly ActivityMod[] DemoActivityMods = [ /* ... */ ]; // Wave 6 — opt-in test/demo only
public static readonly PolicyMod[] DemoPolicyMods = [ /* ... */ ]; // Wave 6 — opt-in test/demo only
}
Phase 0 baseline: the actual hand-written
examples/valenar/Generated/SecsModule.csis currently the canonical shape the compiler must emit. Phase 1+ implementation should match this shape file-for-file. The example above is the abridged structural skeleton; the live file shows the full ordering (phases/tickrates registered before scopes; named tag registrations beforeValidateDependencies; per-template-idnew TemplateId(X.Id)wrapping; per-templateRegisterDefaultTemplatecalls keyed by scope hash) and is the binding contract.
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 against unmodified Roslyn main. Phase 1+ work is currently deferred. The runtime + Generated/ stand-in pattern carries the workspace today; the 7-wave behavior-layer refactor (Activities, EffectPlan preview, typed activity args, Policies, Runtime slots, Mod runtime, warn-only static analysis) was completed without any compiler change. When compiler work resumes, Phase 1 starts the first SECS syntax change.
- 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.
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 typed ID strong types to
SECS.Abstractions. The full set required by the modding/merge architecture (perdocs/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.
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.
Wave 6 baseline: the runtime side of mod operations is already implemented and shipped for activities and policies (Wave 6). Phase 3's job is to parse .secs mod source and emit ActivityMod / PolicyMod instances into the merged registration. The runtime types (ModRegistry, ModSlotKey, ActivityMod, PolicyMod, ActivityModPatch, PolicyModPatch, MergedActivity, MergedPolicy, ModOperationKind, ModDiagnostic, ModDiagnosticCode) live under src/SECS.Engine/Modding/. Slot schema is enumerated in 06 § 8.10 (25 activity slots — Wave 6 added activity_args_schema) and 06 § 8.11 (6 policy slots), in 1:1 correspondence with the ActivitySlotKind / PolicySlotKind enums. Diagnostic codes 600–611 are errors (throw on Finalize); SECS0800 is a warning (slot conflict); SECS0801 is an error (inject into replace-only architectural slot). The compiler does not need to re-implement merge semantics — it parses, builds the merged AST, and emits typed mod objects targeting the existing runtime.
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. The Wave 6 runtime ships the concrete shape under src/SECS.Engine/Modding/ModSlotKey.cs:
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.Activity_ScoutNearbyLead, (int)ActivitySlotKind.Duration, 0UL), (Policy, H.Policy_CharacterSurvival, (int)PolicySlotKind.Needs, H.Need_StayAlive).
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>), 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
Generated/Hashes.cs,Generated/Declarations.cs,Generated/Templates/*.cs,Generated/Systems/*.cs,Generated/Events/*.cs,Generated/SecsModule.cs,Game.Merged.dll, andconflict-report.json.
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, emit to conflict-report.json. Conflicts are not compiler errors by default; --strict-conflicts 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 (Wave 6 closed under inject)
Four activity slots — activity_actor_scope, activity_target_scope,
activity_lane, and activity_args_schema (Wave 6, ActivitySlotKind.ArgsSchema = 14) — and two policy slots (policy_actor_scope, policy_domain) are replace-only at runtime: ModRegistry.RegisterActivityMod / RegisterPolicyMod emit SECS0801 (InjectClosedSlot) at Severity.Error when an inject / try inject / inject_or_create patch sets any of them. Phase 3 mod parsing must reject inject against these slots earlier, at parse time, so the user sees the error pinned to the offending source line rather than a runtime registration throw. Reserved diagnostic ids for the 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 Wave 7 activity static-analysis warnings atSECS0410-0415.
The activity-args schema slot is keyed by ActivitySlotKind.ArgsSchema = 14 and 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.
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. Reject channel-based and value-based predicates inpropagates_to wherewithSECS0802. 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(EventContext),Execute(EventContext), optionalBuildOptions(EventOptions options, EventContext context), option handler methods takingEventContext, and staticEventEntry Registration. 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 priorSecsActionDeclarationSyntax) -> class deriving fromSecsActivity, usingActivityContextfor read-only methods (CanStart,IsTargetValid,Preview) andActivityRunContextfor command-producing lifecycle methods (OnStart,OnUpdate,OnComplete,OnStop,OnCancel,OnFail). 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:GetAiWeightwas deleted in Wave 4 — utility-AI scoring is handled byPolicyExecutoragainstEffectPlans, not by per-activity weight overrides. Old actionallow/effectblock syntax is prototype-only and must be rejected unless the design docs recommit it.
Hand-stand-in batch wrappers. The current
examples/valenar/Generated/Activities/Character/Site/CharacterSiteActivities.csuses a singleCharacterSiteActivitywrapper class that delegates to data-drivenCharacterSiteActivityDefinitionrecords, rather than emitting oneSecsActivitysubclass per activity. This is a legitimate hand-authoring optimization for content-heavy activity families with shared body shape; the future compiler may emit either shape. Both 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, PolicyRunContext) -> RuleDecisiondispatching by rule id.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 runtime registry diagnostics SECS0410-SECS0414 (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) —'action' keyword is removed; use 'activity'.Fires on top-levelaction Name { ... }declarations and onaction_*slot identifiers in mod operations. Replacement guidance is indocs/design/04-behavior.md § Activitiesanddocs/decisions/ADR-0002-behavior-vocabulary.md.SECS0031(error) —'program' keyword is removed; use 'policy'.Fires on top-levelprogram Name { ... }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, EventContext context), OnActionDeclaration[], SecsActivity, ActivityContext, ActivityRunContext, EffectPlan with PredictedEffect rows, IActivityArgs<TSelf> typed arg structs, SecsPolicy with NeedDeclaration / SelectorDeclaration / RuleDeclaration arrays and an EvaluateRule dispatch, and explicit FlushCommands() after command-producing source statements.
Phase 5: secsc CLI
Goal: Single binary wrapping the forked compiler with SECS-specific commands.
| Command | What it does |
|---|---|
secsc build --project Content/ | Parse → merge → lower → compile → Game.Content.dll |
secsc build --base Content/ --mods A,B --order A,B | Parse → merge in order → lower → compile → Game.Merged.dll |
secsc validate --base Content/ --mods A,B | Parse → merge → binder diagnostics → report (no DLL) |
secsc conflicts --base Content/ --mods A,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 --project Content/ | File watcher, re-validates on save |
Ships as a single self-contained binary — no .NET SDK required on the target machine.
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
Game Developer (no mods)
secsc build --project Content/
│
├── 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[] + ActivityMods[] + PolicyMods[] to the lowered trees
├── Roslyn binder + emitter → IL
└── Output: Game.Content.dll
Normal builds do not need to materialize .cs files. secsc --emit-generated-cs is a diagnostic/review mode that writes the lowered C# trees to disk so the output can be compared against examples/valenar/Generated/.
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/ --mods A,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/ --mods A,B --order A,B
│ 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.Engine.dll runtime engine (netstandard2.1)
├── SECS.Abstractions.dll shared types (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 CLI (self-contained, no SDK needed)
│ ├── 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.105 today). 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.
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.
- secsc distribution: Self-contained single binary vs dotnet tool. Same code either way, just packaging.
- 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 rename of the prior
actionkeyword) are committed, but high-level inspectable cost metadata is open indocs/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.