Skip to main content

07 — Structured Template Data and Typed Callables

This document supersedes the scalar-only template-field and SecsValueKind callable ABI assumptions in earlier generated stand-ins. It is authoritative for the SECS type model, structured template data, and typed query return values.

The runtime now has the first catch-up layer for this model: SecsTypeRef, SecsTypeDeclaration, type-reference validation, SecsTypeRef callable metadata, typed template-query registration, typed template-command registration, and generated scope command/query facades. Template queries return declared concrete C# types through TemplateQueryDelegate<TArgs,TResult> / TemplateQuery<TArgs,TResult>; template methods use typed TemplateCommand<TArgs> wrappers such as TemplateCommand.NoArgs. SecsValue remains only for command payload storage and explicitly erased dynamic/tooling adapters, not normal template or scope callable ABI.

Prerequisites

  • 00-overview.md — doc authority levels, lowering contract, hash identity.
  • 01-world-shape.md — scopes, contracts, top-level channels, top-level fields.
  • 02-templates.md — template entries, template fields, contract query/method bodies.
  • 05-expressions.md — scope frames, contract calls, host scope methods.
  • 06-overrides-and-modding.md — slot schema and closed mod surfaces.

C# Source Types

What: SECS source uses ordinary C# type syntax wherever C# already has the construct. Authors write enum, record, record struct, arrays, IReadOnlyList<T>, IReadOnlyDictionary<TKey,TValue>, and scope entity types such as Location. SECS does not have a parallel author-facing type language for lists, maps, records, or scope entities.

SecsTypeRef is generated metadata. The compiler binds the C# source type symbols, then emits SecsTypeDeclaration / SecsTypeRef rows so the registry, tooling, validators, and dynamic adapters can reason about those types.

SECS source:

public enum FeatureFamily
{
NaturalResource,
Mineral,
Ruin,
Threat,
Landmark,
SettlementOpportunity,
Story,
}

public enum LocationFactKind
{
Fertility,
ForestCoverage,
WaterCoverage,
MineralWealth,
Elevation,
BaseThreat,
Ancientness,
RouteImportance,
Corruption,
SettlementSuitability,
}

public readonly record struct FeatureHardRequirement(
LocationFactKind Fact,
PlacementComparison Comparison,
int Value);

public sealed record FeaturePlacementProfile(
FeatureFamily Family,
int MinScore,
int MinPerWorld,
int MaxPerWorld,
int DensityDivisor,
int MaxPerLocation,
int MaxPerProvince,
int MaxPerArea,
int MaxPerRegion,
int MinSpacingRank,
int DiscoveryDifficulty,
int SurveyDifficulty,
int InteractionDifficulty,
FeatureDiscoveryState DefaultDiscoveryState,
bool DefaultHostile,
IReadOnlyList<FeatureHardRequirement> HardRequirements,
// Wave R-4 (Valenar): AvoidanceWeights was folded into SuitabilityWeights
// as negated entries; the avoidance dictionary is no longer a separate
// profile field.
IReadOnlyDictionary<LocationFactKind, int> SuitabilityWeights,
IReadOnlyList<string> ProducedTags,
IReadOnlyList<string> ConflictTags,
IReadOnlyList<string> SynergyTags);

public readonly record struct LocationFacts(
long LocationId,
long ProvinceId,
long AreaId,
long RegionId,
int Fertility,
int ForestCoverage,
int WaterCoverage,
int MineralWealth,
int Elevation,
int BaseThreat,
int Ancientness,
int RouteImportance,
int Corruption,
int SettlementSuitability);

Compiles to metadata plus matching C# types:

public static readonly SecsTypeDeclaration[] Types =
[
new()
{
TypeId = H.FeatureFamily,
Kind = SecsTypeKind.Enum,
Name = "FeatureFamily",
},
new()
{
TypeId = H.FeaturePlacementProfile,
Kind = SecsTypeKind.Record,
Name = "FeaturePlacementProfile",
Fields =
[
new() { FieldId = H.Family, FieldType = SecsTypeRef.Enum(H.FeatureFamily) },
new() { FieldId = H.MinScore, FieldType = SecsTypeRef.Int },
new() { FieldId = H.HardRequirements, FieldType = SecsTypeRef.Array(SecsTypeRef.Record(H.FeatureHardRequirement)) },
new() { FieldId = H.SuitabilityWeights, FieldType = SecsTypeRef.Map(SecsTypeRef.Enum(H.LocationFactKind), SecsTypeRef.Int) },
new() { FieldId = H.ProducedTags, FieldType = SecsTypeRef.Array(SecsTypeRef.String) },
],
},
];

