SECS Modding and Slot-Merge Architecture
Status: Design target. Compatibility: No backward compatibility required. Existing APIs, syntax, generated output, and docs may be rewritten. Core direction: SECS uses a Paradox-style layered content database with compiler-owned semantic slots, not runtime patching and not arbitrary C# member replacement.
SECS stands for Scripting Engine C Sharp.
This document defines the long-term modding and slot-merge model for SECS. It replaces the earlier idea of a single generic override Name { ... } keyword with a stronger Paradox-style operation model:
inject template Farm { ... }
replace template Farm { ... }
try inject template Farm { ... }
inject_or_create modifier GrainMarketBonus { ... }
The implementation is still AST-level, but not arbitrary text/AST patching. The compiler replaces known semantic slot nodes, then binds and lowers the merged result once into a final game DLL.
1. Core principles
1.1 .secs is C# plus SECS declarations
A .secs file is a C# compilation unit with additional SECS declaration syntax.
Plain C# is legal where plain C# is enough:
namespace Valenar;
public static class Cadence
{
public static readonly TickRate Daily = TickRate.Days(1);
public static readonly TickRate Weekly = TickRate.Days(7);
}
SECS syntax is used only for compiler-owned semantic declarations:
template<Building> Farm { ... }
modifier StableBonus { ... }
system GrowthSystem { ... }
event HarvestFestival { ... }
activity BuildRoad { ... }
scope Settlement { ... }
contract Building { ... }
The goal is not "make everything a keyword." The goal is:
Add syntax only where the compiler needs identity, merge rules, semantic slots, generated storage, lifecycle behavior, diagnostics, or lowering.
1.2 Modding is compile-time, not runtime
The final runtime should not know that mods exist.
The pipeline is:
base + official expansions + mods
→ resolve playset/load order
→ parse all .secs files
→ extract declarations and semantic slots
→ apply mod operations in order
→ produce merged AST / merged declaration IR
→ bind/type-check merged result
→ lower once
→ emit Game.Merged.dll
There is:
no runtime parsing
no runtime mod DLL patching
no reflection-based mod discovery
no per-mod runtime override tables
The merged game behaves like one compiled game.
The startup-finalization caveat. The "no per-mod runtime override tables" prohibition applies to the ticking game. SECS engines DO have a runtime merge step at startup: hosts call SecsRegistry.RegisterActivityMod / RegisterPolicyMod to accumulate mod patches, then FinalizeModRegistration() merges them once into a frozen view. After Finalize returns, the merged registry is read-only for the rest of the process, and the ticking game sees only the merged declarations — exactly as if it had been compiled into a single DLL. This startup-merge model is the committed permanent architecture, not a transitional stand-in. The Phase 3 compiler emits ActivityMod / PolicyMod registration calls for the shipped activity/policy subset, while the full source-set-aware semantic merger remains compiler-owned and must not be re-created as a generic SecsRegistry / ModRegistry runtime merger. See docs/adr/ad-0001-runtime-mod-finalization-boundary.md.
1.3 Mod operations are semantic-slot writes
The compiler should not patch arbitrary C# nodes.
Correct:
replace system_phase slot
replace template_field Farm.GoldCost
replace modifier_effect_bundle StableBonus.effects
replace event_option HarvestFestival.Accept
Incorrect:
search for property named Phase and replace its body
replace the third statement in a method
textually patch generated C#
The merger operates on known SECS slots.
1.4 Paradox-style layered content database
Mods are layered over the base game in load order.
Later mods win when they write the same slot.
Conflicts are reported, not errors by default.
base
official expansions / DLC
mod A
mod B
mod C
If Mod A and Mod C both patch Farm.GoldCost, Mod C wins because it loads later.
The conflict report records both writes so the mod manager can show the player what happened.
2. Source sets
A source set is one logical content layer.
Examples:
Base game
Official expansion / DLC
Third-party mod
Compatibility patch
Total conversion
Each source set contains:
Content/**/*.secs
localization files
assets
mod manifest
optional helper C# files if permitted
Source sets are processed independently first, then merged.
2.1 Host-capable source sets
Host-capable source sets are:
base game
official expansions / DLC
first-party content packs with engine/host support
They may add host-backed structures:
scope MercenaryGuild
{
walks_to Settlement;
int Reputation;
collection Contracts;
}
This is allowed only because host code can allocate storage, enumerate children, resolve walks, and implement scope methods.
2.2 Third-party data-only mods
Third-party mods are data-only under the core runtime model.
They may add or modify SECS content that targets the exported base/expansion API.
They may not add host-backed runtime structures:
scope NewHostScope { ... } // not allowed for data-only mods
scope Settlement { int NewField; } // not allowed
scope Feature { walks_to Realm; } // not allowed
They can add data content:
template<Building> Lighthouse { ... }
modifier CoastalTrade { ... }
system PirateRaidSystem { ... }
event SmugglerArrival { ... }
3. Operation model
The old single keyword:
override Farm { ... }
is replaced by explicit database operations.
Canonical operations:
create implicit normal declaration
inject partial slot patch
replace full declaration replacement
try inject patch only if target exists
try replace replace only if target exists
inject_or_create inject if exists, otherwise create
replace_or_create replace if exists, otherwise create
This is closer to Paradox-style modding, but stronger because SECS has a compiler and a real slot schema.
3.1 Create
A normal declaration creates a new object.
template<Building> Farm
{
field int GoldCost = 10;
field int WoodCost = 15;
}
If a declaration with the same identity already exists, this is an error:
SECS0602: duplicate declaration 'Farm'; use inject or replace
3.2 Inject
inject partially modifies an existing declaration by replacing specific semantic slots.
Base:
template<Building> Farm
{
field int GoldCost = 10;
field int WoodCost = 15;
query bool CanBuild()
{
return province.resolve(Stability) >= 50;
}
}
Mod:
inject template Farm
{
field GoldCost = 8;
}
Merged result:
template<Building> Farm
{
field int GoldCost = 8;
field int WoodCost = 15;
query bool CanBuild()
{
return province.resolve(Stability) >= 50;
}
}
Only the template_field:GoldCost slot changes.
Everything else is retained.
List-typed slots may define slot-specific inject behavior, but no template child-row slot is currently part of the committed template runtime surface.
3.3 Replace
replace replaces the entire declaration.
Base:
template<Building> Farm
{
field int GoldCost = 10;
field int WoodCost = 15;
query bool CanBuild()
{
return province.resolve(Stability) >= 50;
}
}
Mod:
replace template Farm
{
field int GoldCost = 6;
field int WoodCost = 8;
query bool CanBuild()
{
return true;
}
}
Merged result:
template<Building> Farm
{
field int GoldCost = 6;
field int WoodCost = 8;
query bool CanBuild()
{
return true;
}
}
The old declaration body is discarded.
3.4 Try inject
try inject patches an existing declaration if present.
try inject template Farm
{
field GoldCost = 8;
}
If Farm exists, this behaves like inject.
If Farm does not exist, the operation is ignored.
This is useful for compatibility patches that support multiple DLC/loadout combinations.
3.5 Try replace
try replace fully replaces an existing declaration if present.
try replace system OldGrowthSystem
{
phase = Phases.Maintenance;
frequency = Cadence.Weekly;
method void Execute()
{
}
}
If OldGrowthSystem does not exist, no error is emitted.
3.6 Inject or create
inject_or_create modifies a declaration if it exists; otherwise it creates it.
inject_or_create modifier GrainMarketBonus
{
translation = "Grain Market Bonus";
icon = "icons/modifiers/grain_market";
Income *= 110%;
}
If the target exists, the body is interpreted as an inject body.
If the target does not exist, the body must satisfy the full declaration requirements for that declaration kind.
3.7 Replace or create
replace_or_create fully replaces a declaration if present; otherwise creates it.
replace_or_create template<Building> Lighthouse
{
field int GoldCost = 100;
field int WoodCost = 40;
}
This is useful for total conversions and broad compatibility layers.
4. Declaration identity
Every declaration has a stable semantic identity.
Do not rely on file path or source order for identity.
4.1 Typed IDs
Use typed ID wrappers internally and in generated code.
Avoid raw ulong everywhere.
Preferred runtime/generator model:
public readonly record struct TemplateId(ulong Value);
public readonly record struct ModifierId(ulong Value);
public readonly record struct ChannelId(ulong Value);
public readonly record struct TagId(ulong Value);
public readonly record struct PhaseId(ulong Value);
public readonly record struct TickRateId(ulong Value);
public readonly record struct ScopeId(ulong Value);
public readonly record struct ContractId(ulong Value);
public readonly record struct SystemId(ulong Value);
public readonly record struct EventId(ulong Value);
public readonly record struct OnActionId(ulong Value);
// Behavior-layer IDs (alphabetical):
public readonly record struct ActivityId(ulong Value);
public readonly record struct ActivityLaneId(ulong Value);
public readonly record struct ActivityRunId(ulong Value);
public readonly record struct DomainId(ulong Value);
public readonly record struct NeedId(ulong Value);
public readonly record struct PolicyId(ulong Value);
public readonly record struct RuleId(ulong Value);
public readonly record struct SelectorId(ulong Value);
public readonly record struct SlotKindId(ulong Value);
The compiler and generated code should not allow accidental mixing of:
ChannelId
TagId
PhaseId
TemplateId
even if they all wrap ulong.
4.2 Hashing
Runtime IDs are still FNV-1a-64 values.
The hash input is a canonical ID string, not a display name. The format is committed (Wave 5):
<namespace>:<kind>/[<owner-or-domain>/]<name>
<namespace>is the source-set namespace (valenar, mod-local namespaces).<kind>is the SECS construct kind (template,contract,scope,channel,data,modifier,system,event,on_action,activity,policy,need,selector,rule,slot,domain,tag,phase,frequency,formula,recipe).<owner-or-domain>is the owning category. Omitted when the construct has no scope/owner.<name>is the declaration's snake_case name.
Examples:
valenar:template/building/farm
valenar:modifier/stable_bonus
valenar:phase/growth
valenar:channel/settlement/population
valenar:channel/character/attack
valenar:tag/food
better_farms:template/building/irrigated_farm
cautious_survival:rule/character_survival/flee_on_low_hp
Then:
TemplateId = FNV64("valenar:template/building/farm")
PhaseId = FNV64("valenar:phase/growth")
TagId = FNV64("valenar:tag/food")
ActivityId = FNV64("valenar:activity/recruit_garrison")
PolicyId = FNV64("valenar:policy/character_survival")
RuleId = FNV64("cautious_survival:rule/character_survival/flee_on_low_hp") // mod-local; owner segment names the policy
This prevents unrelated mods from accidentally colliding on common names like Market, Farm, Lighthouse, or Food.
The C# typed-id wrappers (TemplateId, ChannelId, TagId, ActivityId, PolicyId, NeedId, SelectorId, RuleId, SlotKindId, DomainId) all expose .Create(string canonicalId) factories that hash the canonical id string via FNV-1a-64. Empty / whitespace strings throw — there is no silent zero-id fallback. DomainId.Create("valenar:domain/survival") was added in Wave 5b alongside the canonical-string migration so engine-test code and any runtime surface that round-trips a domain id from its canonical string can produce the typed wrapper without hashing inline. Compiler-emitted Generated code may still construct new DomainId(H.Domain_Survival) directly when the canonical string is already hashed at compile time.
4.3 Source aliases
Source code can still use short names when unambiguous:
inject template Farm
{
field GoldCost = 8;
}
The compiler resolves Farm through the exported SDK/identity table.
If ambiguous:
SECS0610: declaration name 'Farm' is ambiguous; use a qualified identity
Qualified target examples:
inject template valenar::Farm
{
field GoldCost = 8;
}
or, if string IDs are allowed:
inject template "valenar:template/farm"
{
field GoldCost = 8;
}
The exact syntax can be finalized later, but the architecture should support canonical IDs.
4.4 Collision handling
FNV-1a-64 collisions are errors.
SECS0601: hash collision between 'valenar:template/farm' and 'other:template/x'
Collisions must never silently shadow.
5. .secs language model
A .secs file may contain:
ordinary C# declarations
SECS declarations
SECS operation declarations
using directives
namespace declarations
Example:
namespace Valenar;
using SECS.Abstractions.Pipeline;
public static class Phases
{
public static readonly PhaseDeclaration Growth =
PhaseDeclaration.Create("valenar:phase/growth", SystemPhase.Main, 4);
public static readonly PhaseDeclaration Maintenance =
PhaseDeclaration.Create("valenar:phase/maintenance", SystemPhase.Post, 1);
}
And in another .secs file:
namespace Valenar;
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
// system logic
}
}
Plain C# source declarations should live in the source namespace, not in generated namespaces.
Correct:
namespace Valenar;
Incorrect for source content:
namespace Valenar.Generated;
Generated is reserved for compiler output.
6. System declaration design
A system is a closed semantic declaration.
It is not an arbitrary C# class body.
Canonical base form:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
// normal C# / SECS logic
}
}
Canonical inject form:
inject system GrowthSystem
{
phase = Phases.Maintenance;
}
Canonical replace form:
replace system GrowthSystem
{
phase = Phases.Maintenance;
frequency = Cadence.Weekly;
method void Execute()
{
// replacement logic
}
}
6.1 Allowed members inside system
Only these members are allowed in Phase 1:
phase = <phase expression>;
frequency = <frequency expression>;
method void Execute() { ... }
These map to:
system_phase
system_frequency
system_execute_body
6.2 Not allowed inside system
Do not allow arbitrary system-level C# members:
public PhaseId Phase => Phases.Growth.Id; // no
public int Frequency => Cadence.Daily; // no
private int cache; // no
public string DebugName => "Growth"; // no
GrowthSystem() { } // no
Reason:
system is a content declaration with known merge slots
not an open C# class
If helper code is needed, write normal C# outside the system:
namespace Valenar;
public static class GrowthSystemHelpers
{
public static bool ShouldGrow(int population)
{
return population > 1000;
}
}
Then call it inside Execute:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
int population = resolve(Population);
if (GrowthSystemHelpers.ShouldGrow(population))
{
// logic
}
}
}
6.3 Why phase = ... instead of phase Growth;
Use assignment syntax:
phase = Phases.Growth;
frequency = Cadence.Daily;
instead of:
phase Growth;
frequency Daily;
Reasons:
it reads as slot assignment
it matches tags = ...
it matches track_prev = true
it supports qualified C# expressions naturally
it avoids confusing system phase assignment with top-level phase declaration
6.4 System lowering
Source:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
}
}
Generated target:
public sealed class GrowthSystem : ITickSystem
{
public PhaseId Phase => Phases.Growth.Id;
public TickRateId Frequency => Cadence.Daily.Id;
public void Execute(TickContext ctx)
{
}
}
If raw hashes are temporarily retained:
public ulong Phase => Phases.Growth.Hash;
public int Frequency => Cadence.Daily;
But the long-term model should use typed IDs.
7. AST-level merge model
Mod operations happen at AST level, but through semantic slots.
Base:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
RunGrowth();
}
}
Mod:
inject system GrowthSystem
{
phase = Phases.Maintenance;
}
Merged AST:
system GrowthSystem
{
phase = Phases.Maintenance;
frequency = Cadence.Daily;
method void Execute()
{
RunGrowth();
}
}
Then the normal binder/lowerer runs.
The merger does not patch generated C#.
The merger does not search arbitrary C# class members.
The merger replaces the known system_phase AST node.
7.1 Correct implementation shape
Parse .secs
→ build SECS AST
→ extract declaration IR
→ extract semantic slots
→ build slot registry
→ apply operations
→ produce merged AST / merged IR with source provenance
→ bind/type-check
→ lower
→ emit Game.Merged.dll
7.2 Slot payload
Every slot write stores:
source set id
load order
source path
source span
declaration identity
declaration kind
slot kind
slot child identity if any
syntax node payload
typed semantic payload
The syntax node is needed for source mapping.
The typed semantic payload is needed for merge, validation, conflict reporting, and lowering.
7.3 Slot key
Do not combine hashes with XOR.
Use a structured key.
public readonly record struct SlotKey(
DeclarationKind DeclarationKind,
ulong DeclarationId,
SlotKind SlotKind,
ulong? ChildId);
Examples:
(system, GrowthSystem, system_phase, null)
(system, GrowthSystem, system_frequency, null)
(system, GrowthSystem, system_execute_body, null)
(template, Farm, template_field, GoldCost)
(template, Farm, template_dynamic_channel_formula, FoodOutput)
(modifier, StableBonus, modifier_effect_bundle, null)
(modifier, StableBonus, modifier_tags, null)
This is more correct than:
hash(parent) XOR hash(child)
7.4 Lifecycle slot pairing is not a merger concern
Ordinary modifier lifetime is not an override-pairing problem. A template
that attaches settlement.add_modifier(HouseMoralePresence) in OnBuilt
creates a binding owned by that template instance. If a mod injects or
replaces OnBuilt and attaches HouseMoralePresenceV2 instead, the new
binding is still owned by the instance and is still removed by owner cleanup
when the instance is destroyed. There is no inherited OnDestroyed remove
site required for the normal "building owns modifier on settlement" pattern.
Current rule: the merger does NOT emit a generic OnBuilt / OnDestroyed
pairing warning for modifier attachments. Pairing checks are deferred until
a source construct explicitly declares a semantic pair (e.g. an early
remove_modifier in OnCleared meant to undo a specific OnSpawned effect
before the entity dies). At that point the compiler can analyze the concrete
add/remove pair instead of warning on hook names alone.
Why this shape: the old explicit-pair design made partial overrides dangerous — a mod could override the add half and inherit a stale remove half. The committed owner/target lifecycle removes that failure mode. Warnings should now focus on real source-level dependencies, not on lifecycle names that merely look paired.
Open question: whether a future contract syntax should support explicit
paired hooks (paired OnSpawned OnCleared;) for custom non-destruction
workflows. If it lands, diagnostics should be based on declared pairs and
concrete add/remove identities, not a hard-coded OnBuilt / OnDestroyed
assumption.
8. Slot schema
Every declaration kind has a schema.
The schema defines:
allowed members
slot identifiers
slot cardinality
base declaration requirements
inject requirements
replace requirements
merge rule
diagnostics
lowering target
conflict-report kind
Adding a new language feature should usually mean adding a new schema entry, not writing one-off merge code.
8.1 System slots
| Slot | Syntax | Merge rule |
|---|---|---|
system_phase | phase = Phases.Growth; | per-field replacement |
system_frequency | frequency = Cadence.Daily; | per-field replacement |
system_execute_body | method void Execute() { ... } | full body replacement |
Base system must contain:
phase
frequency
Execute
Inject system may contain any subset, but must contain at least one slot.
Replace system must contain all required base slots.
8.2 Template slots
Base:
template<Building> Farm
{
name = "Farm";
field int GoldCost = 10;
field int WoodCost = 15;
tags = Tags.Food, Tags.Perishable;
channel int FoodOutput
{
return (int)(5 * location.Fertility / 33);
}
query bool CanBuild()
{
return province.resolve(Stability) >= 50;
}
modifier LocalFertilityBonus
{
FoodOutput *= 110%;
}
}
Slots:
| Slot | Syntax | Merge rule |
|---|---|---|
template_name_metadata | name = "Farm"; | per-field replacement |
template_tags | tags = Tags.Food, Tags.Perishable; | full list replacement |
template_field | field int GoldCost = 10; | per-field replacement |
template_intrinsic_stat | channel int Income = 6; | per-channel replacement |
template_dynamic_channel_formula | channel int FoodOutput { ... } | full formula body replacement |
template_query_method | query bool CanBuild() { ... } | full body replacement |
template_method | method void OnBuilt() { ... } | full body replacement |
template_scoped_modifier | modifier LocalBonus { ... } | full modifier replacement |
template_transition_edge | replaces OldTemplate { requires Q; } | per-edge replacement/addition |
Inject example:
inject template Farm
{
field GoldCost = 8;
channel FoodOutput
{
return (int)(7 * location.Fertility / 33);
}
}
In inject bodies, do not repeat immutable declaration types unless required.
Prefer:
field GoldCost = 8;
over:
field int GoldCost = 8;
Reason: the mod is replacing a value slot, not redeclaring the field type.
8.2.1 Deferred intrinsic composition rows
No child / template-child-row operation is committed in this design pass.
Valenar currently uses create_entity inside a lifecycle method body for
runtime entity creation. If a declarative intrinsic-composition slot returns
later, its merge keys, row provenance, duplicate validation, and conflict
reporting must be specified in a fresh ADR rather than inferred from the
method-body create_entity statement.
8.3 Modifier slots
Base:
modifier StableBonus
{
translation = "Stable Province";
icon = "icons/modifiers/stable";
stacking = Reject;
max_stacks = 1;
tags = Tags.Economic, Tags.Buff;
propagates_to children where has_contract Settlement;
Income *= 120%;
Manpower *= 110%;
field GoldCost *= 95%;
}
Slots:
| Slot | Syntax | Merge rule |
|---|---|---|
modifier_translation | translation = "..."; | per-field replacement |
modifier_icon | icon = "..."; | per-field replacement |
modifier_stacking | stacking = Reject; | per-field replacement |
modifier_reapply | reapply = ...; | per-field replacement |
modifier_decay | decay = ...; | per-field replacement |
modifier_max_stacks | max_stacks = 1; | per-field replacement |
modifier_tags | tags = Tags.Economic; | full list replacement |
modifier_effect_bundle | channel/field effects and propagation clauses | full bundle replacement |
Effect bundle replacement is full replacement, not partial merge.
Base:
modifier StableBonus
{
Income *= 120%;
Manpower *= 110%;
}
Inject:
inject modifier StableBonus
{
Income *= 140%;
}
Merged:
modifier StableBonus
{
Income *= 140%;
}
Manpower *= 110% is removed because the effect bundle is one full-replacement slot.
This avoids ambiguous partial effect merging.
8.4 Event slots
Current doc 04 event lowering uses explicit BuildOptions(EventOptions options, EventContext context) methods with options.Add("Label", Handler) calls. The option { ... } source syntax below is a proposed/future slot-friendly spelling for the same option model; it is not committed compiler source syntax until doc 04 adopts it and the compiler/runtime/generated tests land together. Until then, event option mod operations target the generated BuildOptions/handler model, not a standalone option keyword.
Base:
event HarvestFestival
{
trigger = OnYearlyPulse;
frequency = Cadence.Yearly;
chance = 20;
priority = 10;
weight = 100;
query bool Condition()
{
return settlement.resolve(Food) > 100;
}
option Celebrate
{
text = "Celebrate";
method void Execute()
{
settlement.add_modifier(FestivalJoy);
}
}
option Refuse
{
text = "Refuse";
method void Execute()
{
}
}
}
Slots:
| Slot | Syntax | Merge rule |
|---|---|---|
event_trigger | trigger = OnYearlyPulse; | per-field replacement |
event_frequency | frequency = Cadence.Yearly; | per-field replacement |
event_chance | chance = 20; | per-field replacement |
event_priority | priority = 10; | per-field replacement |
event_weight | weight = 100; | per-field replacement |
event_condition_method | query bool Condition() { ... } | full body replacement |
event_execute_body | method void Execute() { ... } | full body replacement |
event_option | option Celebrate { ... } | per-option replacement/addition |
event_option_execute_body | option X { method void Execute() { ... } } | full body replacement |
event_option_text | option X { text = "..."; } | per-field replacement |
Inject:
inject event HarvestFestival
{
option Celebrate
{
text = "Celebrate lavishly";
method void Execute()
{
settlement.add_modifier(GreatFestivalJoy);
}
}
}
If adopted, the proposed syntax would be cleaner than analyzing:
options.Add("Celebrate", Celebrate);
inside arbitrary method bodies.
8.6 Scope slots
Scopes are host-backed.
Host-capable source sets may add:
scope Feature
{
walks_to Location;
walks_to Province;
}
Official expansion:
scope Feature
{
walks_to MercenaryGuild;
}
Merged:
scope Feature
{
walks_to Location;
walks_to Province;
walks_to MercenaryGuild;
}
Scope additions are additive, not overrides.
Third-party data-only mods cannot add:
new scopes
scope fields
scope collections
scope methods
walks_to edges
because these require host support.
8.7 Contract slots
Contracts define runtime API surfaces.
Contracts are not patchable by third-party mods.
A mod cannot add methods or queries to an existing base contract.
If a mod needs a new API surface, it declares a new contract and a new template hierarchy.
8.8 Type declarations
Types are additive but closed under inject/replace for existing exported types.
Allowed:
record FeaturePlacementInput
{
int Priority;
bool AllowWater;
}
Not allowed:
inject record FeaturePlacementInput
{
int NewField;
}
Changing exported types is an ABI break.
8.10 Activity slots
Activities (SecsActivity) are reusable AI/player-initiated workflows declared
in Content/activities/**/*.secs and lowered to a sealed SecsActivity
subclass per 04-behavior.md § Activities. Activity slots are the surface mods
target when injecting or replacing an activity declaration.
The activity-tag example below uses hypothetical activity classification tags. They must be declared as plain C# vocabulary and collected into the registry-backed tag catalog before the activity can reference them:
namespace ExampleMod;
public static class ActivityTags
{
public static readonly TagId Recon =
TagId.Create("example:tag/recon");
public static readonly TagId Routine =
TagId.Create("example:tag/routine");
}
Base:
activity ScoutNearbyLead
{
actor character;
target location;
field int DurationTicks = 4;
field int Cooldown = 0;
field string Description = "Investigate a lead.";
field string Icon = "scout";
tags = ActivityTags.Recon, ActivityTags.Routine;
cost stamina 1;
query bool IsVisible() { ... }
query bool IsTargetValid() { ... }
query bool CanStart() { ... }
query int GetDurationTicks() { ... }
query EffectPlan Preview() { ... }
method void OnStart(ActivityRun run) { ... }
method void OnUpdate(ActivityRun run) { ... }
method void OnComplete(ActivityRun run) { ... }
method void OnStop(ActivityRun run) { ... }
method void OnCancel(ActivityRun run) { ... }
method void OnFail(ActivityRun run) { ... }
}
Slots (★ marks replace-only architectural slots — closed under inject; see "Architectural slots" note below the table):
| Slot | Syntax | Merge rule |
|---|---|---|
activity_actor_scope ★ | actor character; | replace-only (architectural) |
activity_target_scope ★ | target location; | replace-only (architectural) |
activity_lane ★ | lane = ActivityLanes.Primary; | replace-only (architectural) |
activity_args_schema ★ | activity Build(BuildArgs args) (typed-args header) | replace-only (architectural) |
activity_duration | field int DurationTicks = 4; | per-field replacement |
activity_cooldown | field int Cooldown = 7; | per-field replacement |
activity_description | field string Description = "..."; | per-field replacement |
activity_icon | field string Icon = "scout"; | per-field replacement |
activity_source | field string Source = "..."; | per-field replacement |
activity_group | field string Group = "..."; | per-field replacement |
activity_sort_order | field int SortOrder = 10; | per-field replacement |
activity_wire_id | field string WireId = "..."; | per-field replacement |
activity_tags | tags = ActivityTags.Recon, ActivityTags.Routine; | full list replacement |
activity_costs | cost stamina 1; rows (runtime committed; .secs source syntax deferred — see FUTURE_WORK.md § "cost X N; authoring syntax") | full list replacement |
activity_is_visible_body | query bool IsVisible() { ... } | full body replacement |
activity_is_target_valid_body | query bool IsTargetValid() { ... } | full body replacement |
activity_can_start_body | query bool CanStart() { ... } | full body replacement |
activity_duration_body | query int GetDurationTicks() { ... } | full body replacement |
activity_preview_body | query EffectPlan Preview() { ... } | full body replacement |
activity_on_start_body | method void OnStart(...) { ... } | full body replacement |
activity_on_update_body | method void OnUpdate(...) { ... } | full body replacement |
activity_on_complete_body | method void OnComplete(...) { ... } | full body replacement |
activity_on_stop_body | method void OnStop(...) { ... } | full body replacement |
activity_on_cancel_body | method void OnCancel(...) { ... } | full body replacement |
activity_on_fail_body | method void OnFail(...) { ... } | full body replacement |
Architectural slots (replace-only). activity_actor_scope, activity_target_scope,
activity_lane, and activity_args_schema are closed under inject — Wave 6 treats them
as architectural identifiers. A mod that wants to retarget an activity at a different actor
scope, target scope, concurrency lane, or typed-args schema must use replace (or
replace_or_create), which is the correct semantic act: the resulting activity is a
different activity in everything but name. Inject (and try inject / inject_or_create)
against any of these four slots emits SECS0801 (InjectClosedSlot) at registration with
Severity.Error, mirroring the policy-side rule for policy_actor_scope / policy_domain
in § 8.11. The runtime-side enforcement lives in ModRegistry.RegisterActivityMod.
Save-load interaction (Wave 8). A mod that replaces any architectural slot on an
activity (or on a policy — policy_actor_scope, policy_domain) effectively invalidates
any saved ActivityRunState that references the previous identity. The replace operation
changes the activity's args schema, lane, or actor/target scope, and the saved run will hit
SECS0810/SECS0811/SECS0812 on ActivityRunStore.Restore. There is no engine-side
args-migration callback: hosts MUST run a migration pass after
SecsRegistry.FinalizeModRegistration() and before resuming the loop, walking saved runs
against the merged registry and cancelling orphans before they reach Restore. See
04-behavior.md § Activity run save/load migration for the diagnostic codes and the
host-side response.
Inject example — change a single field and replace the preview body:
inject activity ScoutNearbyLead
{
field DurationTicks = 6;
query EffectPlan Preview()
{
var dur = GetDurationTicks();
return EffectPlan.From(
PredictedEffect.Field(actor, H.Stamina, PredictionOp.Add, -2),
PredictedEffect.Field(target, H.LeadProgress, PredictionOp.Add, 30 * dur, durationTicks: dur));
}
}
Replace example — full declaration replacement:
replace activity ScoutNearbyLead
{
actor character;
target location;
field int DurationTicks = 8;
query bool IsVisible() { return true; }
query bool IsTargetValid() { return true; }
query bool CanStart() { return true; }
query EffectPlan Preview() { return EffectPlan.Empty; }
}
Required slots in a replace: activity_actor_scope plus at least one
lifecycle body (typically activity_on_start_body or activity_preview_body).
A replace that omits activity_actor_scope emits SECS0611 because the
runtime cannot validate actor binding without it.
Slot identity at the runtime layer:
activity.<ActivityId>.<slot>
e.g. activity.scout-nearby-lead.preview-body
e.g. activity.scout-nearby-lead.tags
The compiler stores these as SlotKey(DeclarationKind.Activity, ActivityId, SlotKind.<name>, child=null). The runtime mod registry hashes the same
canonical name with FNV-1a-64 to address per-slot writes.
activity_costs and activity_tags are full list replacement slots:
injecting either replaces the entire list. To extend a list, inject the union
manually (the compiler can offer a future extend tags shorthand;
syntax unresolved and deferred until usage demands it).
8.11 Policy slots
Policies (SecsPolicy) are compile-time AI decision logic declared in
Content/policies/*.secs and lowered to a sealed SecsPolicy subclass per
04-behavior.md § Policies and 09-ai-policies-and-activities.md. Policy slots are the
surface mods target when injecting or replacing a policy declaration.
Base:
policy CharacterSurvival
{
actor Character;
domain Survival;
need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}
selector RestSelector
{
consider activity RestAtCamp from all_registered;
}
rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}
}
Slots (★ marks replace-only architectural slots — closed under inject):
| Slot | Syntax | Merge rule |
|---|---|---|
policy_actor_scope ★ | actor Character; | replace-only (architectural) |
policy_domain ★ | domain Survival; | replace-only (architectural) |
policy_needs | need <Name> { ... } rows | per-need-id replacement/addition |
policy_selectors | selector <Name> { ... } rows | per-selector-id replacement/add |
policy_rules | rule Decision <Name>() { ... } rows | per-rule-id replacement/addition |
policy_evaluate_rule_body | the body of a single rule method | full body replacement (child=ruleId) |
Each child-keyed slot resolves to:
policy.<PolicyId>.needs.<NeedId>
policy.<PolicyId>.selectors.<SelectorId>
policy.<PolicyId>.rules.<RuleId>
policy.<PolicyId>.evaluate-rule-body.<RuleId>
Per-row writes carry the row's typed id as ChildId in the structured
SlotKey shape from § 7.3. A mod adding a new need writes a child slot whose
id does not exist in the base catalog; the merger appends. A mod replacing an
existing need writes the same child id; the merger replaces.
A NeedDeclaration is an immutable record. To change a single field of an
existing need, the mod's inject body must spell the full need (the merger
substitutes the whole record at the child slot). The compiler may provide a
sugar that re-emits the missing fields from the base when the inject body is
partial; this lowering decision is owned by the compiler bring-up and not
locked here.
Inject example — add a new need and tighten an existing need's threshold:
inject policy CharacterSurvival
{
need WatchForLoot
{
channel = LootScore;
target = 100;
curve = linear;
threshold = 25;
weight = 40;
}
need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 65; // tightened from 50
weight = 100;
}
}
Inject example — append a rule whose body lives in the policy's
EvaluateRule dispatch:
inject policy CharacterSurvival
{
rule Decision FleeOnLowHp()
{
if (actor.resolve(HP_Current) * 5 < actor.resolve(HP_Max))
return call_best(FleeSelector);
return continue;
}
}
Replace example — full policy replacement:
replace policy CharacterSurvival
{
actor Character;
domain Survival;
need StayAlive { ... }
selector RestSelector { ... }
rule Decision EmergencyRest() { ... }
}
Required slots in a replace: policy_actor_scope, policy_domain. The
needs / selectors / rules lists may be empty but must be present (an empty
list is a legitimate authored choice — a policy with no rules is a no-op).
policy_actor_scope and policy_domain are closed under inject — Wave 6
treats them as architectural identifiers. A mod that wants to retarget a
policy at a different scope or domain must use replace, which is the
correct semantic act (it is a different policy in everything but name).
The runtime mod registry consumes per-row writes deterministically: for a
list-with-children slot (policy_needs, policy_selectors, policy_rules),
existing-id writes replace the row at its current list position; new-id
writes append at the end. Load order is the tiebreaker when two mods write
the same row.
Mod-owned rule / need / selector ids (Wave 5)
Mod-introduced rule, need, and selector ids live in the mod file (NOT in the
base game's Generated/Hashes.cs) and quote the mod's own source-set
namespace as the canonical-id prefix. The source-set name is the mod's
top-level directory under Content/mods/<source_set>/; a mod under
Content/mods/cautious_survival/ uses cautious_survival: as its namespace.
Examples:
cautious_survival:rule/character_survival/flee_on_low_hp
better_farms:need/character_survival/keep_warehouse_stocked
demonic_pacts:selector/character_survival/preferred_offerings
Wave 5b extended the mod-local convention to require the policy-owner segment
between <kind> and <name> (e.g. character_survival above) so a mod that
defines a FleeOnLowHp rule for one policy never collides with another mod's
identically-named rule on a different policy.
The mod's Generated stand-in (examples/valenar/Generated/Mods/<Name>Mod.cs)
declares the constant locally:
// Wave 5b — mod-owned rule ids live in the mod file (NOT in Hashes.cs),
// quote the mod's own source-set namespace, and carry the policy-owner
// segment so cross-mod collisions are impossible by construction.
public const ulong Rule_FleeOnLowHp = 0x3DDA17451A5FC5B7UL; // FNV("cautious_survival:rule/character_survival/flee_on_low_hp")
Engine-shipped rules (those declared inside a base-game policy { ... } body)
continue to live in Generated/Hashes.cs and use the base-game's valenar:
namespace. The base-game H.Rule_* and the mod-local Rule_* constants are
therefore non-overlapping by construction — colliding hashes would surface as
a registry conflict and abort loading.
8.9 What's NOT overridable
The slot schema in § 8.1–8.8 enumerates what mods CAN modify. The list below
enumerates the closed surfaces that mods CANNOT modify with any operation
(inject / replace / try / or_create). Phase 3 compiler rejects all
of the following with a binder diagnostic before merge even runs.
- Channel declarations — a mod cannot change an existing channel's
ChannelKind,ValueType, orMin/Maxconstraints. It may declare a new channel, but not alter the type machinery of an existing one. Reason: the resolution pipeline relies on type-stable channel declarations; flipping aContributed intto aBase floatmid-session breaks modifier arithmetic. - Template field declarations — a mod cannot change an existing field's
scalar type or clamps. It may override a template's value for that field
via the
template_fieldslot (inject template Farm { field GoldCost = 8; }) and provide localization entries for display text, but the global field declaration's type contract is closed like a channel declaration. Mods may declare new field names when they need new template data. - SECS type declarations — a mod cannot change an existing struct,
record, or enum declaration. It may declare new value types for new
content, but it may not alter the field list or enum members of a type
already exported by base or an enabled official expansion. Existing
callable and field metadata may already reference that generated
SecsTypeRef; changing the declaration would be an ABI break. - Contract definitions — a mod cannot alter a contract's
root_scope, query list, void-method list, or lifecycle bindings such asactivation. Contracts are the runtime's API surface; mutating them would break the binding between the engine's dispatch tables, generic contract-call APIs, and the host's C# hooks. Mods also cannot add new queries or methods to existing base contracts; new query surfaces or lifecycle hooks require declaring a new contract and a new template hierarchy. SecsScalarTypeenum — closed atint / long / float / double / boolper01-world-shape.md. Achannel decimal Treasury { … }declaration in a mod emitsSECS0102before merge runs.SecsTypeRefcallable signatures — a mod may override a query or method body, but the parameter and returnSecsTypeRefs are fixed by the declaring contract/scope. ChangingFeature.EvaluatePlacement(Location, FeaturePlacementInput):FeaturePlacementResultto returnboolor acceptsettlementisSECS0705, not an overload.EffectModeenum — closed atAdditive / Multiplicative / HardOverrideper03-channels-and-modifiers.md. Mods cannot introduce a new effect mode; new modes require an engine + compiler release.ModifierEffectTargetKindenum — valid effect target families are closed atChannel / TemplateFieldper03-channels-and-modifiers.md;Unknownis the invalid runtime sentinel and is never emitted. Mods may target existing channel or field declarations, but they cannot invent a new effect target family.SystemSourceenum — closed atSecs / Host. Mods declare onlySystemSource.Secssystems (see04-behavior.md § "SystemRegistration"mod scope note).EventSelectionModeenum — closed atAll / FirstValid / WeightedRandomper04-behavior.md. Mods cannot define new selection algorithms.- Phase ordering array — mods may declare new
PhaseDeclarationvalues in their own static class, but the host owns the ordered phase-id array passed to the pipeline; mods cannot append or reorder it from.secs(see § 9.2 above).mod.json phase_orderis DEFERRED. - Host-backed scope surfaces — third-party mods cannot add scopes, scope
fields, collections,
walks_toedges, or scope methods because those declarations require host allocation, child enumeration, walk resolution, or method implementations. Base game and official expansions may add them as host-capable source sets per § 2.1. provides scope:Xon on-actions — closed slot on the declaring on-action; mods cannot extend a base on-action'sprovideslist because every firing site would silently fail to save the new scope. Mods that need new saved scopes declare a new on-action.- Bare template synthesis rule — for every
scopedeclared (base or mod), the compiler auto-emits oneBareXtemplate; mods may not declare a hand-writtenBareXto compete with the synthesis. They mayinject template BareX { ... }to change defaults (e.g., startingSlots), which is a per-field slot replacement. - Built-in sigils (
this,root,prev,owner,host) — closed five-name set baked into the compiler. Mods may declare new game scope sigils viascopedeclarations but cannot redefine the five built-ins, cannot makethisavailable in creation-time query calls, and cannot replace the contractroot_scopeframe with ad hoc world parameters.
9. Phase, cadence, tags, and previous tick
These are special because they look like language features but are mostly data.
9.1 Phase declarations
Top-level phase declarations should not use SECS keyword syntax by default.
Prefer source-side C#:
namespace Valenar;
using SECS.Abstractions.Pipeline;
public static class Phases
{
public static readonly PhaseDeclaration Production =
PhaseDeclaration.Create("valenar:phase/production", SystemPhase.Main, 1);
public static readonly PhaseDeclaration Growth =
PhaseDeclaration.Create("valenar:phase/growth", SystemPhase.Main, 4);
public static readonly PhaseDeclaration Maintenance =
PhaseDeclaration.Create("valenar:phase/maintenance", SystemPhase.Post, 1);
public static readonly PhaseDeclaration[] All =
{
Production,
Growth,
Maintenance,
};
}
System usage is still a SECS slot:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
}
}
Reason:
top-level phase data is host/registry data
system phase assignment is an override slot
Do not use:
phase Growth : Main
{
order = 4;
}
unless phases become first-class SECS declarations with source-level inject/replace semantics.
9.2 Third-party mods and phases
Because phase ordering is scheduler-owned, third-party data-only mods should not be allowed to add arbitrary new phases unless there is a declared loadout-level phase-order mechanism.
Allowed:
inject system ExistingSystem
{
phase = Phases.Maintenance;
}
Not allowed for ordinary data-only mods unless explicitly supported:
public static readonly PhaseDeclaration MyNewPhase = ...
and then using it in a system when the scheduler has no order entry for it.
Host-capable expansions may add phases because they can also update host scheduling configuration.
Future feature:
{
"phase_order": {
"insert_after": "valenar:phase/growth",
"phase": "my_mod:phase/new_phase"
}
}
This is deferred unless phase extension is a core modding requirement.
9.3 Cadence / tick rates
Avoid const for gameplay cadence values if they may ever be replaced or resolved dynamically.
Bad for moddable gameplay values:
public const int Daily = 1;
Better:
public static readonly TickRate Daily = TickRate.Days(1);
public static readonly TickRate Weekly = TickRate.Days(7);
System usage:
system GrowthSystem
{
frequency = Cadence.Weekly;
method void Execute()
{
}
}
If cadence values are purely compile-time constants and never globally overridden, const is acceptable. For a modding-oriented engine, prefer static readonly or typed TickRateId.
9.4 Tags
Tags are registry-backed identity atoms.
Mods can add new tags, but should not replace existing tag identities. Plain TagId constants are typed identity helpers in source and generated code; they are not a substitute for registration. Generated output must register collected tag ids/names with the registry, and analyzers/static validation check tags = ..., has_tag ..., activity tags, and policy selector tags against that registry-backed catalog.
Prefer C# or compiler-collected tag IDs:
namespace Valenar;
public static class Tags
{
public static readonly TagId Food =
TagId.Create("valenar:tag/food");
public static readonly TagId Perishable =
TagId.Create("valenar:tag/perishable");
}
Usage:
template<Resource> Wheat
{
tags = Tags.Food, Tags.Perishable;
}
or, if the language supports tag aliases:
template<Resource> Wheat
{
tags = Food, Perishable;
}
template_tags and modifier_tags are slot-merge targets.
Both are full-list-replacement slots; there is no committed bracket-list
syntax for tag assignments.
Alias forms must still bind to declared catalog entries. An unknown tag
alias is SECS0212, not an implicit tag declaration.
Top-level tag Food; is not committed source syntax. If the language later wants explicit tag declarations for tooling, that future design must preserve the registry-backed validation contract rather than reintroducing no-registry tags.
9.5 Previous tick reads
Do not make prev_tick a keyword by default.
If a normal API call is committed in the future, it should look like:
ctx.PrevTick(Channels.Population)
or:
ctx.ReadPrevious(Channels.Population)
The compiler/analyzer can recognize well-known APIs for dependency analysis if needed.
Use special syntax only if previous-tick reads become part of a broader SECS query DSL.
10. Merge rules
10.1 Load order
Load order is resolved by the launcher.
The compiler receives a linear order:
base = 0
official expansion A = 1
official expansion B = 2
mod A = 3
mod B = 4
mod C = 5
Later writes win.
10.2 Base and official expansions
The effective base layer is:
base game + enabled official expansions
Official expansions may be host-capable.
Third-party mods are applied after the effective base.
10.3 Create rule
Normal declaration:
template<Building> Farm { ... }
If identity does not exist:
create succeeds
If identity exists:
SECS0602 duplicate declaration
10.4 Inject rule
inject template Farm { ... }
If target exists:
apply listed slot writes
If target missing:
SECS0603 target missing
10.5 Replace rule
replace template Farm { ... }
If target exists:
replace full declaration
If target missing:
SECS0603 target missing
10.6 Try operations
try inject template Farm { ... }
try replace template Farm { ... }
If target exists, apply operation.
If target missing, no-op.
No diagnostic unless strict mode chooses to warn.
10.7 Or-create operations
inject_or_create template Farm { ... }
replace_or_create template Farm { ... }
If target exists, apply operation.
If target missing, create new declaration.
For create path, the body must satisfy full declaration requirements.
10.8 Conflicts
A conflict exists when two or more mods write the same slot.
Base writes do not count as conflicts by themselves.
Example:
// Mod A
inject template Farm
{
field GoldCost = 8;
}
// Mod B
inject template Farm
{
field GoldCost = 12;
}
If Mod B loads later:
final Farm.GoldCost = 12
Conflict report records both writes.
Conflicts are not compiler errors by default.
--strict-conflicts turns them into errors.
11. Conflict report
The merger emits a JSON report.
Example:
{
"conflicts": [
{
"identity": "valenar:template/farm.field/gold_cost",
"declaration": "Farm",
"declarationKind": "template",
"slot": "template_field",
"field": "GoldCost",
"baseValue": 10,
"writes": [
{
"modId": "better_farms",
"loadOrder": 3,
"operation": "inject",
"value": 8,
"sourcePath": "mods/BetterFarms/Content/farm.secs",
"sourceLine": 4
},
{
"modId": "expensive_buildings",
"loadOrder": 5,
"operation": "inject",
"value": 12,
"sourcePath": "mods/ExpensiveBuildings/Content/farm.secs",
"sourceLine": 6
}
],
"winner": {
"modId": "expensive_buildings",
"value": 12
}
}
]
}
The report should also support non-conflict diagnostics and dependency cycles:
{
"conflicts": [],
"cycles": [],
"diagnostics": []
}
12. Diagnostics
Canonical diagnostics:
| Code | Meaning |
|---|---|
SECS0601 | hash collision between distinct canonical identities |
SECS0602 | duplicate declaration without inject/replace |
SECS0603 | inject/replace target missing |
SECS0604 | operation kind not valid for declaration kind |
SECS0605 | unknown slot in inject body |
SECS0606 | attempt to override/additive-closed scope edge through replacement |
SECS0607 | walks_to references undeclared scope |
SECS0608 | expansion references source set not in load-order ancestry |
SECS0609 | data-only mod declares host-backed surface |
SECS0610 | ambiguous short identity reference |
SECS0611 | replace declaration missing required slot |
SECS0612 | duplicate slot in same declaration/inject body |
SECS0613 | phase/frequency refers to value not registered in scheduler/catalog |
SECS0614 | targeted child-row operation matched zero targetable rows |
SECS0615 | targeted child-row operation matched multiple targetable rows |
SECS0616 | child-row selector is not activation-constant or not bindable |
Normal C# type errors should still use normal Roslyn diagnostics where appropriate, pointing at the mod source line.
12.1 Severity model
Every mod-runtime diagnostic carries a Severity enum value (Error, Warning, Info, Hint) defined in SECS.Engine.Diagnostics. The 600-band codes above all surface at Severity.Error and cause ModRegistry.Finalize to throw. inject_closed_slot diagnostics — emitted when a mod tries to inject against a replace-only architectural slot (policy_actor_scope, policy_domain, activity_actor_scope, activity_target_scope, activity_lane, activity_args_schema) — also surface at Severity.Error (Wave 6) and cause Finalize to throw, on the same footing as the 600-band errors. Slot-conflict diagnostics (two mods writing the same non-architectural slot) remain warn-only (Severity.Warning) and accumulate silently in ModFinalizeResult.Diagnostics.
The bool IsError accessor on ModDiagnostic is a derived view over Severity == Error, kept for back-compat with existing tests. New code should compare against the enum directly.
12.2 Registry static analysis (warn-only)
Wave 7 of the behaviour-layer refactor added SecsRegistry.Validate(). It walks every registered activity, policy, and formula and emits RegistryDiagnostic entries with Severity.Warning. The pass is read-only, idempotent, and never throws — hosts call it after FinalizeModRegistration() to surface authoring mistakes that the hard-validate phase intentionally permits (and that mod patches can introduce post-finalize, since FinalizeModRegistration does not re-run per-declaration validation on the merged map).
Diagnostics produced by Validate() come from a separate channel (RegistryDiagnostics.Diagnostics); they do not flow through ModFinalizeResult.Diagnostics. Both channels share the same Severity enum. Codes shipped in Wave 7 cluster under SECS04xx (activity), SECS09xx (policy), and SECS0303 (formula); see 00-overview.md § Diagnostic codes for the full table.
13. Strict modes
Default modding should be permissive.
Strict modes are optional compiler/launcher flags.
| Flag | Meaning |
|---|---|
--strict-conflicts | fail if any slot conflict exists |
--strict-readsets | enforce formula read-set checks more aggressively |
--strict-walks | convert unresolved/silent walk fallbacks into errors/checks |
--strict-mod-ops | warn/error on try operations that did not match anything |
--strict | enable all strict modes |
Mods may request strict checks in their manifest:
{
"strict": ["readsets", "mod-ops"]
}
Strict mode is additive. Mods cannot disable strict checks requested by the player or launcher.
14. Virtual file system and assets
The semantic .secs merge system should not depend on filename overwrite tricks.
Do not make this meaningful for SECS declarations:
mods/A/Content/common/buildings/farm.secs overwrites base/common/buildings/farm.secs
Semantic content should use operations:
inject template Farm
{
field GoldCost = 8;
}
However, Paradox-style file replacement is still useful for assets.
Use replace_paths or equivalent for:
textures
icons
audio
localization
raw data files
shader assets
UI images
Example manifest:
{
"id": "better_farms",
"replace_paths": [
"localization",
"gfx/icons/buildings"
]
}
Rule:
.secs declarations merge semantically
raw assets merge through VFS/path rules
15. Full examples
15.1 Base content
namespace Valenar;
template<Building> Farm
{
name = "Farm";
tags = Tags.Food, Tags.Perishable;
field int GoldCost = 10;
field int WoodCost = 15;
channel int FoodOutput
{
return (int)(5 * location.Fertility / 33);
}
query bool CanBuild()
{
return province.resolve(Stability) >= 50;
}
method void OnBuilt()
{
settlement.add_modifier(FarmPresence);
}
modifier LocalFertilityBonus
{
FoodOutput *= 110%;
}
}
namespace Valenar;
modifier FarmPresence
{
translation = "Farm Presence";
icon = "icons/modifiers/farm_presence";
tags = Tags.Food;
FoodOutput += 2;
}
namespace Valenar;
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
foreach settlement in Settlement
{
int food = resolve(Food);
int population = resolve(Population);
if (food > population)
{
add_modifier(PopulationGrowth);
}
}
}
}
15.2 Mod: cheaper farms
namespace BetterFarms;
inject template Farm
{
field GoldCost = 8;
field WoodCost = 10;
}
Merged Farm keeps everything else.
15.3 Mod: different farm formula
namespace IrrigationMod;
inject template Farm
{
channel FoodOutput
{
int baseValue = 7;
if (location.WaterCoverage > 50)
baseValue += 2;
return (int)(baseValue * location.Fertility / 33);
}
}
Only the dynamic channel formula body is replaced.
15.4 Mod: replace whole system
namespace SlowGrowth;
replace system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Weekly;
method void Execute()
{
foreach settlement in Settlement
{
int food = resolve(Food);
int population = resolve(Population);
if (food > population * 2)
{
add_modifier(PopulationGrowth);
}
}
}
}
The entire base system declaration is replaced.
15.5 Mod: patch system frequency only
namespace WeeklyGrowth;
inject system GrowthSystem
{
frequency = Cadence.Weekly;
}
This replaces only system_frequency.
15.6 Compatibility patch
namespace BetterFarmsIrrigationCompat;
try inject template Farm
{
field GoldCost = 7;
}
If Farm exists, patch it.
If a total conversion removed Farm, ignore this operation.
16. Implementation pipeline
Step 1 — Parse source sets independently
Each source set is parsed independently.
Base Content/
Expansion Content/
ModA Content/
ModB Content/
Each produces syntax trees and source-set-local diagnostics.
Step 2 — Build declaration catalog
The compiler walks all source trees and records declarations.
For each declaration:
kind
name
canonical id
typed id
source set
load order
file path
source span
declaration AST node
slot schema
Step 3 — Extract semantic slots
Each declaration kind uses its schema.
For a system:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute() { }
}
Extract:
(system, GrowthSystem, system_phase, null)
(system, GrowthSystem, system_frequency, null)
(system, GrowthSystem, system_execute_body, null)
For a template:
template<Building> Farm
{
field int GoldCost = 10;
}
Extract:
(template, Farm, template_field, GoldCost)
Step 4 — Apply operations in load order
For every source set in resolved order:
normal declaration → create
inject → write listed slots
replace → replace whole declaration
try inject → inject if target exists
try replace → replace if target exists
inject_or_create → inject or create
replace_or_create → replace or create
Every write is appended to slot history.
The current value is the last write.
For targeted template child-row operations, "current value" is resolved in two
steps: first the selector is evaluated against the targetable row catalog and
records the matched row-provenance-id; then the replacement/removal payload
is appended to that row's slot history. The catalog matches both immutable
original row keys and current visible payloads, while tombstoned rows remain
targetable by their original key. Later appends that would also match the old
selector do not change the recorded row id.
Step 5 — Produce merged AST / merged IR
The merged result should retain source provenance.
If a mod injects:
field GoldCost = "cheap";
and GoldCost is an int, the type error should point to the mod line, not generated code.
This is the main benefit of the Roslyn fork.
Step 6 — Bind and validate merged result
Run normal C#/SECS binding after merge.
This catches:
unknown identifiers
type mismatches
invalid channel operations
illegal scope walks
bad contract methods
dependency cycles
read-set errors
Step 7 — Lower once
The lowerer sees one merged world.
It emits:
Generated/Hashes.cs
Generated/Declarations.cs
Generated/Templates/*.cs
Generated/Systems/*.cs
Generated/Events/*.cs
Generated/SecsModule.cs
Game.Merged.dll
conflict-report.json
The runtime loads only the merged DLL and declaration arrays.
17. Generated output expectations
Base source:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
}
}
Injected mod:
inject system GrowthSystem
{
frequency = Cadence.Weekly;
}
Generated result:
public sealed class GrowthSystem : ITickSystem
{
public PhaseId Phase => Phases.Growth.Id;
public TickRateId Frequency => Cadence.Weekly.Id;
public void Execute(TickContext ctx)
{
}
}
No runtime override lookup is needed.
18. Array ordering
Generated arrays should preserve stable ordering.
Rules:
base declarations occupy the base prefix
official expansions append after base in expansion order
mods append in load order
injected/replaced existing declarations keep their existing position
new declarations append at the end of the source set block
If a mod disables, its appended declarations disappear. Existing overridden declarations revert to the previous winner.
Base indices do not change.
19. Current exclusions
These rules are part of the live modding contract. Longer rejected-design rationale belongs in docs/design/FUTURE_WORK.md and the historical recovery/audit material, not in the first-pass path.
19.1 System scheduling uses explicit slots
system declarations use phase = ...; and frequency = ...; slots inside the body. Arbitrary C# property overrides and magic fields are not part of the SECS surface because they weaken merge semantics and diagnostic clarity.
19.2 Phase declarations stay plain C#
Top-level phase declarations remain plain C# helper values (PhaseDeclaration.Create(...)), not a phase SECS keyword.
19.3 Tick-rate declarations stay plain C#
Top-level cadence declarations remain plain C# helper values (TickRate.Days(...) or equivalent helpers), not a tick_rate SECS keyword.
19.4 Tag identities stay plain C#
Declare tags as public static readonly TagId ... = TagId.Create("ns:tag/name"). Top-level tag Name; is not part of the SECS source surface.
19.5 Merge operates on semantic slots
The merger performs semantic-slot replacement, not arbitrary AST diff/patch editing.
20. Final design summary
The agreed long-term SECS modding model is:
Paradox-style layered content database
+ explicit mod operations
+ semantic slot schema
+ AST-level slot replacement
+ typed identity
+ single merged DLL
+ conflict report
+ strict mode optional
Canonical operation syntax:
inject template Farm { ... }
replace template Farm { ... }
try inject template Farm { ... }
inject_or_create modifier Bonus { ... }
Canonical system syntax:
system GrowthSystem
{
phase = Phases.Growth;
frequency = Cadence.Daily;
method void Execute()
{
}
}
Canonical system patch:
inject system GrowthSystem
{
frequency = Cadence.Weekly;
}
Canonical top-level phase data:
namespace Valenar;
public static class Phases
{
public static readonly PhaseDeclaration Growth =
PhaseDeclaration.Create("valenar:phase/growth", SystemPhase.Main, 4);
}
Core rule:
Use plain C# for ordinary values and helpers.
Use SECS syntax for compiler-owned declarations and mergeable semantic slots.
The compiler should not patch runtime objects.
The compiler should not patch generated C#.
The compiler should not guess from arbitrary C# member names.
It should merge known semantic slots at AST/IR level, type-check the merged result, lower once, and emit one final game DLL.
21. Cross-references
00-overview.md— the 5-section skeleton, the design-vs-semantics split, the H.* convention, doc map, glossary entries that summarize the vocabulary used here.01-world-shape.md— scope schemas, contract surface,walks_toedges, closedSecsScalarTypeenum that the "What's NOT overridable" section references.02-templates.md— the base template lowering (template<C> T { ... }), template fields, intrinsic channels, contract queries/methods, lifecycle bindings, bare templates. Slot semantics here in § 8.2 build on the shapes defined in 02.03-channels-and-modifiers.md— modifier declaration shape, effect modes, trigger lowering, formula lowering, thereads { }clause, and the 6-phase resolution pipeline. Modifier slot semantics in § 8.3 build on shapes defined in 03.04-behavior.md—system,event,on_action,activity, andpolicydeclarations. Slot semantics in § 8.1 / § 8.4 / § 8.10 / § 8.11 build on shapes defined in 04.05-expressions.md— scope sigils,resolve/template_field/increment/add_modifier/fire/save_scope_as,foreach, query keywords. Body slots (*_execute_body,*_method,*_query_method) contain expressions defined in 05.07-structured-template-data-and-callables.md— C# source type binding, generatedSecsTypeRefmetadata, structured template fields, typed query returns. § 8.8 (Type declarations) builds on shapes defined in 07.08-collections-and-propagation.md—ScopedList<T>,ScopedDictionary <TKey,T>,TemplateId,tags = X, Y;slot, modifierpropagates_to where, previous-tick bridge reads, aggregate channel sources (Children.Sum/Min /Max/Where), the system-execution model (system_phase/system_frequencyslots + plain C#Phases/Cadencestatic classes), recipe system (registry_only).docs/adr/ad-0001-runtime-mod-finalization-boundary.md— records the permanent boundary: runtime owns activity/policy startup finalization; compiler owns the full source-set-aware semantic merger.SECS-Compiler-Plan.md— Phase 3 (Mod Operation & Merger) implements this document. Phase 1 commits the typed identity record structs from § 4.1. Phase 1 hash emission uses the canonical-identity-string convention from § 4.2.mod-coverage-audit.md— the audit doc that tracks finding-by-finding coverage of mod-friendliness across the engine. Many findings are resolved by this doc's slot schema and operation model.- Earlier single-keyword
overridedrafts are superseded by this doc's explicit operation model.