Creating a mod
Cooperate with another mod
Connect an optional button to a service and build an independent preview tool.
Declare the contract between two projects
This guide assumes a working signal project. Add an action to its signalModel and create a second Native project for the provider. The signal keeps its rules when the tool is not installed; whenMod does not add an installation dependency.
action("preview", "Preview markers",
whenMod = "preview-tool", service = "preview.v1")| Field | Contract |
|---|---|
| preview | Identity of this action within the signal model. |
| preview-tool | Exact provider mod identity, declared in its mod.json. |
| preview.v1 | Service name registered by the provider with service. |
The button becomes available when the provider and its service are present with a fresh observation of the same game. A click sends a SignalActionRequest containing the source, action, service and game scope. A click alone grants no construction permission.
Choose a service or a standalone window
toolMod declares a tool without a fictitious signal model. Declare at least one service or window. A service handles signal actions; a window also supports tools without a selected signal. Callbacks use the ToolContext supplied for that call.
| Declaration | Use |
|---|---|
| service(id) { request -> … } | Receive a click or valid panel-field edit. |
| window(id, title, shortcut) { event -> … } | Receive standalone-window events. |
| onTick { … } | Advance bounded work or renew an active preview without waiting. |
| onStop { … } | Clean local state during normal shutdown; do not rely on this callback after abrupt termination. |
A mod accepts up to 32 services and 8 windows, with distinct identities within each group. Handlers within one mod are serialized: a waiting handler prevents its other tasks from advancing. Retain values and work state; each callback receives a usable context.
Use the context without retaining it
| Call or value | Useful result |
|---|---|
| worldId / generation | Data scope: invalidate stale calculations, source and state when it changes. |
| network() | A new network copy. Request it when beginning a calculation, not for every visual renewal. |
| showPanel(request, message, buttons, inputs) | Panel associated with the source action: at most 12 buttons and 4 integer fields. |
| showSignalPreview(request, positions) | Publish at most 64 temporary positions without placing signals. |
| clearSignalPreview() | Remove this tool’s preview without touching constructed signals. |
| log(message) | Log a useful event or state change. |
Kotlin copies remain readable after the callback returns; ToolContext does not remain usable. An older request and its positions must still belong to the current worldId/generation before republication. Do not turn a temporary refusal into an empty result or success.
ToolOperationException.isBusy indicates a temporary refusal. For previews, retaining positions and retrying in the next callback is allowed; no waiting loop is needed. For construction with an uncertain response, retain the ticket and inspect its state instead of resending the command.
Complete example: preview without construction
This file places markers at evenly spaced fractions on the source signal’s track. It chooses no branches and does not calculate spacing in metres. PreviewSession separates logic from SDK calls so it can be tested outside the game.
package wiki.preview
import nimby.*
// Graphical markers only, not a route planner or a construction command.
fun previewPositions(network: ToolNetwork, sourceId: Long, count: Int): List<SignalPosition> {
require(count in 1..64)
val source = requireNotNull(network.topology().signal(sourceId)) { "Source or track unavailable." }
return (1..count).map { source.placementAt(source.track, it.toDouble() / (count + 1)) }
}
// Test seam around public SDK calls, recreated for each callback, never retained.
interface PreviewPort {
val worldId: String
val generation: Long
fun network(): ToolNetwork
fun clear()
fun show(request: SignalActionRequest, positions: List<SignalPosition>)
fun panel(request: SignalActionRequest, message: String, count: Int, closed: Boolean)
}
class PreviewSession {
private var request: SignalActionRequest? = null
private var count = 3
private var positions: List<SignalPosition>? = null
private var clearRequested = false
private var calculationRequested = false
private var closed = false
private var panelDirty = false
private var message = "Choose the number of markers."
private var idleMessage = message
fun event(port: PreviewPort, next: SignalActionRequest) {
if (next.worldId != port.worldId || next.generation != port.generation) return
request = next
positions = null // Revoke locally BEFORE calls that may be refused.
calculationRequested = false
clearRequested = true
closed = next.action == "close"
if (next.action == "count") next.value?.takeIf { it in 1..64 }?.let { count = it }
calculationRequested = next.action == "show"
message = if (closed) "" else "Preview hidden."
idleMessage = message
panelDirty = true
tick(port)
}
fun tick(port: PreviewPort) {
val current = request ?: return
if (current.worldId != port.worldId || current.generation != port.generation) {
stop()
return
}
try {
if (clearRequested) {
port.clear(); clearRequested = false
if (!calculationRequested) setMessage(idleMessage)
}
if (calculationRequested) {
val snapshot = port.network()
check(snapshot.worldId == current.worldId && snapshot.generation == current.generation)
positions = previewPositions(snapshot, current.signalId, count)
calculationRequested = false
}
positions?.let {
port.show(current, it) // Renew using copied positions, no new capture.
setMessage("Showing ${it.size} temporary markers.")
}
} catch (error: ToolOperationException) {
if (error.isBusy) setMessage("Temporarily busy; waiting to display the preview.")
else abandon()
} catch (error: Exception) { abandon() }
if (panelDirty) {
try {
port.panel(current, message, count, closed)
panelDirty = false
} catch (error: ToolOperationException) {
if (!error.isBusy) stop()
} catch (error: Exception) { stop() }
}
}
private fun setMessage(value: String) {
if (message != value) { message = value; panelDirty = true }
}
private fun abandon() {
positions = null
calculationRequested = false
clearRequested = true
idleMessage = "Preview unavailable; request a new preview."
setMessage(idleMessage)
}
fun stop() {
request = null; positions = null
calculationRequested = false; clearRequested = false; panelDirty = false
}
}
private fun ToolContext.previewPort() = object : PreviewPort {
override val worldId get() = this@previewPort.worldId
override val generation get() = this@previewPort.generation
override fun network() = this@previewPort.network()
override fun clear() = clearSignalPreview()
override fun show(request: SignalActionRequest, positions: List<SignalPosition>) = showSignalPreview(request, positions)
override fun panel(request: SignalActionRequest, message: String, count: Int, closed: Boolean) {
if (closed) showPanel(request, "", listOf(ToolButton(request.originAction, "Open preview tool")))
else showPanel(request, message,
listOf(ToolButton("show", "Show preview"), ToolButton("hide", "Hide"), ToolButton("close", "Close")),
listOf(ToolNumberInput("count", "Number of markers", count, 1, 64)))
}
}
fun createPreviewTool(): ToolMod {
val session = PreviewSession()
return toolMod("preview-tool", "Preview tool") {
service("preview.v1") { request -> session.event(previewPort(), request) }
onTick { session.tick(previewPort()) }
onStop { session.stop() }
}
}
package nimby.mod
fun createMod() = wiki.preview.createPreviewTool()Create the project using the setup guide, then set id to preview-tool in mod.json to match this example. It needs no signal assets. Install the signal mod containing the action together with this tool; open the action, choose a count and select Show preview. You should see several temporary markers and no new signals in the game.
Handle editing, expiry and refusal
- An empty or invalid field remains a draft. The callback receives value only for a valid integer; do not replace null with zero.
- A new edit hides the previous preview. Also invalidate the previous calculation before any operation that can fail.
- Publish the complete position list in one call. The preview expires after two seconds without renewal; onTick can renew copied values without rereading the entire network.
- An isBusy refusal does not renew the preview. Disable any placement confirmation depending on that new display until publication succeeds.
- Only one preview is active at a time. It remains tied to editing the source signal and disappears when the mod or game stops.