public sealed record FeaturePlacementProfile(...);
public readonly record struct LocationFacts(...);

Why this shape: C# remains the language underneath SECS. The special SECS constructs are the game/modding model: template, contract, scope, channel, modifier, event, system, activity, policy, lifecycle bindings, scope walks, registry metadata, and slot-merge rules. Ordinary data should not be written in an invented type syntax when C# already has the right construct.

The generated metadata table is still necessary. It lets the runtime validate callable signatures, preserve mod/tooling schemas, dispatch typed template queries, and expose structured template fields without reflection. A dynamic host/tooling bridge may still carry erased values, but the erased value is validated against generated SecsTypeRef metadata; known generated call sites use concrete C# types.

Notes: The concrete source collection type for true set semantics is not locked. Current Valenar feature tags use IReadOnlyList<string> because they are open authoring tags. A future tag surface may be a SECS modding construct, but it must not introduce a parallel list/set/map type grammar for ordinary data.

Source Type Binding

What: SecsTypeRef is compiler/runtime metadata, not author syntax. It replaces SecsValueKind in callable signatures and template-field metadata after binding.

Type categories:

CategoryExample sourceGenerated type ref
Scalar valueint, long, float, double, bool, string, ulong helper idsSecsTypeRef.Int, .Long, .Float, .Double, .Bool, .String, .UInt64
Built-in typed idTemplateId, ChannelIdSecsTypeRef.TemplateId, SecsTypeRef.ChannelId
Scope entityLocation candidateSecsTypeRef.EntityInScope(H.Location)
Template referenceTemplate<Feature> or a template-id helperSecsTypeRef.Template(H.FeatureContract)
EnumFeatureFamilySecsTypeRef.Enum(H.FeatureFamily)
RecordFeaturePlacementProfileSecsTypeRef.Record(H.FeaturePlacementProfile)
Record structLocationFactsSecsTypeRef.Record(H.LocationFacts)
ArrayFeaturePlacementResult[]SecsTypeRef.Array(SecsTypeRef.Record(H.FeaturePlacementResult))
List-like valueIReadOnlyList<string>SecsTypeRef.Array(SecsTypeRef.String)
Map-like valueIReadOnlyDictionary<LocationFactKind, int>SecsTypeRef.Map(SecsTypeRef.Enum(H.LocationFactKind), SecsTypeRef.Int)
Voidmethod void OnSpawned();SecsTypeRef.Void

ulong remains valid for untyped generated helper ids and hash ids. Source-visible strong ids such as TemplateId and ChannelId must lower to their dedicated SecsTypeRef kinds, even though their runtime storage is a wrapped ulong; this keeps structured metadata like IReadOnlyList<ChannelId> distinct from arbitrary unsigned integers. That does not reopen unsigned integer channels: channel arithmetic still uses the closed channel scalar set in 01-world-shape.md.

Why this shape: SecsTypeRef separates source type identity from storage strategy. A scope entity and a template id may both have compact runtime representations, but they are not type-equivalent in source. A Location parameter can only receive a location entity; a Template<Feature> parameter can only receive a feature-template reference. This lets the compiler reject accidental id-smuggling while preserving compact command payloads and erased adapter storage where those boundaries are explicitly required.

Notes: None for the committed Valenar feature-placement types.

Structured template fields

What: A top-level field declaration may use any SECS value type. A template assignment supplies immutable definition data of that exact type. Scalar template fields still use fast typed accessors; structured fields use generated strongly typed accessors and a metadata row keyed by SecsTypeRef.

SECS:

field FeaturePlacementProfile Placement
{
name = "Placement";
description = "Local placement scoring data for this feature template.";
}

