NRF SDK 0.9

Creating a mod

Settings and effective values

Declare checkboxes and integers, read their availability, and prepare area rules without writing profiles.

One declaration reused by the rule

A setting belongs to a model. Give it a stable key for saved games, a readable label and an explicit default. The label and help can use tr; the technical key is not translated. Declaring a checkbox changes no rule: read enabled inside rules.

Fragment inside a model declaring these enums
val active = checkbox(
    name = "active", label = "Enabled",
    description = "Use this model’s rules.", defaultValue = true
)
rules {
    when {
        !enabled(active) -> Indication(Aspect.Closed, Reason.Disabled)
        !fresh || !routeKnown || block != Occupancy.Clear ||
            observation.forcedStop || observation.lampFailed ->
            Indication(Aspect.Closed, Reason.Unknown)
        else -> Indication(Aspect.Open, Reason.Clear)
    }
}

Retain the returned Checkbox and pass that same declaration to enabled. A checkbox from another model is rejected even if it has the same name. Two models can each declare active with different defaults.

StatusMeaning in SignalRuleContext
PresentEffective profile supplied. The SDK can fill defaults for a known signal without saved values.
AbsentThe context uses declared defaults and retains this status. This does not prove a profile was read from the game.
UnavailableUnavailable profile: the observation becomes stale. A default value is not a successful read.

enabled uses the default when a key is missing. Also check fresh and the observations your rule requires; never treat an unavailable profile as all checkboxes unchecked. The shared-enum DSL also retains settingsStatus but does not apply all SignalRuleContext normalisation.

Place an integer below its controlling checkbox

Fragment inside your signalModel
val work = checkbox("work", "Work zone", defaultValue = false)
val workBlocks = NumberSetting(
    "workBlocks", "Following blocks", maximum = 64,
    defaultValue = 0, visibleWhen = work.name
)
number(workBlocks)

// Inside rules:
// val following = workBlocks.read(settings)

visibleWhen names the same-model checkbox that shows the field. The panel places the field below its controlling checkbox. Hiding it retains its value: your rule must decide whether that value has any effect while the checkbox is off. An empty visibleWhen string leaves the field without a visibility condition.

Parameter or functionContract
maximumFrom 1 through 65535; the field range is 0..maximum.
defaultValueWithin 0..maximum. Your rule chooses what zero means.
read(settings)Reads a bounded value from copied settings without game access.
withValue(settings, value)Returns a new settings map for calculations or tests; saves nothing.

A model accepts up to four distinct NumberSetting declarations. Use their API without constructing storage fields. ToolNumberInput integer fields in tool forms are a separate API: they deliver input to your service, not a persistent signal setting.

Prepare an area’s effective settings

prepareNetwork receives the mod’s observed signals before resolution. It can derive settings for that calculation, for example propagating an option across a number of following signals. It does not save those values. Preserve signal count, order, identities, links, types and observations; the SDK checks that they are preserved.

PreparedNetwork.kt
package wiki.prepared

import nimby.*

val work = Checkbox("work", "Work zone", "Apply this model's work-zone rule.")
val workBlocks = NumberSetting("workBlocks", "Following blocks", maximum = 64,
    defaultValue = 0, visibleWhen = work.name)
enum class Aspect { Closed, Open }
enum class Reason { Unknown, Clear, Work }

val model = signalModel(
    SignalType("example.work", "Work signal", "example_work", checkboxes = listOf(work)),
    fallback = Indication(Aspect.Closed, Reason.Unknown)) {
    number(workBlocks)
    construction(listOf("closed.svg", "open.svg"))
    rules {
        if (!fresh || !routeKnown || block != Occupancy.Clear || observation.forcedStop || observation.lampFailed)
            Indication(Aspect.Closed, Reason.Unknown)
        else if (enabled(work)) Indication(Aspect.Closed, Reason.Work)
        else Indication(Aspect.Open, Reason.Clear)
    }
    images { if (it.aspect == Aspect.Open) "open.svg" else "closed.svg" }
    driving { if (it.aspect == Aspect.Open) AutomaticDriving.clear() else AutomaticDriving.stop() }
}

// Example policy: propagate along nextSignal, not physical distance.
// Zero means source only. No derived setting is written to persistent storage.
fun effectiveWorkSettings(signals: List<Signal>): List<Signal> {
    val byId = signals.associateBy { it.id }
    val affected = HashSet<Long>()
    for (source in signals) {
        if (source.type != model.type.id || source.settingsStatus != SettingsStatus.Present ||
            !source.observation.fresh || source.settings[work.name] != true) continue
        var current: Signal? = source
        val seen = HashSet<Long>()
        repeat(workBlocks.read(source.settings) + 1) {
            val signal = current ?: return@repeat
            if (!seen.add(signal.id) || signal.type != model.type.id ||
                signal.settingsStatus != SettingsStatus.Present || !signal.observation.fresh) {
                current = null
                return@repeat
            }
            affected.add(signal.id)
            current = byId[signal.nextSignal]
        }
    }
    return signals.map { signal ->
        if (signal.id in affected && signal.settings[work.name] != true)
            signal.copy(settings = signal.settings + (work.name to true))
        else signal
    }
}

fun createPreparedMod() = signalMod("prepared-example", "Prepared settings") {
    signal(model)
    prepareNetwork(::effectiveWorkSettings)
}

In this example, zero targets only the source; two targets the source and two following signals of the same model. Traversal stops at a missing link, cycle, another model or unusable data. This area imposes closure: it is a fictional policy demonstrating preparation, not a work-zone signalling convention.

Test this function with Kotlin values and verify the inputs remain unchanged. prepareObservedNetwork checks identities and network preservation; evaluateNetwork then resolves dependencies. Avoid repeating the same global search inside every signal rule.

Keep setting identities stable

Changing a label does not require changing its key. Retain model, catalogue and setting identities to find a game’s values. onlyWhenEnabled can represent a warning visible until acknowledged; it does not change the checkbox’s domain meaning.

If you deliberately rename an already distributed key, migrateSettings receives only actually saved values. Convert existing ones, leave others absent so defaults can be filled, and make the transformation idempotent. New projects do not need this step.