03 — Channels and Modifiers
Channels, template fields, and modifiers are one value subsystem because they share modifier bindings, effect modes, stacking, triggers, and tooltip attribution. A channel declares a runtime-resolved channel value; a field declares template-definition data; a modifier declares how a context changes one of those values. Under the committed model (see 01-world-shape.md § "Model: intrinsic sources are own-root, modifiers cross scopes" and the next section below), Contributed channels start at zero and may add compiler-emitted own-root intrinsic channel sources before modifier phases run; cross-scope aggregation runs through named modifiers. Template fields have a parallel base/effective split: the generated GetField switch returns the base value, and the template-value resolver may apply active modifiers in a caller-supplied context. Both resolver families use the same modifier declarations and bindings, but fields do not become channels and are not accepted by resolve(...).
This doc covers the runtime value machinery:
- Modifier declarations (additive and multiplicative effects, percentage syntax, sub-options).
- Template-field modifier effects (
field GoldCost *= 80%) and effective template value resolution. - Trigger lowering (
when,while→Trigger_*.cs). - Formula lowering (dynamic modifier effects and dynamic intrinsic template channels →
Formula_*.cs). - The six-phase resolution pipeline (Base → Additive → Multiply → HardOverride → Clamp → Return), in recap form.
reads { }dependency declaration andValidateDependencies().- Template-scoped modifiers (declared inside a template body).
Channel kinds (Contributed / Base / Accumulative), top-level channel declarations, and top-level field declarations live in 01-world-shape.md; Contributed means "no host base; phase 1 starts at zero, adds own-root intrinsic channel sources, then modifiers apply" — not "any template may anonymously contribute anywhere". Per-instance channels on templates — the channel int X = N and channel int X { return ... } forms, always on the template activation root — and template field assignments (field int GoldCost = 10) live in 02-templates.md. This doc picks up where the template doc leaves off: everything downstream of a modifier attachment, a template registering an intrinsic channel source on its activation root, or code asking for an effective template value in a context.
Modifiers as cross-scope aggregation
SECS uses named modifiers as the sole cross-scope aggregation mechanism. Base and Accumulative channels read host fields; Contributed channels start at zero and can add compiler-emitted intrinsic channel sources registered on the same activation root. When ten buildings each add +5 to a settlement channel outside their own root, that is ten stacks of the same stackable modifier, not ten anonymous channel-source pushes. This is the canonical statement of the modifier model for the whole spec; see 01-world-shape.md § "Model: intrinsic sources are own-root, modifiers cross scopes" for the declaration-side table.
Comparison: the Paradox idiom vs. the discarded contribution model.
| Pattern | Shape | Status in SECS |
|---|---|---|
| Paradox idiom (CK3 / EU4 / Victoria 3) | Building attaches a named stackable modifier to its parent scope (province / county / realm) | This is the model SECS uses. |
| Contribution model (discarded) | Template registers an anonymous additive channel source directly on a higher scope (no binding, no name, no tooltip identity) | Not a .secs primitive. Rejected as a language smell: cross-scope effects must have modifier identity. |
Concrete example: a House building contributing to settlement.Morale. A House template does not push a value into the settlement's Morale through its own channel block — template-body channel int X = N is a per-instance channel on the template's own scope. The cross-scope effect uses a named modifier:
// modifiers.secs
modifier HouseMoralePresence
{
translation = "House";
tags = Buff, Economic;
stacking = stackable;
Morale += 5;
}
// House template body
template<Building> House
{
method void OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}
Ten Houses attach ten stackable HouseMoralePresence bindings to the settlement entity. The settlement's Morale resolves to base (0 for Contributed) + ten × (+5) + other modifiers → 50, clamped to [0, 100]. Tooltip attribution reads "Morale: 50 = 0 base + 50 House (×10)" straight from the named bindings.
Each binding is owned by the House entity that created it and targeted at the settlement. Destroying one House removes that House's owned binding; the other nine stacks remain. A source-level remove_modifier call is only needed for early/custom teardown, not for ordinary template lifetime cleanup.
Why this shape:
- Named effects unlock mod-override attribution. Modifiers have stable ids; mods that disable or replace a specific buff need something to target. An anonymous contribution from a template body has no handle.
- Tooltip breakdowns require named sources. "Morale: 65 = 50 base + 15 Celebration + 10 Temple" means each line is a named modifier. Aggregation via modifiers makes this fall out of the data.
- Language orthogonality. One mechanism for "source affects another scope" is better than two. Stackable modifiers cover the cross-scope multi-source case; the language has no
contributeverb.
Runtime primitive note: CommandBuffer.RegisterChannelSource still exists on the runtime command surface, but only as the lowering primitive for compiler-emitted template intrinsic channels. It is emitted from Activate, targets the activation root, and is not a modifier optimisation or author-visible verb. House.cs now uses named modifiers for settlement-scope effects; Farm.cs uses RegisterDynamicChannelSource because FoodOutput is a location-root channel on the Building contract's activation root. See ## The contribution primitive: why it's not in SECS below.
Prerequisites
00-overview.md— the five-section feature skeleton.01-world-shape.md—scope,contract, top-levelchanneldeclarations,ChannelKind.02-templates.md— template members, intrinsic static and dynamic channels.08-collections-and-propagation.md—ScopedList/ScopedDictionarycollection scopes, virtual binding materialization, aggregate channel resolution, previous-tick snapshots — referenced throughout this doc for the deep semantics behindpropagates_to children,Children.Sum/etc.,on Parent.FieldName, andtrack_prev.
Modifier declaration — basic form
What: A modifier block declares a reusable effect bundle: a name, a translation, optional sub-options (tags, stacking, reapply, decay), and one or more effect lines. Channel effects use the existing Channel OP Value form. Template-field effects use an explicit field FieldName OP Value form so the target family is visible in source. Under the committed model (see opening section), modifiers are the primary mechanism for effects on channels and the committed context mechanism for effective template values — every cross-scope effect, every timed / conditional buff, and every contextual build-cost discount lowers to a ModifierDeclaration. There is no second cross-scope binding system.
SECS:
// Content/common/modifiers.secs
modifier FestivalSpirit
{
translation = "Festival Spirit";
tags = Buff, Economic;
Production *= 110%;
}
modifier Famine
{
translation = "Famine";
tags = Debuff;
Morale += -30;
}
modifier RoyalBuilderDiscount
{
translation = "Royal Builder Discount";
tags = Economic;
field GoldCost *= 80%;
field WoodCost *= 90%;
}
Compiles to:
// Generated/Declarations.cs — inside the Modifiers[] array
new()
{
ModifierId = H.FestivalSpirit, Translation = "Festival Spirit",
Tags = [H.Tag_Buff, H.Tag_Economic],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Production, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 }],
},
new()
{
ModifierId = H.Famine, Translation = "Famine",
Tags = [H.Tag_Debuff],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -30 }],
},
new()
{
ModifierId = H.RoyalBuilderDiscount, Translation = "Royal Builder Discount",
Tags = [H.Tag_Economic],
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.TemplateField, TargetId = H.GoldCost, Mode = EffectMode.Multiplicative, DoubleValue = 0.8 },
new() { TargetKind = ModifierEffectTargetKind.TemplateField, TargetId = H.WoodCost, Mode = EffectMode.Multiplicative, DoubleValue = 0.9 },
],
},
Why this shape: The modifier is a data table entry, not a class. ModifierDeclaration is a readonly struct (src/SECS.Abstractions/Modifiers/ModifierDeclaration.cs) so registration is a single dictionary insert. The whole effect bundle is an immutable ModifierEffect[], keyed by ModifierId, target family, and target id so the channel resolver can filter channel effects and the template-value resolver can filter template-value effects without a second modifier registry. Translation lives on the struct because the tooltip layer needs it without a second lookup. This struct is also the attribution primitive for tooltips and slot-merge mod patches — every modifier effect on a resolved channel or effective template value has a named ModifierDeclaration behind it, so a tooltip breakdown or a targeted inject modifier X { ... } patch can target it by id.
Runtime contract: ModifierEffect carries ModifierEffectTargetKind { Unknown, Channel, TemplateField } plus TargetId. Unknown is only the invalid default sentinel; RegisterModifiers rejects it. All compiler output emits the target family explicitly: { TargetKind = Channel, TargetId = H.X } for channel effects and { TargetKind = TemplateField, TargetId = H.X } for template-value effects. There is no ChannelId shortcut on modifier effects.
Mod scope note. Modifier declarations are subject to SECS0602 (duplicate declaration without a mod operation) across the merged source set. Two mods that each declare modifier Blessed { ... } without using inject / replace / try ... / ..._or_create both error. The diagnostic fires across source sets — mod-versus-base, mod-versus-mod, and mod-self-redeclaration cases all hit the same code. See 06-overrides-and-modding.md § "Diagnostics fired across source sets" for the full enumeration covering every declaration type (template, modifier, system, event, on_action, activity, channel, scope, contract, formula, trigger, tag).
Notes: None.
Effect modes: Additive, Multiplicative, HardOverride
What: The three operators supported in modifier effect lines. += contributes to the additive phase; *= contributes to the multiplicative phase; = (bare) contributes to the HardOverride phase, replacing the post-multiplicative value. The same operators apply to channel effects and template-field effects. *= N% is the canonical percentage form; it lowers to a double factor in ModifierEffect.DoubleValue. -= is sugar for += -N. The EffectMode enum at src/SECS.Abstractions/Modifiers/EffectMode.cs:3-7 defines the closed set: Additive, Multiplicative, HardOverride.
SECS:
modifier Thriving
{
translation = "Thriving Settlement";
tags = Buff, Economic;
FoodOutput *= 110%; // multiplicative, factor 1.1
ProductionOutput *= 110%;
WoodOutput *= 110%;
}
modifier Overcrowded
{
translation = "Overcrowded";
tags = Debuff;
Morale += -20; // additive, IntValue -20
}
modifier HarshWinter
{
translation = "Harsh Winter";
decay = linear;
FoodOutput *= 50%; // multiplicative, factor 0.5
Morale += -10;
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.Thriving, Translation = "Thriving Settlement",
Tags = [H.Tag_Buff, H.Tag_Economic],
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.FoodOutput, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.ProductionOutput, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.WoodOutput, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 },
],
},
new()
{
ModifierId = H.Overcrowded, Translation = "Overcrowded",
Tags = [H.Tag_Debuff],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -20 }],
},
new()
{
ModifierId = H.HarshWinter, Translation = "Harsh Winter",
Tags = [H.Tag_Debuff, H.Tag_Seasonal],
Decay = DecayMode.Linear,
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.FoodOutput, Mode = EffectMode.Multiplicative, DoubleValue = 0.5 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -10 },
],
},
Why this shape: The modes map one-to-one onto the additive, multiplicative, and HardOverride phases; storing a flag on each ModifierEffect avoids a second collection. *= N% lowers to DoubleValue = N / 100.0 because the multiplicative phase compounds factors as double for every numeric target (int, long, float, double). += N uses the target declaration's scalar type: integer channels/fields take IntValue, long channels/fields take LongValue, float channels/fields take FloatValue, and double channels/fields take DoubleValue. Bool targets only accept HardOverride and use BoolValue. The compiler picks the value slot from the bound target's declared ValueType, regardless of whether that target is a channel or a field.
Mod scope note. EffectMode is a closed enum. The three entries — Additive, Multiplicative, HardOverride — are the only operators recognised by ChannelResolver / the template-value resolver and the only operators the compiler accepts on the right-hand side of a modifier effect line. Mods cannot extend EffectMode. A mod that wants a "clamp to N" or "ceiling to N" effect must use HardOverride (Channel = N or field Field = N replaces the post-multiply value) or the deferred <= N operator if/when it lands. New effect modes require an engine + compiler release, not a mod-side declaration. The compiler rejects any operator outside { +=, -=, *=, = } with a syntax error; there is no .secs surface a mod could use to add a new mode. Cross-reference 06-overrides-and-modding.md § "What's NOT overridable" for the full closed-surface enumeration.
Notes:
<= N(clamp operator) remains DEFERRED — use channel-declarationmin/maxinstead. The compiler should reject<= Nwith a diagnostic for now.priority Non modifier declarations also remains DEFERRED; the field is reserved but not implemented.
HardOverride — the set operator
What: Channel = N; or field Field = N; inside a modifier body replaces the target's post-Additive / post-Multiplicative value with the HardOverride value. This is the set operator — it does not add, it does not scale, it replaces. It contributes to a dedicated pipeline phase (see the 6-phase recap below) that runs between Multiply and Clamp. Last HardOverride applied wins (applied in modifier-binding-registration order, identical to the existing effect-pile iteration order). HardOverride works for every channel/field type: int, long, float, double, bool. For bool targets, HardOverride is the only effect mode that makes semantic sense — += and *= on bool are compile errors (see §"bool effect-mode rule" below and SECS0301).
SECS:
// Content/common/modifiers.secs — toggle a boolean mode flag
modifier RaidMode
{
translation = "Raid Mode";
tags = Military;
IsDefending = true; // HardOverride on bool channel
}
// Force a numeric ceiling — Starvation forces the food-capacity channel
modifier StarvationCap
{
translation = "Starvation";
tags = Debuff;
FoodCapacity = 10; // HardOverride on int channel: replaces whatever Additive/Multiplicative produced
}
// Precision override on a double channel
modifier ExactFertility
{
translation = "Exact Fertility";
Fertility = 1.5; // HardOverride on double channel
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.RaidMode, Translation = "Raid Mode",
Tags = [H.Tag_Military],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.IsDefending, Mode = EffectMode.HardOverride, BoolValue = true }],
},
new()
{
ModifierId = H.StarvationCap, Translation = "Starvation",
Tags = [H.Tag_Debuff],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.FoodCapacity, Mode = EffectMode.HardOverride, IntValue = 10 }],
},
new()
{
ModifierId = H.ExactFertility, Translation = "Exact Fertility",
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Fertility, Mode = EffectMode.HardOverride, DoubleValue = 1.5 }],
},
Why this shape: Some channels must be set, not modified — toggle state (IsDefending, IsPaused), mode flags (RaidMode, SiegeMode), and forced values (debug overrides, tutorial clamps, StarvationCap forcing FoodCapacity = 10). Expressing these as += large_number or *= 0 is a code smell: additive and multiplicative are aggregation operators, not replacement operators. HardOverride is the primitive that says "ignore everything stacked before me, use this value". It lives as a distinct EffectMode so the resolver can route it to its own phase and so tooltips can attribute "value = 10 (StarvationCap override)" without pretending the additive / multiplicative math produced it.
The phase order is Base → Additive → Multiply → HardOverride → Clamp → Return (see the 6-phase recap below). HardOverride sits after Multiply so authors can reason about it as "whatever the additive-and-multiplicative stack produced gets thrown away"; it sits before Clamp so the channel-declaration min / max still bound the result (a HardOverride = 10000 on a channel with max = 100 clamps to 100).
Notes:
- Ordering semantics when multiple modifiers HardOverride the same channel: committed to "last-applied wins per modifier-binding-registration order". Do not introduce a
priorityfield for HardOverride — stacking order is the tie-breaker and that matches the existing effect-pile iteration model. If authors need deterministic priority, that lands with the generalpriority Nfeature (DEFERRED, see above).
bool effect-mode rule
What: Modifier effects on bool channels or fields must use HardOverride. += (Additive) and *= (Multiplicative) are not defined on booleans — there is no "add true to a bool" or "multiply a bool by 110%". The compiler rejects these forms with diagnostic SECS0301.
SECS:
// Valid — HardOverride on a bool channel:
modifier RaidMode
{
translation = "Raid Mode";
IsDefending = true;
}
// Invalid — compile error SECS0301:
modifier BadBoolAdditive
{
translation = "Bad";
IsDefending += true; // error SECS0301
}
// Invalid — compile error SECS0301:
modifier BadBoolMultiplicative
{
translation = "Bad";
IsDefending *= 110%; // error SECS0301
}
Why this shape: bool channels resolve through a different short path than numeric channels (see ResolveBool at src/SECS.Engine/Resolution/ChannelResolution.cs:485-542) and bool fields have the same semantic issue — there is no additive sum or multiplicative product to feed. HardOverride is the only mode whose semantics translate cleanly: "this binding sets the value to true / false". Making += and *= compile errors on bool targets keeps the lowering surface honest — authors never accidentally write code that silently compiles to a no-op at resolution time. The discriminant the compiler checks is the bound target declaration's ValueType == SecsScalarType.Bool (see src/SECS.Abstractions/Channels/ChannelDeclaration.cs:3-9).
Notes: None.
Template-field modifier effects
What: A modifier can affect the effective value of a template field by prefixing the effect target with field. The base template field stays readonly on the template; the effective value is computed only when code asks "what is this field worth in this context?"
SECS:
// Global declaration: see 01-world-shape.md
field int GoldCost {
name = "Gold Cost";
description = "Gold required to construct this template.";
min = 0;
}
// Template base value: see 02-templates.md
template<Building> Farm
{
field int GoldCost = 10;
field int WoodCost = 15;
}
// Context modifier: attach to the settlement/realm/actor whose context
// should receive the discount.
modifier RoyalBuilderDiscount
{
translation = "Royal Builder Discount";
tags = Economic;
field GoldCost *= 80%;
field WoodCost *= 90%;
}
Compiles to:
new()
{
ModifierId = H.RoyalBuilderDiscount,
Translation = "Royal Builder Discount",
Tags = [H.Tag_Economic],
Effects =
[
new()
{
TargetKind = ModifierEffectTargetKind.TemplateField,
TargetId = H.GoldCost,
Mode = EffectMode.Multiplicative,
DoubleValue = 0.8,
},
new()
{
TargetKind = ModifierEffectTargetKind.TemplateField,
TargetId = H.WoodCost,
Mode = EffectMode.Multiplicative,
DoubleValue = 0.9,
},
],
};
Effective resolver contract (DESIGN TARGET):
int value = templateValueResolver.ResolveInt(
templateId: H.Farm,
fieldId: H.GoldCost,
contextTargets: [settlement, location]);
The resolver runs the same phase shape as channels:
| Phase | Template-value resolver behavior | Data source |
|---|---|---|
| 1. Base | Read the template's authored base field value (Farm.GetField(H.GoldCost) => 10) | TemplateEntry.GetFieldInt / GetFieldFloat |
| 2. Additive | Sum active field GoldCost += N effects on modifier bindings attached to the supplied context targets | ModifierBindingStore + ModifierDeclaration.Effects[] |
| 3. Multiply | Compound active field GoldCost *= N% effects | same binding/effect pile |
| 4. HardOverride | Apply active field GoldCost = N effects; last applied wins | same ordering rule as channel HardOverride |
| 5. Clamp | Apply top-level field declaration clamps (min = 0, etc.) | TemplateFieldDeclaration |
| 6. Return | Return the effective value; do not write back anywhere | no DirtySet, no SyncDirty |
Context targets: the engine never invents context. The generated code or host code supplies the modifier targets that should affect the read. A Valenar build-cost query passes [settlement, location]; a realm game might pass [country, province, holding]; a card game might pass [player, deck]. The resolver treats these only as ordered EntityHandles. It does not know what a settlement, realm, player, building cost, spawn chance, or card cost is.
The resolver receives a normalized broad-to-narrow context chain: null entities are skipped, duplicate entity handles are skipped while preserving the first occurrence, and the remaining targets are visited in caller-specified order. It iterates each target's active bindings using the existing ModifierBindingStore.GetBindingsForTarget(target) path; no second binding store is introduced. HardOverride tie-breaking is deterministic: context targets are visited in normalized chain order, and bindings within each target use the same binding-registration order as channel resolution. Later contexts win over earlier contexts for HardOverride, so location-level overrides can beat settlement-level overrides when the source writes context(@Settlement, @Location) or a named profile that expands to that chain.
Why this shape:
- Fields remain fields.
GoldCostis still read before a Farm entity exists; it does not become achannel, does not participate inresolve(...), and does not write toDirtySet. - The modifier system is reused where it is already strong: named effects, tags, stacking, duration,
when/while, triggers, decay, slot-merge targets, and tooltip attribution. - The explicit
fieldprefix prevents effect-body ambiguity.Morale += 5;binds to a channel.field GoldCost *= 80%;binds to a template field. If the named target is undeclared in that family, the compiler reports the error at the effect line. - Global discounts are context, not mutation. A policy can make Farm cost 8 gold in one settlement while another settlement still sees the base 10 or a different effective value.
- Context profiles are expansion-only.
BuildCostContextmay save authors from repeatingcontext(@Settlement, @Location), but it must not contain validation, payer selection, or resource-spending rules. Those belong in query/method bodies such asCanBuild().
Dynamic field effects: Static field effects (field GoldCost += 5, *= 80%, = 0) are committed. Dynamic field effects are allowed only when the expression can be evaluated from the normal modifier formula context (target, owner, captured scopes, host reads/channel resolves). A dynamic field effect that needs the template id or the field's base value as an input requires an extended field-formula delegate and is deferred until a concrete use case appears; do not smuggle template ids through EntityHandle.
Cache policy: Base template fields are static after registration and may be cached by (templateId, fieldId, scalarType) for the lifetime of the registry. Effective template-value reads must include the normalized ordered context chain in the key and must invalidate on modifier changes. Short-lived caches scoped to one tick, one UI paint, or one planner pass are valid. Long-lived caches need per-entity modifier versions for every context entity and dynamic formula read-set invalidation when a field effect's value comes from a formula. A cache that cannot prove those versions should not persist effective template values beyond the current tick/pass.
Notes: None.
Field-vs-channel rule for contextual costs
What: Template fields hold the authored base value. Channels hold named runtime context values. Modifier bindings are the bridge that lets a runtime context change either a channel or an effective template value.
Do not turn a concrete template field like GoldCost into a settlement channel just because a settlement can discount it. A settlement does not have one universal GoldCost; Farm, Barracks, Housing, ships, spells, units, cards, and items may all have different gold costs. The settlement has conditions that affect costs.
Recommended pattern:
// Top-level field metadata; base value lives on each template.
field int GoldCost {
name = "Gold Cost";
description = "Gold required to construct this template.";
min = 0;
}
template<Building> Farm
{
field int GoldCost = 10;
field int WoodCost = 15;
}
// Broad named context channel. Designers and modders can inspect this.
channel double ConstructionCostFactor {
kind = Contributed;
name = "Construction Cost";
description = "Multiplier applied to construction costs in this settlement.";
min = 0.0;
}
// Broad rule: affects every construction cost calculation that reads the factor.
modifier RoyalBuilderLaw
{
translation = "Royal Builder Law";
ConstructionCostFactor *= 80%;
}
// Precise exception: affects a specific effective template value in the supplied context.
modifier FertileFarmDiscount
{
translation = "Fertile Farm Discount";
field GoldCost *= 80%;
}
An effective build-cost helper for "Farm in Settlement A at Location X" composes the two families:
1. Read template base field:
Farm.GoldCost = 10
2. Apply direct field effects from the context targets:
Location X has FertileFarmDiscount -> GoldCost x 0.8
3. Apply named contextual channels:
Settlement A resolves ConstructionCostFactor -> 0.8
4. Return final effective cost:
10 x 0.8 x 0.8 = 6.4 -> integer policy decides 6 or 7
Rule of thumb:
| Use | SECS shape | Example |
|---|---|---|
| Authored template data | field | Farm.GoldCost = 10 |
| Broad runtime context that UI / AI / events / mods should inspect | channel | ConstructionCostFactor, RecruitmentSpeed, DiseaseResistance |
| A named thing that changes channels or effective template values | modifier binding | RoyalBuilderLaw, FertileFarmDiscount, BattleScars |
| Final answer for a concrete question | resolver/helper | "Farm GoldCost in Settlement A at Location X" |
Why this shape:
- Fields remain the source of base template data. A Farm's
GoldCostcan be read before any Farm exists. - Channels remain real runtime concepts.
ConstructionCostFactoris something the UI can show, the AI can compare, events can check, and mods can extend. - Direct field modifiers still exist for precise exceptions. "Farms are cheaper in fertile locations" should not require inventing
FarmGoldCostFactorunless that factor is itself a meaningful game concept. - The model stays generic across games. The same pattern works for construction costs, spell mana costs, card play costs, unit recruitment time, item weight, spawn chance, research cost, or production throughput.
Anti-pattern: channel int GoldCost on settlement for build prices. It asks "gold cost of what?" and collapses template data into context data. Use a field for the template's base price and a named context channel/factor for the settlement's broad pricing conditions.
Stacking policies
What: stacking = single | unique | stackable controls how many bindings of the same modifier can coexist on one target. max_stacks = N bounds the Stackable count. Single is the default; one binding per (owner, target).
Stackable as the aggregation primitive
stacking = stackable is the mechanism for many-sources-one-cross-scope effects. When ten buildings each need to add +5 to a settlement's Morale, the correct shape is one stackable modifier attached ten times — not ten separate declarations, not a higher-scope anonymous contribution, not a custom system that sums template fields. The stackable count is the aggregation count.
Canonical example: House presence on settlement Morale.
// Content/common/modifiers.secs — the declaration
modifier HouseMoralePresence
{
translation = "House";
tags = Buff, Economic;
stacking = stackable;
max_stacks = 100; // cap = practical settlement house cap
Morale += 5;
}
// Content/buildings/settlement/house.secs — the attach pattern
template<Building> House
{
method void OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}
Lowered:
// Generated/Declarations.cs — one ModifierDeclaration, regardless of how many Houses exist
new()
{
ModifierId = H.HouseMoralePresence, Translation = "House",
Stacking = StackingPolicy.Stackable,
MaxStacks = 100,
Tags = [H.Tag_Buff, H.Tag_Economic],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = 5 }],
},
// Generated/Templates/Buildings/Settlement/House.cs — attach on built; owner cleanup detaches
public static void OnBuilt(ITemplateCommandContext context, in SecsNoArgs args)
{
var settlement = context.Host.WalkScope(context.Scope.Root, H.Settlement);
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HouseMoralePresence, triggerId: 0, duration: -1);
context.FlushCommands();
}
Binding record. Each attached stack is one ModifierBinding in ModifierBindingStore. The committed record shape is:
| Field | Meaning |
|---|---|
Owner | Lifetime source. Destroying/deactivating this entity removes the binding. |
Target | Effect anchor. Channel effects apply to this entity; template-value effects apply when this entity appears in a template-value context chain. |
ModifierId | Hash of the modifier declaration. |
TriggerId | 0 for always-active bindings; H.Trigger_* for a stored while condition. |
RemainingTicks / TotalTicks | Duration in engine ticks. -1 means permanent. There are no built-in day/month units; games map their calendar to ticks in systems/events. |
Captured0 / Captured1 | Entity references captured for trigger/formula re-evaluation (@root, @prev, or an outer scope). They are references only, not lifetime owners. |
State | Active, Inactive, or Removed. Inactive bindings stay in the store but are skipped by resolution. |
Ten Houses therefore produce ten distinct bindings with ten different Owner handles, all sharing Target = settlement and ModifierId = H.HouseMoralePresence. CommandProcessor enforces StackingPolicy when a new AddModifier arrives: Single treats an existing (owner, target, modifierId) as the duplicate; Unique treats any existing (target, modifierId) as the duplicate regardless of owner; Stackable admits another binding until MaxStacks is reached. On RemoveModifier(owner, target, modifierId) the single binding keyed by that owner is removed — other stacks from other owners are untouched. On template destruction, RemoveAllBindings(owner) removes every binding owned by that template instance; RemoveAllBindingsOnTarget(owner) removes every binding targeted at the destroyed entity.
SECS:
// From Content/common/modifiers.secs
modifier DemonDread
{
translation = "Demon Dread";
tags = Debuff, Military;
stacking = unique; // one per target across all owners
reapply = refresh;
Morale += -20;
Production *= 80%;
}
modifier VictoryRally
{
translation = "Victory Rally";
tags = Buff, Military;
stacking = stackable;
max_stacks = 3; // cap
Morale += 15;
Garrison *= 110%;
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.DemonDread, Translation = "Demon Dread",
Stacking = StackingPolicy.Unique,
ReApplyMode = ReApplyMode.Refresh,
Tags = [H.Tag_Debuff, H.Tag_Military],
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -20 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Production, Mode = EffectMode.Multiplicative, DoubleValue = 0.8 },
],
},
new()
{
ModifierId = H.VictoryRally, Translation = "Victory Rally",
Stacking = StackingPolicy.Stackable,
MaxStacks = 3,
Tags = [H.Tag_Buff, H.Tag_Military],
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = 15 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Garrison, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 },
],
},
Why this shape: Stacking is enforced by the CommandProcessor when it processes AddModifier commands — not by the declaration. The declaration carries only the policy and the cap; the pipeline only ever sees whatever bindings made it through the filter. That keeps ChannelResolver stateless with respect to stacking.
Mod scope note. stacking, max_stacks, and reapply are per-field slot-merge targets on a modifier declaration (slot identifiers modifier_stacking, modifier_max_stacks, modifier_reapply). A mod patch that lowers max_stacks — for example inject modifier HouseMoralePresence { max_stacks = 5; } when base set 100, with eight live HouseMoralePresence bindings already attached — would create the situation where existing bindings exceed the new cap. Because the compiler emits a single DLL and content load happens at startup before any binding exists, the policy is re-validate at startup only: the cap applies to bindings created after the patch loads. Save-game migration is out of scope for the spec (the host owns save format); a save loaded with a stricter cap than was active at save time admits the saved bindings without truncation, but new AddModifier commands respect the new cap. Under-cap shrinks therefore never lose data, but they may temporarily admit more bindings than the cap states. Two mods both lowering max_stacks resolve via standard last-writer-wins. See 06-overrides-and-modding.md § "Slot schema" and § "Conflict report shape" for the full enumeration and conflict surface.
Notes: None.
Lifetime note: Owner/target cleanup is part of the runtime contract, not an optional authoring style. Explicit remove_modifier is still useful for early teardown (for example, a feature being cleared before the owning entity is destroyed), but destruction/deactivation always removes bindings owned by the destroyed entity, and target destruction removes bindings anchored on that target. The normal template-owned modifier pattern is one attach site plus owner cleanup, not an explicit add/remove pair.
Modifier propagation — propagates_to children
What: A modifier declaration may include a propagates_to children clause (with an optional where structural predicate) that instructs the engine to materialize virtual bindings on each child entity of the collection scope the modifier is bound to. When a modifier carrying this clause is attached to a ScopedDictionary or ScopedList collection scope, the engine derives one virtual binding per qualifying child without requiring the author to enumerate children manually or write a system that mirrors the attachment.
This is the primary mechanism for "buff a parent scope — all children feel it" effects. Current materialization/removal mechanics, the future save/load reconstruction protocol, and the performance contract live in 08-collections-and-propagation.md § "Virtual binding materialization".
Five propagation rules (summary):
| Rule | Commitment |
|---|---|
| 1 — Owner preservation | Virtual bindings copy OwnerId from the source binding. |
| 2 — Stacking key | The collection is the gatekeeper; key is (modifier_id, owner_id) — stacking policy is enforced once at the collection level. |
| 3 — Lifecycle mirrors source | Virtual bindings have no independent timers; they are removed when the source binding is removed. |
4 — Structural-only where | has_tag X, has_template X, has_contract X only. Channel-based predicates raise SECS0802. Use reads {} + a value gate inside the effect body for runtime-conditional cases. |
| 5 — HardOverride priority | A direct binding on a child wins over a propagated binding from the parent (closer scope wins). |
Predicate-form consistency. The structural predicates above are the operator form and are the only predicate form accepted in propagates_to where and aggregate .Where(...) filters (see §"Aggregate channel sources" below). Lambda/value predicates (b => b.Level > 5, resolve(Food) > 0, etc.) are not valid in either surface; use declared structural tags/templates/contracts for filtering and put runtime value gates in the effect or aggregate source that owns the read.
Virtual bindings are derived state. They are never persisted to the save file. The current runtime materializes and removes them while source bindings change, but durable reconstruction on load is future protocol work that depends on persisted real source bindings plus stable creation-order / source-binding metadata. See 08-collections-and-propagation.md § "Save/load reconstruction" for the target contract and current gap.
SECS:
// (Illustrative — game vocabulary is chosen by the host, not the engine.)
modifier RegionalTax
{
translation = "Regional Tax";
tags = Economic, Debuff;
stacking = stackable;
propagates_to children where has_contract Settlement;
Production *= 90%;
}
The modifier is attached to a ScopedDictionary or ScopedList collection scope (e.g. a Region entity). The engine materializes a virtual Production *= 90% binding on every child that satisfies has_contract Settlement.
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.RegionalTax, Translation = "Regional Tax",
Tags = [H.Tag_Economic, H.Tag_Debuff],
Stacking = StackingPolicy.Stackable,
PropagatesTo = PropagationTarget.Children,
PropagationPredicate = new() { Kind = PredicateKind.HasContract, Id = H.Settlement },
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Production, Mode = EffectMode.Multiplicative, DoubleValue = 0.9 }],
},
Why this shape: The propagates_to clause belongs on the declaration because it is a structural policy about the modifier's reach, not a per-binding decision. This keeps add_modifier call sites unchanged — the author attaches RegionalTax to the collection scope exactly as they would any other modifier; the engine fans out virtual bindings automatically. The structural-only restriction on where predicates keeps propagation deterministic at modifier-load time without requiring channel resolution to decide which children receive the binding.
Notes: None. Full semantics in 08-collections-and-propagation.md.
ReApply modes
What: reapply = ignore | refresh | extend | stack controls what happens when an add_modifier command hits an existing binding or a stack cap. Default is Ignore.
SECS:
// Content/common/modifiers.secs
modifier DemonDread
{
translation = "Demon Dread";
stacking = unique;
reapply = refresh; // repeated raids reset the 3-tick timer
Morale += -20;
Production *= 80%;
}
modifier QuarantinePenalty
{
translation = "Quarantine";
tags = Disease, Debuff;
reapply = extend; // re-applying adds to remaining duration
Morale += -10;
}
// reapply = ignore is the default and need not be written:
modifier VictoryRally
{
stacking = stackable;
max_stacks = 3;
// reapply defaults to ignore — new casts at max stacks silently skipped
Morale += 15;
Garrison *= 110%;
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.DemonDread, Translation = "Demon Dread",
Stacking = StackingPolicy.Unique,
ReApplyMode = ReApplyMode.Refresh,
Tags = [H.Tag_Debuff, H.Tag_Military],
Effects = [ /* ... */ ],
},
new()
{
ModifierId = H.QuarantinePenalty, Translation = "Quarantine",
ReApplyMode = ReApplyMode.Extend,
Tags = [H.Tag_Debuff, H.Tag_Disease],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -10 }],
},
// Ignore is the zero value; emit nothing when defaulted.
Why this shape: ReApplyMode on the declaration is only the default behaviour for duplicate adds; it is not a duration and it does not create time by itself. The AddModifier command carries an optional per-call override. The compiler omits the field when the author wrote no reapply = line — Default (0) is treated as Ignore by CommandProcessor, so the null case round-trips correctly.
Reapply has four author-facing outcomes:
| Mode | Duplicate / cap behaviour |
|---|---|
ignore | Skip the incoming add; the existing binding is untouched. |
refresh | Keep the binding identity and reset its remaining/total duration to the incoming duration. Permanent (-1) stays permanent. |
extend | Keep the binding identity and add the incoming duration to remaining ticks. Extending with permanent duration makes the binding permanent. |
stack | Create another binding when the stacking policy and max_stacks allow it. At cap, no new binding is created unless a more specific policy later defines eviction. |
The duplicate key is still supplied by the stacking policy: single compares (owner, target, modifierId), unique compares (target, modifierId), and stackable counts all (target, modifierId) bindings up to max_stacks.
Recommended use:
| Modifier | Usual reapply mode | Why |
|---|---|---|
QuarantinePenalty | extend or refresh | Repeated outbreaks should lengthen or reset the temporary penalty. |
FestivalSpirit | refresh | A new celebration resets the event bonus window. |
BattleScars | stack with stackable policy | Multiple independent scars are separate causes. |
RoyalBuilderLaw | ignore | A law should not accidentally apply twice and compound itself. |
HarshWinter | refresh | New weather should reset the previous weather window. |
Notes: None.
Decay
What: decay = none | linear controls how an effect's magnitude scales with remaining duration. none (default) keeps the effect at full magnitude until expiry. linear lerps both additive and multiplicative effects toward zero / 1.0 as RemainingTicks / TotalTicks drops. Decay is only meaningful on timed bindings; a permanent binding has no timeline to decay along.
SECS:
// Content/common/modifiers.secs
modifier HarshWinter
{
translation = "Harsh Winter";
tags = Debuff, Seasonal;
decay = linear;
FoodOutput *= 50%; // eases from 50% back toward 100%
Morale += -10; // eases from -10 back toward 0
}
modifier PlagueSickness
{
translation = "Plague";
tags = Disease, Debuff;
decay = linear;
Morale += -25;
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.HarshWinter, Translation = "Harsh Winter",
Tags = [H.Tag_Debuff, H.Tag_Seasonal],
Decay = DecayMode.Linear,
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.FoodOutput, Mode = EffectMode.Multiplicative, DoubleValue = 0.5 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -10 },
],
},
new()
{
ModifierId = H.PlagueSickness, Translation = "Plague",
Tags = [H.Tag_Disease, H.Tag_Debuff],
Decay = DecayMode.Linear,
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -25 }],
},
Why this shape: Decay is a per-modifier flag, not per-effect. ChannelResolver reads declaration.Decay once per binding and applies the same decayFactor = RemainingTicks / TotalTicks to every effect line. Additive effects multiply their magnitude by decayFactor; multiplicative effects lerp as 1.0 + (factor - 1.0) * decayFactor so a *= 50% effect at half-life becomes *= 75% rather than being halved literally (which would push toward zero instead of toward identity). A permanent binding (RemainingTicks = -1) ignores decay and applies at full strength until removed by owner/target cleanup or an explicit remove operation.
Notes: None.
Lifetime clauses: owned_by, duration, when, while
What: Lifetime belongs to the modifier binding, not to the modifier declaration. A modifier declaration is the reusable rule card; an add_modifier call creates one copy of that card in the world and supplies owner, target, trigger, captured scopes, duration, and initial state.
The committed source shape is:
target.add_modifier ModifierName owned_by ownerExpr when condition while condition duration N;
All clauses after ModifierName are optional. The canonical order is owned_by, then when, then while, then duration.
target— the receiver expression and effect anchor. Channel effects apply to this entity.owned_by ownerExpr— explicit lifetime owner. Destroying/deactivating this entity removes the binding. Captured scopes do not change ownership.- no
owned_byinside a template method — owner defaults to@owner/scope.Owner; target is still the receiver. - no
owned_byoutside a template method — owner defaults to the receiver, so owner and target are the same entity. when <cond>— one-shot gate. Evaluated once before queuing/creating the binding; if false, no binding exists.while <cond>— continuous activity trigger. Stored asTriggerId = H.Trigger_*; the update pass re-evaluates it and togglesStatebetweenActiveandInactive.duration N— duration in engine ticks.RemainingTicks = Nat registration, decremented by update passes, removed at zero.-1is permanent. SECS has no built-in day/month duration units; games map their calendars to ticks.
If when and while both appear, when decides whether the binding is created at all, and while controls active/inactive state after creation. If duration and while both appear, the duration counts total elapsed binding lifetime, not active ticks.
SECS:
// House: permanent settlement modifier owned by the House template instance.
template<Building> House
{
method void OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}
// DemonRaid: timed modifier on the settlement, owned by the settlement by default.
event DemonRaid
{
method void Execute()
{
@Settlement.add_modifier DemonDread duration 3;
}
}
// CelebrationBonus: location-targeted timed bonus owned by the settlement.
event CelebrationBonus
{
method void Execute()
{
var location = scope:Location;
if (location != null)
{
location.add_modifier BountifulHarvest owned_by @Settlement duration 5;
}
@Settlement.add_modifier FestivalSpirit
when @Settlement.resolve(Morale) >= 80 duration 3;
@Settlement.add_modifier Thriving
while @Settlement.resolve(Morale) >= 80;
}
}
Compiles to:
// CommandBuffer extension used by the active template/event/system command surface:
// cmds.AddModifier(owner, target, modifierId, triggerId, duration,
// captured0 = default, captured1 = default, reApplyMode = Default)
// Template-method default owner: context.Scope.Owner (the House), target: settlement.
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HouseMoralePresence, 0, -1);
context.FlushCommands();
// Outside template methods, receiver supplies both owner and target by default.
ctx.Commands.AddModifier(settlement, settlement, H.DemonDread, 0, 3);
// Explicit owned_by: owner is settlement, target is location.
ctx.Commands.AddModifier(settlement, location, H.BountifulHarvest, 0, 5);
// when = condition-on-add: the compiler emits a C# `if` around AddModifier;
// the condition is NOT stored on the binding.
if (Trigger_MoraleGte80.Evaluate(settlement, EntityHandle.Null, EntityHandle.Null, ctx.Host))
{
ctx.Commands.AddModifier(settlement, settlement, H.FestivalSpirit, 0, 3);
}
// while = continuous: triggerId is H.Trigger_*, update pass re-evaluates.
ctx.Commands.AddModifier(settlement, settlement, H.Thriving, H.Trigger_MoraleGte80, -1);
Why this shape: owned_by makes lifetime explicit without overloading the target expression. when and while share the same Trigger_*.cs callback shape but differ in storage: when is inlined at the call site as a plain C# if, while while is persisted on the binding so the update pass can flip Active/Inactive without the method re-running. Keeping duration separate from trigger state lets the combinations (duration only, when only, while only, when + while, duration + while) compose without adding fields to ModifierDeclaration.
The removal/toggle rules are closed:
| Cause | Result |
|---|---|
| Owner destroyed/deactivated | Remove the binding. |
| Target destroyed/deactivated | Remove the binding. |
| Duration reaches zero | Remove the binding. |
while evaluates false | Keep the binding, set State = Inactive, skip effects. |
while evaluates true again | Set State = Active, effects apply again. |
Captured slot required by a trigger is Null | Treat the trigger as false. |
Captured entities are references for trigger/formula evaluation only. A binding owned by a settlement and targeted at a location may capture @root or @prev; that captured handle does not by itself own or remove the binding.
Notes: None.
Tags
What: tags = A, B, ... attaches declared TagId identities to a modifier. Tags enable bulk operations — "cleanse all disease", "count military buffs" — without the caller knowing individual modifier ids. The compiler resolves symbolic tag references against the declared tag catalog and generated output registers that catalog once in SecsModule.Initialize through RegisterTag.
SECS:
// Content/common/modifiers.secs
modifier PlagueSickness
{
translation = "Plague";
tags = Disease, Debuff;
decay = linear;
Morale += -25;
}
modifier Fortified
{
translation = "Fortified";
tags = Buff, Military;
reads { Garrison }
Morale += 5;
Defense += resolve(Garrison) / 2;
Garrison *= 110%;
}
Compiles to:
// Generated/Declarations.cs — Tags on the declaration
new()
{
ModifierId = H.PlagueSickness, Translation = "Plague",
Tags = [H.Tag_Disease, H.Tag_Debuff],
Decay = DecayMode.Linear,
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = -25 }],
},
// Generated/SecsModule.cs — Tag name registration
registry.RegisterTag(H.Tag_Disease, "Disease");
registry.RegisterTag(H.Tag_Debuff, "Debuff");
registry.RegisterTag(H.Tag_Buff, "Buff");
registry.RegisterTag(H.Tag_Military, "Military");
registry.RegisterTag(H.Tag_Seasonal, "Seasonal");
registry.RegisterTag(H.Tag_Economic, "Economic");
Why this shape: A ulong[]? on the declaration is the cheapest representation that still allows zero or many tags. null means no tags; the resolver skips without allocating. Tag ids come from typed TagId.Create("namespace:tag/name") constants; generated H.Tag_* values are convenience mirrors of those TagId.Value hashes, not independent lowercase-name hashes. Bulk operations (remove_modifiers_by_tag, count_by_tag, has_tag) live in method bodies — see 05-expressions.md.
Mod scope note. Tags are mod-extensible through declared TagId constants, not through free-form use sites. A mod may add public static readonly TagId Arcane = TagId.Create("my_mod:tag/arcane"); in its vocabulary input, and generated output must register that collected tag through registry.RegisterTag(id, name). A symbolic reference such as tags = Arcane;, remove_modifiers_by_tag Arcane, or has_tag Arcane binds against the merged declared catalog; if no declared tag matches, the compiler emits SECS0212. Two mods that both choose a short name Arcane but use different source-set namespaces produce distinct canonical ids and distinct TagId values. A mod that wants to target a base tag must reference the base tag constant (for example Tags.Disease or an imported alias), not recreate it by spelling a lowercase name. The first source-set ordering rule in 00-overview.md § "Compiler-output ordering convention" determines deterministic RegisterTag emission for the merged catalog.
Notes: None.
Translation key
What: translation = "..." is the display string shown in tooltips and UI lists. Required on every modifier declaration.
SECS:
modifier FestivalSpirit
{
translation = "Festival Spirit";
tags = Buff, Economic;
Production *= 110%;
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.FestivalSpirit, Translation = "Festival Spirit",
Tags = [H.Tag_Buff, H.Tag_Economic],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Production, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 }],
},
Why this shape: The translation is an inline C# string today; the localization layer (SECS.Localization, YAML-backed) replaces it at runtime by id lookup when a non-default culture is active. Lowering it as a literal on the declaration keeps the default-culture path allocation-free — no dictionary round-trip when the player speaks English. Localized builds read Translation as a key into the YAML table rather than as the final string.
Mod scope note. translation = "..." is a per-field slot-merge target (modifier_translation). A mod patch that touches only the translation (inject modifier HouseMoralePresence { translation = "Cottage Presence"; }) replaces only the Translation field; the effects, stacking, tags, and decay stay base-defined. For non-default cultures, each mod ships its own YAML alongside its .secs files at mods/<ModId>/Localization/<culture>.yaml. The localization provider walks the load order at startup, registering each YAML as a layered source — later mods override earlier mods' keys, base sits at load order 0. A mod that introduces a new modifier ships its own translation key in its YAML files; a mod that patches only the translation field still ships a YAML entry under the base modifier's id (the id, not the source mod, is the lookup key). Two mods both patching the same translation resolve via last-writer-wins by load order; both writes appear in the conflict report. Cross-reference 06-overrides-and-modding.md § "Slot schema" for the per-slot enumeration; the YAML merge rule described here is the live contract.
Notes: None.
Template-scoped modifiers
What: A modifier block declared inside a template body. Its identity is a compiler-emitted canonical scoped modifier id string — distinct from a top-level modifier of the same name. It lands in the same Declarations.Modifiers[] array as every other modifier; the only difference is the identity namespace. add_modifier ModName inside the owning template resolves to the scoped id first, then falls back to the global id.
SECS:
// Inline template-scoped form. Valenar's Content/locations/sites/dungeon.secs currently
// uses a top-level modifier (see Content/common/modifiers.secs); the inline shape
// below is the planned compiler's lowering target when an author wants to keep
// the modifier private to the template that uses it.
template<Feature> Dungeon
{
channel int ThreatLevel = 50;
channel int LootValue = 200;
modifier DungeonAuraOfDread
{
translation = "Dungeon Aura of Dread";
tags = Debuff, Military;
stacking = unique;
}
modifier DungeonClearedReward
{
translation = "Dungeon Cleared";
tags = Buff, Economic;
stacking = unique;
}
method void OnSpawned()
{
add_modifier DungeonAuraOfDread;
}
method void OnCleared()
{
add_modifier DungeonClearedReward;
}
}
Compiles to:
// Generated/Hashes.cs — identity uses the scoped modifier canonical id string
public const ulong DungeonAuraOfDread = /* FNV-1a-64(scoped modifier canonical id) */ 0x...u;
public const ulong DungeonClearedReward = /* FNV-1a-64(scoped modifier canonical id) */ 0x...u;
// Generated/Declarations.cs — same Modifiers[] array, template-scoped entries alongside global ones
new()
{
ModifierId = H.DungeonAuraOfDread, Translation = "Dungeon Aura of Dread",
Stacking = StackingPolicy.Unique,
Tags = [H.Tag_Debuff, H.Tag_Military],
Effects = [],
},
new()
{
ModifierId = H.DungeonClearedReward, Translation = "Dungeon Cleared Reward",
Stacking = StackingPolicy.Unique,
Tags = [H.Tag_Buff, H.Tag_Economic],
Effects = [],
},
// Generated/Templates/Locations/Sites/Dungeon.cs — OnSpawned / OnCleared reference the scoped hashes
public static void OnSpawned(ITemplateCommandContext context, in SecsNoArgs args)
{
context.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.DungeonAuraOfDread, 0, -1);
context.FlushCommands();
}
public static void OnCleared(ITemplateCommandContext context, in SecsNoArgs args)
{
context.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.DungeonClearedReward, 0, -1);
context.FlushCommands();
}
Why this shape: Scoping the identity into the template namespace prevents collisions when two templates define a modifier with the same short name. Putting the declarations in the shared Modifiers[] array (rather than a per-template list) means ChannelResolver, CommandProcessor, and ModifierBindingStore keep a single lookup path regardless of where the modifier was authored. add_modifier ModName inside a template body is resolved by the compiler: it checks the template's local modifier table first, then the global one — the resulting H.* hash collapses both cases to a single ulong, so the generated C# looks identical to a global modifier reference.
In Valenar today, DungeonAuraOfDread and DungeonClearedReward are declared at the top level in Content/common/modifiers.secs because the hand-written stand-in pre-dates the inline lowering and it was simpler to collect everything into one file. The lowering shape above is the contract the compiler must emit once the inline form is supported; when that happens, the H.* constants for scoped modifiers use canonical scoped ids, and Content/common/modifiers.secs will lose those entries.
Mod scope note. Template-scoped modifier identity is derived from the resolved template identity plus the nested modifier's canonical id segment. Two mods that each declare template<Building> Watchtower { modifier Alertness { ... } } collide at the template identity level first, so SECS0602 fires on the duplicate template declaration before the merger ever inspects the nested modifier slot. Once the template conflict is resolved (for example, one mod uses a mod operation against Watchtower and the other declares Watchtower2), the nested modifier inherits the surviving template's scoped identity and no longer collides against the other template's nested modifier. A mod that patches a base template's scoped modifier (inject modifier Barracks.VeteranGarrison { Garrison += 20; }) writes to the per-slot modifier_effect_bundle slot identified by the scoped hash; the merger applies it as a normal effect-bundle patch. Cross-reference 06-overrides-and-modding.md § "Template-scoped modifier slot schema" and § "Slot schema" for the full slot-level rules.
Notes:
- Migrating Valenar's existing top-level DungeonAuraOfDread / DungeonClearedReward to inline-scoped form is a post-compiler exercise — until the compiler emits scoped modifiers, the stand-in keeps the top-level form to avoid manual hash maintenance.
Dynamic effects (formula-backed)
What: A modifier effect whose value is a runtime expression — Channel OP <expression> rather than Channel OP <constant>. The expression is lifted into a Formula_*.cs and the effect carries IsDynamic = true, FormulaId = H.Formula_*. This is how cross-scope derived-rate effects land in SECS: a Temple that scales settlement Morale by nearby population, an Irrigation modifier that lifts neighbouring Fertility by a formula over region biome, or a law that derives FoodCapacity from current infrastructure. These are modifier attachments carrying dynamic effects, not template channel sources on a different scope. The modifier declaration names the rule; the formula evaluates it at resolution time; the attachment site (scope.add_modifier X) decides the target. Dynamic template-body channels, such as Farm's location-root FoodOutput, use the same formula delegate shape but register as intrinsic sources on the activation root.
SECS:
// Content/common/modifiers.secs — Fortified mixes a static and a dynamic effect
modifier Fortified
{
translation = "Fortified";
tags = Buff, Military;
reads { Garrison }
Morale += 5; // static additive
Defense += resolve(Garrison) / 2; // dynamic additive
Garrison *= 110%; // static multiplicative
}
Compiles to:
// Generated/Declarations.cs
new()
{
ModifierId = H.Fortified, Translation = "Fortified",
Tags = [H.Tag_Buff, H.Tag_Military],
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Morale, Mode = EffectMode.Additive, IntValue = 5 },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Defense, Mode = EffectMode.Additive, IsDynamic = true,
FormulaId = H.Formula_FortifiedDefense },
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Garrison, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 },
],
},
Why this shape: The dynamic flag lives on the effect, not the modifier — one modifier can mix static and dynamic effects, as Fortified does. IsDynamic = true tells ChannelResolver to call registry.GetFormulaInt(effect.FormulaId)(target, binding.Owner, EntityHandle.Null, host) instead of reading effect.IntValue. The lowering leaves the other value slots at their zero defaults on dynamic effects; they are unused. Note that the modifier also contains reads { Garrison }, which does not participate in the effect itself — it is picked up during registration for cycle detection. Under the D-Hybrid rule the explicit reads { Garrison } block on Fortified is a legal superset — the compiler would auto-extract Garrison from the static-literal resolve(Garrison) in Defense += resolve(Garrison) / 2 anyway; the block survives as an author annotation (and currently matches the hand-written stand-in). See §"reads { } dependency hint — D-Hybrid rule" below for the full rule.
Notes: None.
Numeric type expansion for modifier effects
What: ModifierEffect stores the effect value in a type-discriminated slot chosen from the bound target declaration's ValueType: IntValue, LongValue, FloatValue, DoubleValue, or BoolValue. Multiplicative effects are the one deliberate exception: every numeric *= effect stores its factor in DoubleValue, even when the target is int, long, or float.
Why this shape: Additive and HardOverride values are actual target values, so the compiler picks the slot from the bound target declaration's ValueType: int channels and fields land in IntValue, long in LongValue, float in FloatValue, double in DoubleValue, and bool in BoolValue. Multipliers are not target values; they are factors applied to a numeric value, so DoubleValue carries the factor for all numeric targets. This avoids coercing bool through int or float and avoids using FloatValue as an accidental catch-all. Dynamic effects (IsDynamic = true) continue to leave the flat-value slots at their zero defaults — the formula returns the appropriate typed value at resolution time via the typed formula overloads.
Notes: None.
Modifier effect preview
What: Modifier effects are previewable directly from ModifierDeclaration.Effects data without analyzing any code body. The runtime exposes EffectPlanner.FromModifierDeclaration(target, modifierDeclaration, durationTicks) to translate a declaration into one or more PredictedEffect rows for use in an EffectPlan (see 04-behavior.md § Preview). EffectPlanner.FromModifierEffect(target, effect, durationTicks) translates a single effect for callers that need row-level control.
Translation rules:
EffectMode.Additivelowers toPredictionOp.Add.EffectMode.Multiplicativelowers toPredictionOp.Multiply.EffectMode.HardOverridelowers toPredictionOp.Set.- Static effects (
IsDynamic = false) carry the typed value asPredictedEffect.Amountin priorityIntValue>LongValue>FloatValue>DoubleValue>BoolValue. Int/long values keepConfidence = Exact; float/double values fall back to a long-typed encoding (multiplicative ratios are scaled ×100 to preserve the percentage on the integer wire) and downgrade toConfidence = Estimatedto flag the precision loss. - Dynamic effects (
IsDynamic = true) carryFormulaIdandConfidence = Estimated; the formula cannot be resolved at preview time without a live owner context, so the consumer must treat the predicted amount as unknown. - Effect
DurationTicksis the modifier-binding duration the caller passes in toEffectPlanner.FromModifierDeclaration. Probability = 1.0for unconditional bindings. Triggered modifiers may emitProbability < 1.0once a future wave introduces trigger-aware preview.
Why structured data instead of body analysis: ModifierDeclaration.Effects is already a structured array of (TargetKind, TargetId, EffectMode, value) tuples. Translating it to PredictedEffect rows is a 1:1 mechanical mapping. Method-body analysis (the alternative) would have to recover this same data from a more general representation — strictly less reliable than reading the structured table directly.
Notes: None for Wave 2. Trigger probability and stack-count effects are open for the wave that introduces trigger-aware preview.
Aggregate channel sources — Children.Sum et al.
What: A channel declared on Parent.FieldName (where Parent.FieldName is a ScopedList<T> or ScopedDictionary<TKey, T> field) can use LINQ-flavored aggregate expressions as its source instead of a host field or modifier pile. These expressions compute across the child entities of the named collection scope and are cached per tick in ChannelCache. The full collection-scope model and channel placement rules live in 08-collections-and-propagation.md § "Collection-scope channels".
Available aggregate operators:
| Expression | Meaning |
|---|---|
Children.Sum(Channel) | Sum the resolved value of Channel across all children. |
Children.Min(Channel) | Minimum resolved value. |
Children.Max(Channel) | Maximum resolved value. |
Children.Average(Channel) | Arithmetic mean of resolved values. |
Children.Count() | Count of children in the collection. |
Children.Count(predicate) | Count of children satisfying a structural predicate: has_tag X, has_template X, or has_contract X. |
Children.Where(predicate).<aggregate>(...) | Filter the child set before aggregating. Predicates must be structural: has_tag X, has_template X, or has_contract X. |
.Where(predicate) accepts only structural predicate operators (has_tag X, has_template X, has_contract X). Aggregate filters do not accept arbitrary lambda/value predicates such as b => b.Level > 5; the compiler rejects those instead of lowering them as runtime closures. This matches propagates_to where so both collection-filtering surfaces bind through the same declared structural predicate table.
Cache invalidation: The result is cached per tick in ChannelCache and dirties on (a) any child's resolved value of the aggregated channel changing, and (b) any child joining or leaving the collection scope. No manual invalidation is needed.
SECS:
// (Illustrative — game vocabulary is chosen by the host.)
channel int TotalCapacity
{
on Parent.Holdings;
kind = Contributed;
source = Children.Sum(StorageCapacity);
}
channel int ActiveSettlements
{
on Parent.Provinces;
kind = Contributed;
source = Children.Count(has_contract Settlement);
}
channel int FilteredProduction
{
on Parent.Districts;
kind = Contributed;
source = Children.Where(has_tag Food).Sum(Production);
}
Compiles to:
// Generated/Declarations.cs — channel declaration with aggregate source
new ChannelDeclaration
{
ChannelId = H.TotalCapacity,
Kind = ChannelKind.Aggregate,
CollectionFieldId = H.Holdings, // from `on Parent.Holdings`
AggregateSource = new()
{
Op = AggregateOp.Sum,
ChildChannelId = H.StorageCapacity,
Predicate = PropagationFilter.None,
},
},
new ChannelDeclaration
{
ChannelId = H.ActiveSettlements,
Kind = ChannelKind.Aggregate,
CollectionFieldId = H.Provinces,
AggregateSource = new()
{
Op = AggregateOp.Count,
Predicate = PropagationFilter.ForContract(H.Settlement),
},
},
new ChannelDeclaration
{
ChannelId = H.FilteredProduction,
Kind = ChannelKind.Aggregate,
CollectionFieldId = H.Districts,
AggregateSource = new()
{
Op = AggregateOp.Sum,
ChildChannelId = H.Production,
Predicate = PropagationFilter.ForTag(H.Tag_Food),
},
},
Why this shape: Aggregate expressions replace a custom system whose only job is "iterate children, sum a channel, write the result". The declaration carries the operator and the target channel; the engine handles caching, invalidation, and resolution. Making the cache dirty on child join/leave (not just on value change) is necessary because a child's presence or absence is itself part of the aggregate. The LINQ-flavored syntax is intentional: it parallels C# collection pipelines so authors familiar with LINQ can read aggregate sources at a glance.
Notes: None. Full semantics — caching contract, invalidation graph edges, save/load behaviour — in 08-collections-and-propagation.md § "Aggregate channel resolution".
Channel snapshot — track_prev and previous-tick bridge reads
What: A channel declaration may include track_prev = true. When set, the engine snapshots the channel's resolved value at the END of each tick and exposes it to generated/runtime code through the host bridge ReadPrevTick* family. A hypothetical standalone prev_tick helper is not committed SECS source syntax, and older ctx.PrevTick wording is not the live runtime API contract.
Previous-tick reads are the complement to resolve(Channel): resolve returns the current tick's resolved value, while ReadPrevTick* returns last tick's final value for tracked channels. Generated formulas/systems may use the bridge/runtime read path when lowering a committed higher-level construct; authors should not write standalone previous-tick helper calls in .secs source.
SECS:
channel int Morale
{
kind = Contributed;
track_prev = true; // snapshot at end-of-tick
min = 0; max = 100;
}
// Usage in generated/runtime code that needs the previous snapshot:
modifier MoraleMomentum
{
translation = "Morale Momentum";
reads { Morale }
// Pseudocode only: current live source has no standalone previous-tick expression.
Production += /* resolve(Morale) - previous Morale bridge read */ / 5;
}
Compiles to:
// Generated/Declarations.cs — channel declaration
new ChannelDeclaration
{
ChannelId = H.Morale,
Kind = ChannelKind.Contributed,
TrackPrev = true,
HasMin = true, Min = 0,
HasMax = true, Max = 100,
},
// Generated/SecsModule.cs — registration
registry.RegisterFormula(H.Formula_MoraleMomentum, Formula_MoraleMomentum.Evaluate,
readsChannels: [H.Morale]);
// Generated/Formulas/Formula_MoraleMomentum.cs
public static int Evaluate(EntityHandle target, EntityHandle owner, EntityHandle captured1, ISecsHostReads host)
{
var current = host.ResolveChannelInt(target, H.Morale);
var previous = host.ReadPrevTickInt(target, H.Morale); // prev-tick snapshot slot
return (current - previous) / 5;
}
Why this shape: track_prev = true is an opt-in so the engine does not allocate a previous-tick cache slot for every channel. The snapshot happens at the END of each tick (after SyncDirty) so the previous-tick value is the full resolved-and-clamped result. The runtime bridge read should either return that prior value or fail when the channel was not tracked/snapshotted; a source-level diagnostic for a future author syntax remains a compiler/analyzer task, not a live parser contract.
Diagnostic:
| Code | Trigger | Message |
|---|---|---|
SECS0830 | Previous-tick bridge/helper read targets channel S where S does not carry track_prev = true | previous-tick read targets channel '<S>' which does not declare track_prev = true. Add track_prev = true to the channel declaration, or remove the previous-tick read. |
Notes: None. Snapshot mechanics and the ReadPrevTickInt host-interface contract live in 08-collections-and-propagation.md § "previous-tick snapshots".
Formula lowering
What: Every runtime expression that produces a channel-typed value — whether from a dynamic template channel (channel int FoodOutput { return ... }, see 02-templates.md) or from a dynamic modifier effect (above) — lifts into a public static class Formula_<Name> with a single Evaluate method. Registered on SecsRegistry via RegisterFormula.
SECS:
// Case 1: dynamic template channel (from 02-templates.md)
// Content/buildings/district/farm.secs
channel int FoodOutput { return (int)(5 * @Location.Fertility / 33); }
// Case 2: dynamic modifier effect (from this doc)
// Content/common/modifiers.secs
modifier Fortified
{
reads { Garrison }
Defense += resolve(Garrison) / 2;
}
Compiles to:
// Generated/Formulas/Formula_FarmFood.cs — template channel formula
namespace Valenar.Generated.Formulas;
public static class Formula_FarmFood
{
public static int Evaluate(EntityHandle target, EntityHandle owner, EntityHandle captured1, ISecsHostReads host)
{
var location = host.WalkScope(owner, H.Location);
if (location.IsNull) return 5;
var fertility = host.ReadInt(location, H.Location, H.Fertility);
return Math.Max(1, (int)(5.0 * fertility / 33));
}
}
// Generated/Formulas/Formula_FortifiedDefense.cs — modifier effect formula
namespace Valenar.Generated.Formulas;
public static class Formula_FortifiedDefense
{
public static int Evaluate(EntityHandle target, EntityHandle owner, EntityHandle captured1, ISecsHostReads host)
{
var garrison = host.ResolveChannelInt(target, H.Garrison);
return garrison / 2;
}
}
Why this shape: One unified delegate shape for both kinds of dynamic value. The four parameters are:
target— the entity whose channel is being resolved. For a dynamic template channel, this is the entity the channel belongs to (e.g., the settlement whoseFoodOutputis being computed). For a dynamic modifier effect, this is the binding's target.owner— the runtime owner context. For a dynamic template channel, this is the template instance that registered the intrinsicChannelSource(e.g. the farm building). For a dynamic modifier effect, this is the binding's owner. Scope walks start fromowner.captured1— reserved for the second captured scope entity used inroot/prevre-evaluation. NOT YET IMPLEMENTED — scope stack capture is designed but not wired; bothChannelResolver.ResolveIntUnchecked(src/SECS.Engine/Resolution/ChannelResolution.cs:261) and the modifier effect call site (:310) passEntityHandle.Nullfor the captured slot and the trigger contract (see below) carries two captured slots for forward compatibility.host— theISecsHostReadsview used for scope walks (host.WalkScope) and host-field reads (host.ReadInt).
Registration happens in Generated/SecsModule.cs. Each RegisterFormula call carries a readsChannels: argument populated by the D-Hybrid rule — static resolve() arguments are auto-extracted by the compiler; dynamic ones must be declared in a reads { } block on the formula body. See §"reads { } dependency hint — D-Hybrid rule" below for the full rule, the three SECS source shapes (static-only, dynamic, mixed), the three-code diagnostic surface (SECS0302, SECS0303, SecsFormulaReadSetViolation), and the runtime validation path in ChannelResolver. Formulas live under Generated/Formulas/ with one file per Formula_* class — easy to navigate, trivial to codegen.
Notes:
- Scope-stack capture for
root/previn dynamic-effect re-evaluation is designed but NOT YET IMPLEMENTED (both captured slots areEntityHandle.Nullat every call site). The parameter slot is reserved.
Trigger lowering
What: Every boolean condition in when <cond> or while <cond> lifts into a public static class Trigger_<Name> with a single Evaluate method returning bool. Registered on SecsRegistry.RegisterTrigger.
SECS:
// Content/buildings/district/tavern.secs — while form
method void OnBuilt()
{
add_modifier FestivalSpirit when @Settlement.resolve(Morale) >= 80;
}
// Content/events/settlement/construction/celebration_bonus.secs — both while and when forms
@Settlement.add_modifier FestivalSpirit when @Settlement.resolve(Morale) >= 80 duration 3;
@Settlement.add_modifier Thriving while @Settlement.resolve(Morale) >= 80;
Compiles to:
// Generated/Triggers/Trigger_MoraleGte80.cs — canonical trigger, no captured entities
namespace Valenar.Generated.Triggers;
public static class Trigger_MoraleGte80
{
public static bool Evaluate(EntityHandle target, EntityHandle captured0, EntityHandle captured1, ISecsHostReads host)
{
return host.ResolveChannelInt(target, H.Morale) >= 80;
}
}
// Generated/Triggers/Trigger_MoraleGte60.cs — uses the captured0 slot
public static class Trigger_MoraleGte60
{
public static bool Evaluate(EntityHandle target, EntityHandle captured0, EntityHandle captured1, ISecsHostReads host)
{
// When used with captured root: target = location, captured0 = settlement.
// The trigger checks morale on the settlement (captured0), not the location.
if (captured0.IsNull) return false;
return host.ResolveChannelInt(captured0, H.Morale) >= 60;
}
}
// Generated/Triggers/Trigger_FoodLt0.cs — simple target-only check
public static class Trigger_FoodLt0
{
public static bool Evaluate(EntityHandle target, EntityHandle captured0, EntityHandle captured1, ISecsHostReads host)
{
return host.ResolveChannelInt(target, H.Food_Channel) < 0;
}
}
Why this shape: Both when (condition-on-add) and while (continuous) produce identical Trigger_*.cs files — a trigger is just a (target, captured0, captured1, host) → bool function. The difference lives at the call site, not in the trigger: when inlines the trigger into a C# if around AddModifier; while passes H.Trigger_* as the triggerId argument to AddModifier and the binding update pass calls the same Evaluate method every tick.
The captured0 / captured1 slots are the captured-entity convention: when a while trigger references @root, @prev, or an outer/saved scope that is not the binding target, the compiler captures those entities into the binding at the add_modifier call site and the trigger reads them from the fixed slots. This keeps the signature fixed regardless of which scope references the trigger uses. Captured entities are references only: they do not own the binding, and they do not extend any lifetime. If a trigger requires a captured entity and the slot is Null, the generated trigger returns false.
Notes: None.
reads { } dependency hint — D-Hybrid rule
What: The read-set of a formula — the list of channels it calls resolve() on — is authoritative metadata for cycle detection (ValidateDependencies()) and for dirty-tracking / cache invalidation (ChannelCache). The compiler populates the readsChannels: argument on RegisterFormula via the D-Hybrid rule, which has one consistent shape driven by the form of each resolve() argument in the formula body:
- Static literal
resolve(ChannelName)— compiler auto-extracts the channel id into the formula's read set. An explicitreads { }block is optional in source; when the author supplies one, it must be a superset of the auto-extracted set. Channels listed in the block that noresolve()in the body references emit warningSECS0303. The reverse (a staticresolve(X)whoseXis missing from a suppliedreads { }) is not itself an error — auto-extraction always unions the block with the set of literal resolves, so the finalreadsChannelsincludesXregardless. The block, when supplied, is purely additive documentation for static-only formulas; the spec's "missing is an error" rule applies to the dynamic case (rule 2), where the block is required and under-declaration cannot be papered over by auto-extraction. - Dynamic
resolve(<expression>)— anyresolve()argument that is not a bare channel identifier (runtime-computed id, value held in a variable, result of a helper call, element of a collection) requires an explicitreads { channel1, channel2, ... }block enumerating every channel the formula may read at runtime. Over-approximation is legal (extras areSECS0303); a missing channel emits compile errorSECS0302at theresolvecall site. Omittingreads { }on a formula that contains a dynamicresolve()is alsoSECS0302— the block is required, not optional, once any dynamic read appears. - Mixed — static literals are auto-extracted; dynamic reads must be covered by the explicit
reads { }. The finalreadsChannelsarray isauto-extracted ∪ declared. - Runtime validation (debug-build only, optional in release):
ChannelResolverchecks everyresolve(ChannelId)call during formula evaluation against the formula's registeredreadsChannelsarray. Mismatch throwsSecsFormulaReadSetViolationwith the formula name, the missing channel id, and a one-line suggestion. Release builds skip the check.
SECS:
// Case 1: static-only formula — no reads { } needed (compiler auto-extracts).
template<Building> Farm
{
channel int FoodOutput
{
return resolve(Morale) > 50 ? 12 : 10;
// Auto-extracted read set: { Morale }. No `reads { }` required.
}
}
// Case 2: dynamic formula — reads { } required.
// The resolve target is a runtime value (loop variable holding a channel id).
channel int ResourceTotal
{
reads { Gold, Silver, Copper } // required — resolve takes a variable
int total = 0;
foreach (var s in settlement.ResourceStats)
total += resolve(s);
return total;
}
// Case 3: mixed — static literal auto-extracted, dynamic channels in reads { }.
channel int MixedSample
{
reads { DynamicChannel1, DynamicChannel2 } // only the dynamic side needs declaring
return resolve(StaticA) + resolve(computedId);
// Final read set: { StaticA, DynamicChannel1, DynamicChannel2 }
// StaticA — auto-extracted
// DynamicChannel1, DynamicChannel2 — declared
}
Compiles to:
// Case 1 — static-only: readsChannels populated purely from auto-extraction.
registry.RegisterFormula(H.Formula_FarmFood, Formula_FarmFood.Evaluate,
readsChannels: []);
registry.RegisterFormulaContribution(H.Formula_FarmFood, H.FoodOutput);
// Case 2 — dynamic: readsChannels copied verbatim from the reads { } block.
registry.RegisterFormula(H.Formula_ResourceTotal, Formula_ResourceTotal.Evaluate,
readsChannels: [H.Gold, H.Silver, H.Copper]);
registry.RegisterFormulaContribution(H.Formula_ResourceTotal, H.ResourceTotal);
// Case 3 — mixed: readsChannels = union of auto-extracted and declared.
registry.RegisterFormula(H.Formula_MixedSample, Formula_MixedSample.Evaluate,
readsChannels: [H.StaticA, H.DynamicChannel1, H.DynamicChannel2]);
registry.RegisterFormulaContribution(H.Formula_MixedSample, H.MixedSample);
// Modifier-effect formula (static) — Fortified from Content/common/modifiers.secs.
// Auto-extracted from `Defense += resolve(Garrison) / 2;`. The explicit
// `reads { Garrison }` in today's stand-in is a legal superset.
registry.RegisterFormula(H.Formula_FortifiedDefense, Formula_FortifiedDefense.Evaluate,
readsChannels: [H.Garrison]);
// Contribution for modifier-effect formulas is auto-collected from
// ModifierDeclaration.Effects during RegisterModifiers (SecsRegistry.cs:47-60);
// no explicit RegisterFormulaContribution call is needed here.
Why this shape: Four concrete scenarios drive the rule. Static literal channel reads such as resolve(Garrison) in Fortified or resolve(Morale) in HousePopulationCapStack pay zero annotation tax because the compiler sees the identifier in the AST and extracts it. Raw scope-field reads such as @Location.Fertility are not readsChannels edges because they do not call the channel resolver. Conditional-literal (if (cond) resolve(A); else resolve(B);) also extracts cleanly because both branches name channels directly. Dynamic-by-runtime-id (iterating a collection of channel ids) cannot be statically resolved without full abstract interpretation, so the rule requires the author to name the set upfront — the cost of being wrong is a silent cycle-detection / dirty-tracking bug that only surfaces when the specific resolution chain is exercised. Mod-extended static references (a mod adds a new resolve(X) call but the base formula already declared reads { X }) stay legal because readsChannels is an over-approximation contract. The runtime validation in ChannelResolver (debug builds only) is the safety net that catches the remaining case — an author declared reads { A, B } but the body actually reads C at runtime — with a structured exception pointing at the specific call site rather than a silent graph corruption. The rule is upgrade-compatible: if the compiler later gains interprocedural analysis that can trace into helper methods, auto-extraction gets smarter without breaking existing reads { } declarations (extras stay legal, just redundant).
Mod scope note. D-Hybrid runs on the merged formula body, so mod-added channels are first-class: a mod that introduces channel int Blight and writes resolve(Blight) inside a new formula auto-extracts Blight exactly as base would. An inject template Farm { channel FoodOutput { return @Location.Fertility - resolve(Blight); } } patch writes the template_dynamic_channel_formula slot, and the slot merger emits the resulting declaration-layer formula body into the same Formula_FarmFood.cs file; this is not a late-bound runtime override. Auto-extraction therefore runs on that merged body and produces readsChannels: [Blight]. The merger rewrites the matching RegisterFormula(H.Formula_FarmFood, ..., readsChannels: [...]) line in SecsModule.cs so the cycle detector and the dirty-tracking graph see the merged slot result's channel read set, not the base declaration's. ValidateDependencies() runs after Phase 3 merge on the merged registry (see § "ValidateDependencies()" below); a mod-introduced edge that creates a cycle is reported by the merger's conflict report as a cycle entry (not a slot conflict) per 06-overrides-and-modding.md § "Conflict report shape". The diagnostic codes (SECS0302, SECS0303, SecsFormulaReadSetViolation) all fire identically across source sets — there is no separate "mod" check; the merged AST is the only AST.
The RegisterFormula(ulong, delegate, ulong[]?) overload (src/SECS.Engine/SecsRegistry.cs:110-115) stores the read list in formulaDependencies[formulaId]. Two sides of the graph are built separately: the output side ("formula F can affect channel X") and the dependency side ("formula F reads channels A, B"). Modifier-effect formulas get their output side auto-filled during RegisterModifiers (SecsRegistry.cs:47-60); intrinsic channel-source formulas need an explicit RegisterFormulaContribution(formulaId, channelId) call. The method name is historical/runtime-facing; it records dependency-graph output metadata, not an author-level contribution verb.
Runtime read-set validation
Where it runs: ChannelResolver.ResolveIntUnchecked (src/SECS.Engine/Resolution/ChannelResolution.cs:211-369), the corresponding fast path ResolveIntFastUnchecked (:544-634), and the float / bool siblings (:371-483, :528-542). When a formula is about to be invoked (registry.GetFormulaInt(...) / GetFormulaFloat(...)), the debug-build check looks up registry.GetFormulaDependencies(formulaId) and installs a thread-local "current formula" context. Every resolve(ChannelId) reentry into ChannelResolver during that formula's evaluation checks whether ChannelId is in the declared array; a miss throws SecsFormulaReadSetViolation.
Exception shape:
SecsFormulaReadSetViolation: formula 'Formula_MixedSample' called resolve(Gold)
at runtime, but this channel is not in the declared readsChannels = [StaticA, DynamicStat1, DynamicStat2].
Widen the formula's reads { ... } block to include 'Gold', or change the
resolve argument to a static literal identifier.
Cost: One dictionary lookup and one array-contains check per resolve() call on the debug build; zero overhead on release (the check compiles out under #if DEBUG or an equivalent flag). The engine already walks formulaDependencies during ValidateDependencies() at startup, so the data is live; the runtime path reuses it.
Wave 7 registry static-analysis variant of SECS0303. SecsRegistry.Validate() (added in the Wave 7 behaviour-layer refactor) emits SECS0303 at registration time when a formula declares readsChannels but has no contributions and is not wired into any modifier effect — the read set is inert metadata that triggers cache invalidation for nothing. This is the runtime-feasible portion of the master-table SECS0303 ("reads {} declares a channel no resolve() call references"); the full body-level cross-check stays in the Roslyn analyser layer, but the inert-metadata variant catches the most common authoring mistake (registered the formula, forgot the corresponding channel-source contribution or modifier hookup) cheaply at startup. Implementation: src/SECS.Engine/Diagnostics/FormulaStaticAnalysis.cs.
Mod scope note. A modded shipping build is the case where the debug-only check is most needed — a malformed mod whose formula patch drifts past its declared reads { } corrupts dirty-tracking silently in release. To catch this without paying the per-resolve cost on every player's machine, the merger supports a --strict-readsets CLI flag (analogous to --strict-conflicts per 06-overrides-and-modding.md § "Strict mode") that compiles the runtime check into the release-build engine for one or more named mods. When set, every resolve() call inside formulas owned by the named mods runs the array-contains check; base formulas and unflagged mods stay on the zero-overhead path. The default is off; mod authors enable it on themselves during development. The flag has no effect on compile-time SECS0302 and SECS0303, which fire identically in every build.
Supersedes earlier manual-override framing
Earlier drafts described the reads directive as "an optional manual override for cases where the dependency is not obvious from the code" and framed auto-extraction as the default with reads as the escape hatch. That framing is superseded by the D-Hybrid rule above. Under D-Hybrid, reads { } is:
- Optional for static-only formulas (auto-extraction covers them; a supplied block must be a superset or it warns / errors per
SECS0303/SECS0302). - Required for any formula containing a dynamic
resolve(<expression>)(omitting it isSECS0302). - Additive in the mixed case (declared block ∪ auto-extracted literals → final
readsChannels).
The archive's "optional manual override" framing does not distinguish the dynamic case; the D-Hybrid rule makes the distinction explicit and routes each shape through a diagnostic that matches the author's intent. No change to the archive text is required — this design doc is authoritative on lowering, and the archive is superseded on this point.
Notes:
- Interprocedural analysis for shared helper methods. Today, a formula that calls a helper method whose body contains a
resolve()counts theresolveas "dynamic" from the caller's perspective, because the compiler's auto-extractor sees only the call site, not the helper body. A future compiler pass that inlines helper bodies (or builds a per-helper read-set summary and composes them) would let static helpers' resolves be auto-extracted transitively. Not a commitment; the D-Hybrid rule's "dynamic → explicitreads { }" branch covers the interim case without a silent-miss class.
The contribution primitive: why it's not in SECS
Authors arriving from ECS / DOTS / bespoke engine backgrounds may expect a "contribute" verb — a way for a template to register an anonymous additive value on a higher-scope channel from inside its own body (e.g. contribute +5 to settlement.Morale), or a direct call to something like RegisterChannelSource on a cross-scope channel. SECS deliberately omits that verb. Cross-scope effects go through named modifiers (see opening section). Reasons in full live in 01-world-shape.md § "Model: intrinsic sources are own-root, modifiers cross scopes"; the short form:
- Named modifiers give mod overrides and tooltip breakdowns something to attach to; anonymous contributions do not.
- One mechanism for "source affects channel" is cheaper to teach and cheaper to implement than two.
- The Paradox-family precedent (CK3 / EU4 / Victoria 3) is stackable modifiers; SECS follows that path.
The engine still exposes RegisterChannelSource / RegisterDynamicChannelSource on the CommandBuffer surface — these are the primitives the six-phase pipeline reads for Contributed intrinsic sources. The language shape that legitimately uses them is per-instance channel declarations on a template activation root: channel int X = N; or channel int X { return ... } inside a template<T> Name body (see 02-templates.md § "Per-instance flat channels" and § "Per-instance derived channels"). That is the one place a template registers a channel source, and the target is always scope.Root for the template's contract — never a cross-scope push. Anything that looks like a cross-scope push from a template body (e.g. a house writing into a settlement channel from a location-rooted building) must be lowered as a modifier attachment, not as a template channel source on a different scope.
Collection-scope channel declaration — on Parent.FieldName
What: A top-level channel declaration may be prefixed with on Parent.FieldName to declare that the channel lives on the anonymous collection scope located at field FieldName of the Parent entity type, rather than on Parent itself. This is required when the channel's source uses Children.Sum(...) or another aggregate expression that draws from children of that collection.
Without the on prefix, the channel resolver looks for the channel on the entity it is asked to resolve — the collection scope is addressed implicitly through the field path. The on prefix makes the target scope explicit in source, which lets the compiler validate that FieldName is a ScopedList<T> or ScopedDictionary<TKey, T> field and that aggregate source operators are used only on collection-scope channels.
SECS:
// (Illustrative — game vocabulary is chosen by the host.)
channel int TotalPopulation
{
on Parent.Settlements; // channel lives on the collection scope at Parent.Settlements
kind = Contributed;
source = Children.Sum(Population);
}
The on Parent.FieldName form is purely a declaration-scope directive. It does not affect attachment syntax, tooltip attribution, or modifier binding; modifiers that target TotalPopulation still bind to the entity whose Settlements field hosts the collection scope.
Compiles to:
// Generated/Declarations.cs
new ChannelDeclaration
{
ChannelId = H.TotalPopulation,
Kind = ChannelKind.Contributed,
CollectionFieldId = H.Settlements, // derived from `on Parent.Settlements`
AggregateSource = new()
{
Op = AggregateOp.Sum,
ChannelId = H.Population,
},
},
Why this shape: Keeping the on prefix in source (rather than inferring it from the aggregate source expression) makes the declaration self-documenting: readers see immediately that this channel is not on the usual entity scope. It also gives the compiler a clear hook to verify that the named field is a collection type and to reject aggregate sources on non-collection channels. The full collection-scope placement model — including how ChannelResolver routes resolution for collection-scope channels — lives in 08-collections-and-propagation.md § "Collection-scope channels".
Notes: None.
Current invariant: cross-scope building effects use modifiers
The current stand-in follows the committed model: settlement-scope Building effects are named modifier attachments to settlement, while location-scope output channels such as Farm FoodOutput remain intrinsic template channel sources on the activation root. Storehouse-style storage uses capacity channels such as FoodCapacity; accumulative stockpiles such as Food still ignore modifier phases.
The resolved Valenar bug history lives in docs/design/FUTURE_WORK.md § 3.1-3.2 and the historical audits. The live contract here is the channel-and-modifier invariant, not the old stand-in bug list.
The 6-phase resolution pipeline (recap)
This is the canonical sequence ChannelResolver runs for every channel read. This recap is the live lowering contract and includes the committed HardOverride phase. The same phase shape applies to int, long, float, double, and bool specialisations, with bool restricted to HardOverride effects.
Phase 1 — Base
Phase 2 — Additive
Phase 3 — Multiply
Phase 4 — HardOverride
Phase 5 — Clamp
Phase 6 — Return
| Phase | Engine code (ChannelResolver.ResolveIntUnchecked) | Data source | Lowered from |
|---|---|---|---|
| 1. Base | host.ReadInt(target, scopeId, fieldId) for Base/Accumulative; for Contributed, start at 0 and add channelSources entries registered for this target | ChannelDeclaration.Kind + host field; intrinsic ChannelSource entries for a template's activation root (02) | per-instance channel int X = N / channel int X { return ... } inside a template body (02 — activation root only); ChannelKind in top-level channel decl (01) |
| 2. Additive | iterate bindingStore.GetBindingsForTarget(target); for each active binding, for each Mode == Additive effect matching channelId, read effect.IntValue (or call Formula_* when IsDynamic) and sum | ModifierDeclaration.Effects[] + active ModifierBinding list | modifier X { Channel += N } / Channel += resolve(Y) (this doc) |
| 3. Multiply | same iteration, Mode == Multiplicative; compound via multiplicativeProduct *= value | ModifierDeclaration.Effects[] | modifier X { Channel *= N% } (this doc) |
| 4. HardOverride | same iteration, Mode == HardOverride; replace the post-multiply value with the effect value. Last applied wins (in modifier-binding-registration order — the same order phases 2 and 3 already use). Numeric paths read the target-typed value slot (IntValue, LongValue, FloatValue, or DoubleValue); bool paths read BoolValue and reject additive/multiplicative effects. | ModifierDeclaration.Effects[] with Mode == HardOverride | modifier X { Channel = N } (this doc, §"HardOverride") |
| 5. Clamp | compare final value to channelDeclaration.Min / Max; set result.Clamped if enforced. Clamp runs after HardOverride, so a HardOverride result that exceeds the declared range is still clamped. | ChannelDeclaration.HasMin / HasMax | channel int X { min 0; max 100 } in top-level channel decl (01) |
| 6. Return | write result.FinalValue (int cast from (int)((baseValue + additiveTotal) * (double)multiplicativeProduct) after clamp, or the HardOverride value when one applied), write back into ChannelCache | — | — |
Accumulative channels skip phases 2, 3, and 4 — modifiers never apply to them (ChannelResolver.ResolveIntFastUnchecked, the if (channelDeclaration.Kind == ChannelKind.Accumulative) return Clamp(...) short-circuit at src/SECS.Engine/Resolution/ChannelResolution.cs:582). Decay multiplies into the additive and multiplicative gathers per-binding; it does not add a new phase. HardOverride decays identically in the additive case (the replacement value scales by decayFactor) for numeric channels; for bool channels decay has no effect — the replacement either applies or it does not. The pipeline order is fixed.
Effective template values use the same six phase names but run in a separate template-value resolver over TargetKind == TemplateField effects. The base phase reads TemplateEntry.GetFieldInt / GetFieldFloat / GetFieldBool; the additive/multiply/HardOverride phases scan modifier bindings attached to the caller-supplied normalized context chain; the clamp phase reads TemplateFieldDeclaration; the return phase does not write to ChannelCache, DirtySet, or host fields. This is intentional: field modifiers piggyback on modifier declarations and bindings, not on channel storage or dirty tracking. The full source-level context-chain rules and cache key/invalidation rules are specified in 05-expressions.md § "Effective template value read".
Resource model note: Resource amount, rate, and capacity are separate targets. An amount such as Food or Gold is Accumulative and ignores modifier phases entirely. A rate such as FoodOutput is a normal modifier-driven channel. A capacity such as FoodCapacity is another normal channel, usually Contributed when Storehouses, laws, and location bonuses aggregate into it. A Storehouse therefore attaches a StorehouseFoodCapacity modifier that targets FoodCapacity, not Food. The production/spending system resolves FoodCapacity and clamps the delta it applies to the Food stockpile; the channel resolver never treats capacity as an implicit property of the amount channel. See 01-world-shape.md § "Resource amount, capacity, and flow" for the declaration-side model.
Mod scope note. The pipeline is identical for base-authored and mod-authored modifiers — ChannelResolver iterates the merged bindingStore list with no awareness of source-set provenance. A modifier from mod A and a modifier from mod B contribute to phases 2-4 in the same loop as base modifiers; their ordering is modifier-binding-registration chronological (the order CommandProcessor processed each AddModifier command), which depends on system phase + system frequency + entity iteration order at runtime, not on compile-time mod load order. Specifically for HardOverride (phase 4), where last-applied wins per modifier-binding-registration order: when mod A's StarvationCap (FoodCapacity = 10) and mod B's AbundanceCap (FoodCapacity = 100) both bind to the same settlement, the winner is whichever binding was registered most recently in the binding store. The result is deterministic — the same starting state plus the same tick sequence produces the same winner — but is not predictable from mod load order alone; mod authors who care about HardOverride tie-breaking should not depend on stacking semantics. The deferred priority N field (see the deferred notes under § "Effect modes: Additive, Multiplicative, HardOverride" and docs/design/FUTURE_WORK.md) is the future commitment for explicit author-controlled tie-breaking; until it lands, HardOverride stacking remains binding-registration-chronological.
ValidateDependencies()
What: A registration-time check that builds a channel-level dependency graph from the declared readsChannels / formula-output metadata and topological-sorts it with Kahn's algorithm. Returns IReadOnlyList<string> — empty on success, a cycle description otherwise. Called by the generated module after every RegisterFormula / RegisterFormulaContribution and before the tick loop starts.
SECS:
// Not a SECS construct — it is an engine contract the generated module runs.
// The .secs surface that drives it is:
// - reads { Stat1, Stat2 } blocks on modifiers
// - reads { ChannelX } blocks on template channel formulas
// - implicit formula outputs from modifier effects and intrinsic template channel sources
Compiles to:
// Generated/SecsModule.cs — after all formula registration, before template registration
registry.RegisterFormula(H.Formula_FarmFood, Formula_FarmFood.Evaluate,
readsChannels: []);
registry.RegisterFormula(H.Formula_WatchtowerDefense, Formula_WatchtowerDefense.Evaluate,
readsChannels: [H.Garrison]);
registry.RegisterFormula(H.Formula_FortifiedDefense, Formula_FortifiedDefense.Evaluate,
readsChannels: [H.Garrison]);
registry.RegisterFormula(H.Formula_HousePopCap, Formula_HousePopCap.Evaluate,
readsChannels: [H.Morale]);
registry.RegisterFormulaContribution(H.Formula_FarmFood, H.FoodOutput);
// WatchtowerDefense and HousePopCap are modifier-effect formulas now; their
// output side is auto-collected from ModifierDeclaration.Effects.
registry.RegisterModifiers(Declarations.Modifiers);
// Validate dependency graph — catches cycles at registration time
var errors = registry.ValidateDependencies();
if (errors.Count > 0)
throw new InvalidOperationException(
$"SECS dependency validation failed:\n{string.Join("\n", errors)}");
Why this shape: Cycles between channels are fatal at resolution time (the resolving HashSet in ChannelResolver throws InvalidOperationException) but the error surfaces only when the specific channel is read for the first time — which may be ticks or sessions into a game. ValidateDependencies hoists the check to startup by operating purely on declared metadata: for every formula with readsChannels declared, for every channel that formula can affect, add an edge "affected channel depends on each read channel". Run Kahn's algorithm (SecsRegistry.cs:471-567); if processed count != node count, the unprocessed nodes are the cycle participants.
Under the D-Hybrid rule the readsChannels array the algorithm reads is authoritative for every compiler-emitted formula: static-only formulas carry the compiler's auto-extracted set; dynamic formulas carry the author's declared reads { }; mixed formulas carry their union. Formula delegates are registered before modifier declarations so modifier effects can verify that dynamic formula ids exist and return the scalar kind their effect mode requires. RegisterModifiers then auto-collects modifier-effect formula outputs, and only after that does ValidateDependencies() run. The algorithm itself is unchanged — Kahn's topological sort over formulaDependencies × formulaContributions. What changed is the completeness of the input: under D-Hybrid every compiler-emitted formula has a non-null readsChannels, so ValidateDependencies now covers the full declared surface instead of only formulas that happened to carry a manual annotation. Hand-written stand-ins in examples/valenar/Generated/ that predate the compiler may still register formulas without readsChannels; the algorithm silently skips those (the continue at SecsRegistry.cs:483 on missing formula-output metadata is matched by the implicit skip when formulaDependencies has no entry). The runtime re-entry check in ChannelResolver remains the hard guarantee for those skipped paths.
Mod scope note. ValidateDependencies() runs once, on the merged registry, after the merger has applied every base + mod declaration and slot-merge patch. There is no per-mod sub-validation — a mod's contribution to the channel-dependency graph is only checked together with every other mod's contribution. The consequence is that a mod patch which is cycle-free against base alone may still introduce a cycle once mod B's separate patch of an unrelated formula closes the loop; both mods must load together for the cycle to surface, and ValidateDependencies is the single point of detection. When validation fails, the cycle description is routed through the merger's conflict report (see 06-overrides-and-modding.md § "Conflict report shape") framed as a structured cycle entry that lists every formula and channel in the cycle plus their source-set provenance — so the player's mod manager can highlight which combination of mods created the cycle. The compile-time RegisterFormula calls in Generated/SecsModule.cs are the source of readsChannels; mod-introduced edges flow through that same path because the merger emits the merged SecsModule.cs, not separate per-mod modules.
Notes: None.
End-to-end example: FestivalSpirit modifier
A capstone showing every piece of the pipeline for a simple modifier.
SECS source:
// Content/common/modifiers.secs — the declaration
modifier FestivalSpirit
{
translation = "Festival Spirit";
tags = Buff, Economic;
Production *= 110%;
}
// Content/buildings/district/tavern.secs — the call site
template<Building> Tavern
{
field int GoldCost = 25;
field int WoodCost = 20;
field int StoneCost = 5;
query bool CanBuild()
{
return @Location.BuildingCount<Tavern>() == 0
&& @Settlement.Gold >= template_field(Tavern, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(Tavern, WoodCost, BuildCostContext)
&& @Settlement.Stone >= template_field(Tavern, StoneCost, BuildCostContext)
&& @Settlement.Metal >= template_field(Tavern, MetalCost, BuildCostContext);
}
method void OnBuilt()
{
@Settlement.add_modifier TavernMoralePresence when always;
@Settlement.add_modifier FestivalSpirit when @Settlement.resolve(Morale) >= 80;
}
method void OnDestroyed() { }
method void OnDayTick() { }
}
Lowered output:
// Generated/Declarations.cs — modifier declaration
new()
{
ModifierId = H.FestivalSpirit, Translation = "Festival Spirit",
Tags = [H.Tag_Buff, H.Tag_Economic],
Effects = [new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Production, Mode = EffectMode.Multiplicative, DoubleValue = 1.1 }],
},
// Generated/Triggers/Trigger_MoraleGte80.cs — the `when` trigger
namespace Valenar.Generated.Triggers;
public static class Trigger_MoraleGte80
{
public static bool Evaluate(EntityHandle target, EntityHandle captured0, EntityHandle captured1, ISecsHostReads host)
{
return host.ResolveChannelInt(target, H.Morale) >= 80;
}
}
// Generated/Templates/Buildings/Tavern.cs — the call site (sketch; full template shape in 02)
// Inside Tavern.Activate, OnBuilt method:
public static void OnBuilt(ITemplateCommandContext context, in SecsNoArgs args)
{
var settlement = context.Host.WalkScope(context.Scope.Root, H.Settlement);
// `when` is condition-on-add: the compiler emits the if.
if (Trigger_MoraleGte80.Evaluate(settlement, EntityHandle.Null, EntityHandle.Null, context.Host))
{
context.Commands.AddModifier(
owner: context.Scope.Owner,
target: settlement,
modifierId: H.FestivalSpirit,
triggerId: 0, // `when` is evaluated and discarded
duration: -1); // permanent (until removed)
context.FlushCommands();
}
}
// Generated/SecsModule.cs — registration
registry.RegisterTrigger(H.Trigger_MoraleGte80, Trigger_MoraleGte80.Evaluate);
registry.RegisterTag(H.Tag_Buff, "Buff");
registry.RegisterTag(H.Tag_Economic, "Economic");
// Modifiers are registered after channels/template fields/formulas so effect targets
// and dynamic formula scalar kinds are validated at startup.
What happens at runtime:
- The Tavern is built.
OnBuiltruns on the Tavern entity. - The compiled code walks from the contract root-scope target to its Settlement (
context.Host.WalkScope(context.Scope.Root, H.Settlement)). Trigger_MoraleGte80.Evaluate(settlement, Null, Null, context.Host)readsMoraleon the settlement and returnstrue/false.- If true,
context.Commands.AddModifier(...)queues the binding andcontext.FlushCommands()applies it. TheCommandProcessorcreates aModifierBinding { ModifierId = H.FestivalSpirit, Owner = tavern, Target = settlement, TriggerId = 0, RemainingTicks = -1, State = Active }. If false, nothing queued —whenis one-shot. - On any subsequent
ResolveChannelInt(settlement, H.Production):- Phase 1: base. For
Productiondeclared asContributed, the phase-1 value starts at0; the host cache field is not read as a base. (IfProductionwere declared asBasewith a host field, the host's settlement.Production field is read here.) - Phase 2: iterate bindings. Find
FestivalSpirit.Productionhas no additive effect in this modifier, so the additive total is unaffected. - Phase 3: iterate bindings.
FestivalSpirit'sProductioneffect hasMode = Multiplicative, DoubleValue = 1.1. MultiplymultiplicativeProduct *= 1.1. - Phase 4: HardOverride. No
Mode == HardOverrideeffect onProductionin this modifier, so the post-multiply value carries through unchanged. - Phase 5: clamp to
Production's declared min (HasMin = false, skipped) and max (HasMax = false, skipped). - Phase 6: cast and return.
- Phase 1: base. For
- The binding remains until explicit removal, owner cleanup, target cleanup, or duration expiry. There is no continuous re-evaluation because the clause was
when, notwhile. If the author had writtenwhile, the trigger would beH.Trigger_MoraleGte80, the binding would persist, and the binding update pass would re-read morale every tick, togglingStatebetweenActiveandInactive.
This end-to-end traces every piece of the doc — declaration, tags, effect mode, trigger lowering, condition-on-add vs continuous distinction, and the 6-phase pipeline — against the exact files the compiler must produce.
Sibling example: the aggregation pattern (OnBuilt attaches a stackable modifier)
The Tavern example above uses single stacking — one Tavern, one FestivalSpirit binding per settlement. The more fundamental cross-scope pattern is many building instances each attaching a stackable modifier to a shared higher-scope target. This is how multi-source cross-scope aggregation lands in SECS. The shape is minimal: a template's OnBuilt attaches; template-owner cleanup detaches; the modifier's stacking = stackable does the counting.
// Content/common/modifiers.secs — the stackable declaration
modifier HouseMoralePresence
{
translation = "House";
tags = Buff, Economic;
stacking = stackable;
max_stacks = 100;
Morale += 5;
}
// Content/buildings/settlement/house.secs — the canonical aggregation pattern
template<Building> House
{
method void OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}
// Generated/Templates/Buildings/Settlement/House.cs — attach on built; owner cleanup detaches
public static void OnBuilt(ITemplateCommandContext context, in SecsNoArgs args)
{
var settlement = context.Host.WalkScope(context.Scope.Root, H.Settlement);
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HouseMoralePresence, triggerId: 0, duration: -1);
context.FlushCommands();
}
What happens at runtime (ten Houses built over the life of a settlement):
- Each House's
OnBuiltqueues anAddModifiercommand with a distinctowner = <thatHouse>, a commontarget = settlement, andmodifierId = H.HouseMoralePresence. CommandProcessoradmits each command underStackingPolicy.Stackableup toMaxStacks = 100— it stores ten distinctModifierBindingentries inModifierBindingStore, keyed by(owner, target, modifierId)— ten different owners, same target, same modifier id.- On
ResolveChannelInt(settlement, H.Morale):- Phase 1: base =
0(Morale isContributed; no host field). - Phase 2: iterate all bindings on the settlement. Find ten
HouseMoralePresencebindings, each with aMorale += 5additive effect. Additive total = 50. - Phase 3: iterate again for multiplicative; no multiplicative effects from these bindings.
multiplicativeProduct = 1.0. - Phase 4: HardOverride. No
HardOverrideeffects onMoralefrom these bindings. Post-multiply value (50) carries through. - Phase 5: clamp to
[0, 100].50is inside. - Phase 6: return
50.
- Phase 1: base =
- When one House is destroyed,
TemplateActivator.DestroyqueuesRemoveAllBindings(owner)for that House. Only that House's bindings are removed — the other nine stacks stay. Next resolve: additive total = 45.
This is the pattern to copy for any "N buildings each add K to a higher-scope channel" shape in SECS. No template body contribution, no RegisterChannelSource from the author surface, no anonymous aggregation — one named stackable modifier, one attach in OnBuilt, and lifetime handled by the binding owner.
Contrast: the while variant
The same FestivalSpirit modifier is also applied by CelebrationBonusEvent with continuous semantics. The lowering differs only at the call site:
// Content/events/settlement/construction/celebration_bonus.secs
method void Execute()
{
// "when" = condition-on-add: evaluated once at command time
@Settlement.add_modifier FestivalSpirit when @Settlement.resolve(Morale) >= 80 duration 3;
// "while" = continuous: re-evaluated every tick
@Settlement.add_modifier Thriving while @Settlement.resolve(Morale) >= 80;
}
// Lowered call site for the two variants:
// `when` variant: compiler emits the if; trigger is discarded after the check.
if (Trigger_MoraleGte80.Evaluate(settlement, EntityHandle.Null, EntityHandle.Null, context.Host))
{
context.Commands.AddModifier(owner: context.Scope.Owner, target: settlement,
modifierId: H.FestivalSpirit,
triggerId: 0, // no stored trigger
duration: 3);
context.FlushCommands();
}
// `while` variant: trigger id stored on the binding; update pass re-evaluates.
context.Commands.AddModifier(owner: context.Scope.Owner, target: settlement,
modifierId: H.Thriving,
triggerId: H.Trigger_MoraleGte80, // stored for re-evaluation
duration: -1); // permanent until removed
context.FlushCommands();
At runtime, the while binding enters State = Active if Trigger_MoraleGte80 returns true on registration. On every subsequent binding update pass, Trigger_MoraleGte80.Evaluate is re-run; when morale drops below 80, the binding flips to State = Inactive and the effect is skipped during resolution (the if (binding.State != ModifierState.Active) continue; at src/SECS.Engine/Resolution/ChannelResolution.cs:290). When morale climbs back, the binding flips to Active automatically — no template method re-runs, no re-registration. The binding persists across the transition; its effects are temporarily excluded, not destroyed.
The when variant never sees that flip. Once the command is processed, the condition is gone. If morale drops below 80 the next tick, FestivalSpirit stays attached for its full 3-tick duration regardless — the author asked for a one-shot condition, and that is what the lowering gives them.
Diagnostics
Compiler diagnostics emitted from this doc's surface. Error codes follow the SECS03XX convention established in 01-world-shape.md § "Channel-declaration diagnostics" — the two-digit prefix matches the doc number (03XX codes live here; 01XX in doc 01; 02XX in doc 02; etc.).
| Code | Trigger | Message |
|---|---|---|
SECS0301 | Modifier effect on a bool channel or field uses += or *= instead of = | modifier effect on bool target <name> must use HardOverride ( = ). Additive ( += ) and Multiplicative ( *= ) are not defined for bool. |
SECS0302 | Formula contains a dynamic resolve(<expr>) but has no reads { } block, or the block omits a channel that a dynamic resolve may read | formula '<name>' uses dynamic resolve() without a reads { } block. resolve() with a non-literal argument requires an explicit reads { stat1, stat2, ... } enumerating every channel the formula may read at runtime. An over-approximation is allowed; missing channels cause silent cycle-detection and dirty-tracking bugs. |
SECS0303 (warning) | reads { } declares a channel that no resolve() call in the formula body references | reads { } for formula '<name>' declares channel '<X>' but no resolve() call references it. Extras are legal but suggest the declaration has drifted from the formula body. |
SECS0304 | Modifier field effect references a name that is not a declared top-level field | modifier field effect references unknown template field '<name>'. Declare field <type> <name> { ... } before targeting it, or remove the field prefix if this was meant to target a channel. |
SECS0305 | Modifier channel effect references a name that is not a declared top-level channel | modifier channel effect references unknown channel '<name>'. Use field <name> OP value for template fields; unprefixed effect targets must be channels. |
SECS0802 | propagates_to where clause uses a channel-based or value-based predicate | propagates_to where predicate must be structural (has_tag X, has_template X, has_contract X). Channel-based predicates are not allowed here; use reads {} and a value gate inside the effect body for runtime-conditional propagation. |
SECS0830 | Formula, trigger, or generated bridge/helper path performs a previous-tick read of channel S and S does not carry track_prev = true | previous-tick read targets channel '<S>' which does not declare track_prev = true. Add track_prev = true to the channel declaration, or remove the previous-tick read. |
Cross-doc note.
SECS0802andSECS0830are08XX-series codes — they originate from the collection and propagation surface documented in08-collections-and-propagation.md. They are cross-referenced here because their trigger sites (propagates_to whereand previous-tick reads) appear in modifier and channel declarations defined in this doc.
SECS0301 example:
// channel declaration (in channels.secs):
channel bool IsDefending { kind = Contributed; source = settlement.IsDefending; }
// modifier using the wrong operator — compile error:
modifier BadRaidMode
{
translation = "Bad Raid Mode";
IsDefending += true; // SECS0301: IsDefending is a bool channel; use = (HardOverride).
}
The compiler check is (targetDeclaration.ValueType == SecsScalarType.Bool && effect.Mode != EffectMode.HardOverride) → error, where the target declaration may be a channel or a template field. The diagnostic cites the effect line source location and suggests the = form.
SECS0302 example:
// Error: dynamic resolve() with no reads { } block.
channel int BadSum
{
int total = 0;
foreach (var s in settlement.ResourceStats)
total += resolve(s); // SECS0302: `s` is runtime; reads { Gold, Silver, Copper } required.
return total;
}
// Fix: declare the read set explicitly.
channel int GoodSum
{
reads { Gold, Silver, Copper }
int total = 0;
foreach (var s in settlement.ResourceStats)
total += resolve(s);
return total;
}
The compiler check walks every resolve() call in the formula body; if any argument is not a static literal channel identifier, the formula must carry a reads { } block. Missing block or under-declared block (a dynamic resolve whose runtime range cannot be proven to lie inside the declared set) emits SECS0302 at the resolve call site.
SECS0303 example:
// Warning: Silver is listed in reads but no resolve(Silver) appears in the body.
channel int DriftedSum
{
reads { Gold, Silver } // SECS0303: Silver declared but unused
return resolve(Gold);
}
The compiler check cross-references the declared reads { } block against the set of channel identifiers that appear as literal resolve() arguments. Extras are legal (they widen the readsChannels array safely), but the warning flags probable drift between the block and the body. Authors silence the warning either by removing the unused channel or by pairing it with a resolve() call.
Deferred diagnostics (not yet emitted): per the deferred notes in § "Effect modes: Additive, Multiplicative, HardOverride" and docs/design/FUTURE_WORK.md, <= N (the clamp operator) and priority N (the reserved modifier-declaration field) remain DEFERRED from the initial compiler surface. Both currently produce no diagnostic because the grammar does not accept either form. When they do land, they will be assigned SECS03XX codes in this table.
Runtime exception (not a compile-time diagnostic): SecsFormulaReadSetViolation — thrown by ChannelResolver in debug builds when a formula's runtime resolve(ChannelId) call lands outside the registered readsChannels array. Release builds skip the check, except when --strict-readsets is set for the formula's owning mod (see §"Runtime read-set validation").
Cross-source-set firing. All diagnostics in this table fire on the merged AST — base-authored, mod-authored, and mod-operation-introduced violations all hit the same code paths. There is no per-source-set isolation; a mod's IsDefending += true is the same SECS0301 as base's, and a mod's field GoldCost *= 80% resolves against the merged field declaration set. SECS0602 (duplicate declaration without a mod operation) covers cross-mod modifier declaration collisions per § "Modifier declaration — basic form" above; that diagnostic and its conflict-report integration live in 06-overrides-and-modding.md § "Diagnostics fired across source sets".
Cross-references
- Canonical channel model statement (own-root intrinsic sources, named modifiers for cross-scope aggregation, and the
Contributedredefinition) →01-world-shape.md § "Model: intrinsic sources are own-root, modifiers cross scopes". - Intrinsic channels inside templates (per-instance
channel int X = N/channel int X { return ... }forms on the template's own scope — the one place a template registers a channel source) →02-templates.md. - Template fields (top-level
fielddeclarations and template-bodyfield int X = Nassignments) →01-world-shape.md § "field declarations"and02-templates.md § "Template fields". add_modifier/remove_modifierin method bodies (the call-site syntax, cross-scope binding, per-callreapplyoverride) →05-expressions.md.replace modifier X/inject modifier X(mod replacement/injection semantics, slot-level merge rules) →06-overrides-and-modding.md.- Full 6-phase semantics (phase ordering invariants, cycle detection, dirty tracking,
SyncDirty) → this document's § "The 6-phase resolution pipeline (recap)"; archive semantics describe the older five-phase model and are superseded here. - Channel kinds and
ChannelKinddeclaration →01-world-shape.md. propagates_to childrenfull materialization mechanics (five propagation rules, virtual binding lifetime, save/load reconstruction, performance contract) →08-collections-and-propagation.md § "Virtual binding materialization".- Aggregate channel sources (
Children.Sum,Min,Max,Average,Count,Where) andon Parent.FieldNamecollection-scope placement →08-collections-and-propagation.md § "Aggregate channel resolution"and§ "Collection-scope channels". track_prev = truesnapshot mechanics and previous-tick host bridge reads →08-collections-and-propagation.md § "previous-tick snapshots".