template<Feature> Dungeon
{
field FeaturePlacementProfile Placement = new(
Family: FeatureFamily.Threat,
MinScore: 170,
MinPerWorld: 1,
MaxPerWorld: 2,
DensityDivisor: 42,
MaxPerLocation: 1,
MaxPerProvince: 1,
MaxPerArea: 1,
MaxPerRegion: 2,
MinSpacingRank: 2,
DiscoveryDifficulty: 65,
SurveyDifficulty: 75,
InteractionDifficulty: 85,
DefaultDiscoveryState: FeatureDiscoveryState.Hidden,
DefaultHostile: true,
HardRequirements:
[
new(LocationFactKind.BaseThreat, PlacementComparison.GreaterThanOrEqual, 40),
],
// Wave R-4 (Valenar): AvoidanceWeights folded into SuitabilityWeights
// as negated entries.
SuitabilityWeights: new()
{
[LocationFactKind.BaseThreat] = 5,
[LocationFactKind.Corruption] = 3,
[LocationFactKind.Fertility] = -1,
},
ProducedTags: ["threat", "hostile-site"],
ConflictTags: ["sanctuary"],
SynergyTags: ["corruption"]);
}

Compiles to:

public static readonly TemplateFieldDeclaration[] TemplateFields =
[
new()
{
FieldId = H.Placement,
Name = "Placement",
ValueType = SecsTypeRef.Record(H.FeaturePlacementProfile),
},
];

public static readonly FeaturePlacementProfile PlacementValue = new()
{
// Same C# value shape as source; no separate SECS object model.
};

public static FeaturePlacementProfile GetPlacement() => PlacementValue;

The concrete generated accessor name is compiler-owned. The binding contract is that known generated callers receive a concrete FeaturePlacementProfile value, while dynamic callers read the same value through TemplateEntry.StructuredFields / registry.GetTemplateField<FeaturePlacementProfile>(templateId, H.Placement) and validate it against (templateId, fieldId, SecsTypeRef.Record(H.FeaturePlacementProfile)).

Why this shape: Feature placement, AI scoring, authored loot tables, spawn profiles, localization-backed UI rows, and other designer data need nested values. Encoding them as parallel scalar fields (PlacementWeight, PlacementMinDistance, PlacementTraitA, PlacementTraitB) destroys locality, makes slot-merge patches fragile, and prevents tooling from presenting one coherent object. The field value is immutable template-definition data; per-instance state still lives in scope fields, channels, modifiers, or host state.

Modifier rule: Additive and multiplicative template-field modifier effects apply only to numeric scalar fields. HardOverride may replace an entire structured field value only when the replacement value has the exact same SecsTypeRef. There is no partial modifier effect on Placement.MinDistance; partial structured changes are handled by template-field overrides in the merge pass, not by runtime modifier arithmetic.

Notes: Whether HardOverride on structured template fields should ship in the first engine catch-up pass or be deferred until a real content use case needs runtime replacement. The compiler must still reserve the type check now: any whole-value structured replacement effect must be exact-type.

Typed query returns

What: A contract query may return any declared SECS value type, including structs and records. A method remains command-producing and must return void.

SECS:

contract Feature
{
root_scope Feature;
activation OnSpawned;

query FeaturePlacementResult EvaluatePlacement(
Location candidate,
FeaturePlacementInput input);

method void OnSpawned();
method void OnDiscovered();
}

Compiles to:

public static readonly ContractMethodDeclaration[] ContractMethods =
[
new()
{
ContractId = H.FeatureContract,
MethodId = H.Contract_Feature_EvaluatePlacement_Location_FeaturePlacementInput_FeaturePlacementResult,
Name = "EvaluatePlacement",
ReturnType = SecsTypeRef.Record(H.FeaturePlacementResult),
ParameterTypes =
[
SecsTypeRef.EntityInScope(H.Location),
SecsTypeRef.Record(H.FeaturePlacementInput),
],
IsQuery = true,
},
new()
{
ContractId = H.FeatureContract,
MethodId = H.Contract_Feature_OnSpawned_NoArgs_Void,
Name = "OnSpawned",
ReturnType = SecsTypeRef.Void,
ParameterTypes = [],
IsQuery = false,
},
];

Template implementation shape:

public static FeaturePlacementResult EvaluatePlacement(
ScopeFrame scope,
ISecsHostReads host,
in FeaturePlacementQueryArgs args)
{
var candidateFacts = LocationScopeQueries.Facts(host, args.Candidate);
return FeaturePlacementQuery.Evaluate(args.Input, candidateFacts);
}

Generated registry adapters may wrap this concrete method for dynamic invocation. The adapter is not the source-level contract; the source-level contract is typed parameters and a typed return.

Why this shape: Query results are data, not commands. A placement system needs a full explanation object (Eligible, Score, Blockers, reason tags, diagnostics), not a bool plus side-channel globals. Returning a declared record keeps read-only planning pure and makes host UI, AI, tooling, and tests consume the same value. Methods stay void because command-producing surfaces must express their side effects through the command buffer and statement-level flush contract.

