NRF SDK 0.9

Creating a mod

Signals and network

Compose several models, resolve their neighbours and handle unknown observations.

Organise a model’s responsibilities

After the first mod, you can add several signal families to one package. Each signalModel owns its identity, enums and callbacks. The signalMod entry point assembles these declarations; it should not become one large rule comparing every type.

ResponsibilityWhat you write
DeclarationStable identity, catalogue, fallbacks and function registration.
IndicationsTwo enums: the displayed aspect and the reason explaining the decision.
SettingsNamed checkboxes and integers, initial values and panel help.
RulesA decision from the observation and, when needed, the downstream neighbour.
AppearanceCatalogue images and optional animation.
DrivingAn explicitly chosen rule and speeds for each indication.
DiagnosticsReadable reason names and decisions classified as faults.

Keep small models in one file. As they grow, group files by family; place genuinely shared rules in a common directory named for their domain. Rule functions work on supplied values without opening a game connection or starting timers.

Two models with independent vocabularies

SignalModels.kt
package wiki.models

import nimby.*

// Two independent vocabularies, even though both sets of ordinals start at zero.
enum class MainAspect { Closed, Open }
enum class MainReason { Unknown, Clear }
enum class DistantAspect { Wait, Proceed }
enum class DistantReason { Unknown, MainClosed, MainOpen }

val mainSignal = signalModel(
    "mon-reseau.principal", "Principal", "textures_principal",
    fallback = Indication(MainAspect.Closed, MainReason.Unknown)
) {
    rules {
        if (fresh && routeKnown && block == Occupancy.Clear &&
            settingsStatus != SettingsStatus.Unavailable &&
            !observation.forcedStop && !observation.lampFailed)
            Indication(MainAspect.Open, MainReason.Clear)
        else Indication(MainAspect.Closed, MainReason.Unknown)
    }
    images { if (it.aspect == MainAspect.Open) "main-open.svg" else "main-closed.svg" }
}

val distantSignal = signalModel(
    "mon-reseau.annonce", "Annonce", "textures_annonce",
    fallback = Indication(DistantAspect.Wait, DistantReason.Unknown)
) {
    rules {
        when {
            !fresh || !routeKnown || block != Occupancy.Clear ||
                settingsStatus == SettingsStatus.Unavailable ||
                observation.forcedStop || observation.lampFailed ->
                Indication(DistantAspect.Wait, DistantReason.Unknown)
            next == null -> null // Ask the SDK to resolve the neighbour.
            else -> when (next?.of(mainSignal)?.aspect) {
                MainAspect.Closed -> Indication(DistantAspect.Wait, DistantReason.MainClosed)
                MainAspect.Open -> Indication(DistantAspect.Proceed, DistantReason.MainOpen)
                null -> Indication(DistantAspect.Wait, DistantReason.Unknown)
            }
        }
    }
    images { if (it.aspect == DistantAspect.Proceed) "distant-proceed.svg" else "distant-wait.svg" }
}

// Composition and reading example: no driving instruction is declared.
fun createNetworkMod() = signalMod("mon-reseau", "My network") {
    signal(mainSignal)
    signal(distantSignal)
}

The main model decides locally. The distant model first checks its own block, then interprets the main model with next.of(mainSignal). These names and rules are fictional: the SDK supplies no national railway convention.

IdentityScope
ModInfo.idThe package: used for installation and optional services.
SignalType.idThe constructible model: its rules, settings and catalogue.
Signal.idA placed instance in the observed game; do not reuse it in another game.

A mod declares 1 through 16 models with distinct identities and catalogues. Reuse the same SignalModel instance in signal(model) and next.of(model): matching names or ordinals do not make two declarations interchangeable.

Resolve a neighbour only when needed

  • The first rules call receives next == null. Returning an indication immediately settles this signal.
  • Returning null asks the SDK to resolve the nextSignal link. The rule is then called again with the resolved neighbour.
  • If the link is missing, the dependency cycles without a local decision, or the rule still does not conclude, the model uses invalidNetwork.

Distinguish next == null from next.of(mainSignal) == null. The first means resolution has not supplied a neighbour yet. The second can mean a resolved neighbour belongs to another model: choose an explicit policy for that case. The example above returns its fallback for that unknown model.

Neighbour propertyUse
id / typeIdentify the downstream signal and its model in this observation.
of(model)Read the recognised model’s exact enums without numeric conversion.
drivingRuleRead the declared rule if your rule knows how to interpret it; null remains a missing rule.
activeRead the neighbour’s activeWhen result; this proves neither freshness nor a clear track.

next follows your mod’s observed network. It neither searches all nearby signals nor automatically reads other mods’ private decisions. evaluateNetwork tests the same mechanism outside the game; its 4096-signal limit per call is not a maximum map-size promise.

Make unknown information an explicit result

InputMeaning to preserve
fresh == falseValues do not establish the current state. Choose the model’s fallback.
routeKnown == falseThe required route is not established; do not infer a clear block.
Occupancy.UnknownNeither Clear nor Occupied is established. Do not substitute Clear.
SettingsStatus.UnavailableThe profile cannot be read; SignalRuleContext marks the observation stale.
forcedStop / lampFailedData for your rule to interpret; their names do not make a decision on your behalf.

fallback is used notably when isolated calculation does not conclude; invalidNetwork covers an unresolved downstream dependency. These declarations do not replace rules branches for fresh, routeKnown and Occupancy.Unknown. Choose a distinct reason when it helps explain the result, then test unknown inputs before favourable cases.

Observe an approach across several blocks

ApproachSignal.kt
package wiki.approach

import nimby.*

enum class ApproachAspect { Closed, Open }
enum class ApproachReason { Unknown, TrainApproaching }

val approachSignal = signalModel(
    "monmod.approche", "Approach-activated signal", "textures_approche",
    fallback = Indication(ApproachAspect.Closed, ApproachReason.Unknown)
) {
    construction(states = listOf("closed.svg", "open.svg"))
    observeApproach(blocks = 2)
    rules {
        if (fresh && routeKnown && block == Occupancy.Clear && trainApproaching &&
            !observation.forcedStop && !observation.lampFailed)
            Indication(ApproachAspect.Open, ApproachReason.TrainApproaching)
        else Indication(ApproachAspect.Closed, ApproachReason.Unknown)
    }
    images { if (it.aspect == ApproachAspect.Open) "open.svg" else "closed.svg" }
    // The mod chooses its driving policy here.
    driving { if (it.aspect == ApproachAspect.Open) AutomaticDriving.clear() else AutomaticDriving.stop() }
}

In a project prepared with the first tutorial, add this file and assemble approachSignal from createMod. The catalogue declares closed.svg and open.svg: add these files to assets. The expected result is a model that opens only when its local conditions and a fresh approach are satisfied.

src/main/kotlin/Entry.kt
package nimby.mod

import nimby.*
import wiki.approach.approachSignal

fun createMod() = signalMod(modInfo) {
    metadata(author = "Your name", description = "Approach-controlled signal.")
    signal(approachSignal)
}

observeApproach(blocks = 2) requests a train head directed towards the signal within the two upstream blocks. The range accepts 1 through 16 blocks. trainApproaching is true only for a usable, fresh approach; approachingTrain then supplies its identity. Missing evidence produces false/null, not proof that no train exists.

Traversal follows the observed direction and does not choose a branch arbitrarily. After the head passes the signal, that train is no longer approaching it. Detection proves neither reservation nor movement permission; combine it with block checks in your rule.