Notes: None.

Feature placement contract

What: Feature generation uses template-authored FeaturePlacementProfile data and a read-only typed query:

query FeaturePlacementResult EvaluatePlacement(
Location candidate,
FeaturePlacementInput input);

The query evaluates local/template suitability for one candidate. The global placement system applies global budgets, spacing, uniqueness, and conflict resolution after collecting query results.

SECS:

template<Feature> Dungeon
{
field FeaturePlacementProfile Placement = new(
Family: FeatureFamily.Threat,
MinScore: 170,
MaxPerWorld: 2,
MinSpacingRank: 2,
DefaultDiscoveryState: FeatureDiscoveryState.Hidden,
DefaultHostile: true,
HardRequirements: [
new(LocationFactKind.BaseThreat, PlacementComparison.GreaterThanOrEqual, 40),
],
// Wave R-4 (Valenar): AvoidanceWeights folded into SuitabilityWeights
// as negated entries.
SuitabilityWeights: new()
{
[LocationFactKind.BaseThreat] = 5,
[LocationFactKind.Corruption] = 3,
[LocationFactKind.Fertility] = -1,
},
ProducedTags: ["threat", "hostile-site"],
ConflictTags: ["sanctuary"],
SynergyTags: ["corruption"]);

query FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input)
{
return evaluate_feature_placement(input, candidate.Facts());
}
}

Compiles to (system-side shape):

var input = new FeaturePlacementInput(
seed,
worldHash,
generationVersion,
templateId,
templateName,
profile,
worldFacts);
Span<FeaturePlacementCandidate> accepted = stackalloc FeaturePlacementCandidate[maxCandidates];

foreach (var templateId in registry.TemplatesForContract(H.FeatureContract))
{
var args = new FeaturePlacementQueryArgs(candidateLocation, input);
var result = registry.EvaluateTemplateQuery<FeaturePlacementQueryArgs, FeaturePlacementResult>(
templateId,
H.Contract_Feature_EvaluatePlacement_Location_FeaturePlacementInput_FeaturePlacementResult,
ScopeFrame.ForCreation(candidateFeatureRoot),
in args,
host);

if (!result.Eligible)
{
continue;
}

accepted[acceptedCount++] = new(templateId, candidateLocation, result);
}

FeaturePlacementPlanner.ApplyGlobalBudgetsSpacingAndConflicts(accepted[..acceptedCount]);

Why this shape: Template queries should be deterministic, local, and explainable. They answer "how good is this template at this candidate location under this input?" They do not decrement a global budget, reserve a location, inspect other templates' hidden state, or mutate generation state. The placement planner/system owns global policy because budgets, spacing, uniqueness, and conflicts are interactions between many candidate results. Putting those effects inside each template query would make load order and iteration order change placement outcomes and would make dry-run planning impossible.

Implementation constraints:

  • FeaturePlacementProfile is template definition data, not per-location state.
  • FeaturePlacementInput is supplied by the generation system and is immutable for the duration of one evaluation batch.
  • FeaturePlacementResult is a read-only return value. Returning Eligible = true does not place the feature.
  • The system must collect candidate results first, then apply global budgets, spacing, uniqueness, and conflicts in one deterministic pass.
  • Query bodies may read host fields, channels, template fields, and read-only scope methods. They may not call increment, add_modifier, fire, save_scope_as, void scope methods, or contract method void surfaces.

Notes: None.

Diagnostics

CodeSeverityRuleFires across source sets?
SECS0701errorunknown type name in a field, callable signature, struct/record field, array element, collection generic argument, or template/scope referenceevery set, checked against the merged base/expansion type catalog plus permitted mod-added types
SECS0702errorvalue initializer does not match the declared SecsTypeRefevery set
SECS0703errormethod declaration has a non-void return type; use query for read-only typed returnsevery set
SECS0704errorquery body performs a command-producing operationevery set
SECS0705errorcallable mod patch changes parameter or return SecsTypeRef; body replacements may replace implementation onlybase ∪ mods union
SECS0706errorruntime template-field modifier attempts additive/multiplicative effect on a non-numeric structured fieldevery set

These diagnostics are indexed in 00-overview.md, 06-overrides-and-modding.md, and FUTURE_WORK.md; implementation must keep all three tables in sync.