Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Modding Uptime

This folder holds the guides for people who make mods. A mod is a folder with a manifest.json. It is either a content pack, which adds hardware, scenarios, customers or services, or a script mod, which runs code against a running world. A mod can be both.

Start here

GuideRead it to
QuickstartMake, check, run and package a first mod, and preview its upload.
The uptime-mod commandUse new, validate, package, run and upload, and read their exit codes.
Content packsWrite a manifest, add hardware or scenarios, and learn what makes the game refuse a pack.
Equipment modelsGive a new host or switch its own 3D model: the three files, the conventions, every limit and the .pips.json fields.
Script modsLearn what Lua and Rust mods share: declared reads, the 15 second quantum, commands and receipts, limits, faults, and what a modded run costs the player.
Lua modsWrite a mod in Lua: the entry table, require, the sandbox, determinism and hot reload.
Rust modsWrite a mod in Rust: the SDK, building to bin/mod.wasm, and determinism.
Other languagesMake a component in TinyGo, JavaScript or any language with no SDK: the WIT world, the msgpack encoding, the limits and the state contract.
PanelsDraw screens in the game: panel files, widgets, data timing, player input, text rules, the HUD card and keys.
ToastsRaise a short notice every player sees: the call, the tones and the caps.
ConfigOffer settings with a config schema, and set them on a dedicated server.
WorkshopUpload a mod, choose who sees it, push updates, and learn what players and dedicated server operators do with it.
MultiplayerLearn which packs other players must install to join a world with your mod, and how panels behave with several players.
ReferenceFind the generated schemas and ledgers, and learn how API versions work.

Examples

The game’s ModSDK/examples folder carries ten working mods (in the repository they are in mods/examples/):

  • com.uptime.example-hardware adds host and switch SKUs and overrides one built-in SKU.
  • com.uptime.example-model adds a switch SKU with its own 3D model. The model files are game art, not MIT, see TERMS.md.
  • com.uptime.example-scenario adds a scenario and depends on the hardware pack.
  • com.uptime.example-customers adds two customer archetypes and changes one built-in archetype.
  • com.uptime.example-service adds a Cold Storage offering over object storage and a customer archetype that buys it.
  • com.uptime.example-events adds two macro events and their English text.
  • com.uptime.example-locale carries text in English and German.
  • com.uptime.example-autoscaler is a Rust script mod.
  • com.uptime.example-autoscaler-lua is the same policy as a Lua script mod.
  • com.uptime.example-alerts is a Lua script mod with a HUD card, a toast and two keys. It issues no command.

Which API version this reads

These guides describe API generation v1alpha1, minor 1. Set api_minor in your manifest to the minor of the SDK you built against. Reference explains the rule.

Quickstart

This page takes you from nothing to a packaged mod that is ready to upload. You make a Lua script mod, check it, run it in a scenario, package it and preview its Workshop upload. Each command block marked <!-- run --> runs in a test, so the steps here work as written.

You need the game’s ModSDK folder. Its bin folder holds uptime-mod. Run the commands below from a folder you can write to. The uptime-mod command explains every flag.

1. Make a mod

uptime-mod new --template lua --id com.example.first-mod first-mod

This makes the folder first-mod with a manifest and two Lua files. It is a working autoscaler that accepts prospects and buys a host when capacity runs low.

2. Validate it

uptime-mod validate first-mod

The command checks the pack the way the game does. A clean pack prints ok: and a hash. Any problem prints the file, the field and the rule, and the exit code is 1.

3. Run it

uptime-mod run first-mod --scenario garage-to-glory --quanta 200

The command plays your mod in a scenario for 200 quanta, which is 50 minutes of sim time. It prints one JSON line for each command your mod landed. If it cannot find the game’s runner, pass --runner or set UPTIME_RUNNER.

4. Package it

uptime-mod package first-mod --out dist

The command writes dist/com.example.first-mod-1.0.0/, the folder you share. It also prints the pack hash.

To try the pack in the game, copy dist/<id>-<version>/ into your mods folder. For this mod, that is dist/com.example.first-mod-1.0.0/. Where the mods folder is gives the path on each system.

5. Preview the upload

uptime-mod upload first-mod --dry-run

The command packages the mod again, then prints what it would send to the Steam Workshop: the pack id and version, the hash, the title, the tags and the visibility. A first upload is private. It ends with dry run: nothing was sent to the Workshop, and it writes nothing into your folder. Drop --dry-run to upload for real, with Steam running and signed in to an account that owns Uptime. Workshop covers visibility, updates and what players see.

What next

Content packs

A content pack adds hardware, scenarios, customers, services, events or text to the game. It holds data files and no code. This page covers the manifest, the hardware, scenario, customer, service, event and locale files, the archive rules and what makes the game refuse a pack.

Script mods use the same manifest. The script fields are in Script mods.

What a pack is

A pack is a folder. The game scans a mods folder, and each subfolder is one pack. manifest.json sits at the root of the pack folder.

com.example.my-hardware/
  manifest.json
  hardware/hosts.json
  hardware/switches.json

The game reads a pack where it lies. It does not unpack or copy anything. It never follows a link inside a pack.

Where the mods folder is

The game keeps its mods folder in its user data folder. Find it here:

  • macOS: ~/Library/Application Support/Uptime/mods/
  • Windows: %APPDATA%\Uptime\mods\
  • Linux: ~/.local/share/Uptime/mods/, or $XDG_DATA_HOME/Uptime/mods/ when XDG_DATA_HOME is set.

Each subfolder is one pack. If the folder does not exist, create it. Restart the game, or reopen Mods, to see a new pack.

The manifest

manifest.json is strict. A field the game does not know is an error, and so is an unknown value in a list. The file may not exceed 64 KiB.

{
  "manifest_version": 1,
  "id": "com.example.my-hardware",
  "version": "1.0.0",
  "api_version": "v1alpha1",
  "api_minor": 2,
  "game_version": ">=0.1.0, <0.3.0",
  "kind": ["content"],
  "content": ["hardware"],
  "affects_gameplay": true
}
FieldRequiredWhat it holds
manifest_versionyesThe manifest format. It must be 1. The game checks it before any other field.
idyesThe pack’s name. Two or more dot-separated segments, each [a-z][a-z0-9_-]*, at most 128 bytes in all.
versionyesA semver version of the pack.
api_versionyesThe mod API generation. The only word accepted is v1alpha1.
api_minornoThe mod API minor the pack was built against. It defaults to 0.
game_versionyesA semver range of game versions the pack works with.
kindyesA non-empty list of content and script.
contentwith kind contentThe categories the pack carries: hardware, scenarios, customers, services, events, locale, art. It must be non-empty when kind has content, and it must be absent otherwise.
scriptwith kind scriptA block with runtime and entry. It is covered in Script mods.
affects_gameplayyesWhether the pack changes play. The game checks the value, see below.
commandsnoCommand categories a script mod may issue: infrastructure, finance, customers, incidents.
eventsnoEvent categories a script mod is told about: lifecycle, receipts, incidents, customers, finance, builds.
readsnoSnapshot sections a script mod reads.
panelsnoPanel files, at most 32. See Panels.
config_schemanoA file that describes the mod’s config. See Config.
state_schemanoA number that versions the mod’s saved state. It defaults to 0.
dependenciesnoOther packs this one needs, at most 64. See below.

Lists may not repeat a word. A path in the manifest must pass the path rules below, and the file must exist in the pack.

The affects_gameplay check

The game works out the right value for affects_gameplay and refuses the manifest when yours differs. The value is true when the pack lists any commands category or carries hardware, scenarios, customers, services or events. A pack with only locale or art content, and no commands, is false.

Packs that affect play mark the run. See Script mods for what that costs a player.

api_version and api_minor

api_version names a generation. api_minor names the minor within it. Every part of the mod API shares one minor: the snapshot, the events, the commands, the hardware, customer and service files, the scenario files and the manifest.

The game accepts an api_minor between two limits. The low limit is the latest minor that declared a break on data your pack uses: a scenario field its scenario files set, or any break on the hardware, customer and service files or the manifest. The high limit is the minor this build serves. A pack outside that range is incompatible and the game says so by name. A pack built against an older minor still loads while no break on its data has landed since.

The current minor is 1; minor 0 has shipped. Minor 1 removed two scenario fields, pricing.load_balancer.per_million_requests_microcents and tuning.eviction_refund_cap_months. A minor-0 scenario pack that sets either one must be rebuilt against minor 1. While v1alpha1 is a preview, a minor may contain a break. The ledgers in Reference record every change.

Hardware packs

Declare "content": ["hardware"]. The game loads two files and no others from hardware/:

  • hardware/hosts.json holds { "hosts": [ ... ] }.
  • hardware/switches.json holds { "switches": [ ... ] }.

The game refuses any other file under hardware/ with the rule content_category_unsupported.

The base-model rule

A row names a built-in SKU in model and sets scalar stats only. It cannot set shape. Rack units, ports, NIC and PSU counts, bays, architecture, accelerator, category and switch role all come from the base model. Every row rejects unknown keys. A new SKU draws as its base model unless the pack ships a model for it, see Equipment models. An override always draws as the SKU it overrides.

Copy base SKU names exactly from the built-in catalog, for example Balanced x86 1U, Compute 1U and 16-port Access GbE. The GPU host is GPU 2× 2U and the name contains the character ×.

Host rows

FieldNotes
idRequired. The item name in the pack.
modelRequired. The base host SKU the row starts from.
overridesOptional. base:<name> to replace a built-in SKU.
vcpu, mem_mb, ssd_gb, ssd_iops, power_wOptional numbers.
capex, hourly_costOptional money, see below.
mtbf_hours, generation_yearOptional numbers.
accel_count, gpu_mem_mb_per_gpuOptional. Above zero only when the base model has an accelerator.

Switch rows

FieldNotes
idRequired. The item name in the pack.
modelRequired. The base switch SKU the row starts from.
overridesOptional. base:<name> to replace a built-in SKU.
capex, hourly_costOptional money, see below.
power_w, fabric_capacity_gbps, pps_ceiling, mtbf_hoursOptional numbers.

Each number has a floor and a ceiling. The exact bounds are in content.v1alpha1.schema.json, described in Reference. A value outside them fails with the rule min or max and names the field.

Money fields are whole numbers in minor units. One dollar is 100,000,000 units. The example hardware pack sets capex to 220000000000, which is 2,200 dollars.

Ids and overrides

An item id matches [a-z0-9][a-z0-9_-]{0,63}. A new row becomes the SKU named <pack id>:<id>. Scenarios and sku_gates use that full name.

A row with "overrides": "base:<name>" replaces that built-in SKU’s stats in place and keeps its name. Three rules apply:

  • The text after base: must name a built-in SKU of the same family.
  • The row’s model must equal that same name.
  • One pack may override a target once.

Only built-in SKUs can be overridden. If two selected packs override the same built-in SKU, the one that loads last wins. Load order is the dependency order, and ties go to the smaller pack id.

Scenario packs

Declare "content": ["scenarios"]. A scenario is a RON file at scenarios/<item>.ron. The file name stem must match the item id pattern above. The game refuses files in subfolders of scenarios/, and any file that is not .ron or not UTF-8 text.

The scenario’s own id must equal <pack id>:<file stem>. The rule scenario_id_not_namespaced fires otherwise.

A scenario config block may set only these keys: tuning, sku_gates, pricing, prospects, staff, bootstrap, component_lifecycle, relationship, consequences, service_investment, service_plane, rewards, quests, coop, bailout and depreciation. The same holds for the config inside hard_mode. A pack scenario may not set catalogs, steam or sites. New SKUs come from hardware packs, and the achievement catalog and the sites belong to the base game. Every SKU a scenario names must exist in the packs you selected or in the base game. Every archetype a seeded customer or prospect carries must be an archetype id or name from the base game, the scenario’s own archetypes or a selected customer pack, or it fails with unknown_archetype.

The field-by-field shape of a scenario file is in scenario.v1alpha1.fields.

Customer packs

Declare "content": ["customers"]. A customer pack adds customer types, which the game calls archetypes, and can change the built-in ones. The game loads one file and no others from customers/:

  • customers/archetypes.json holds { "archetypes": [ ... ] }, at most 64 rows.

The game refuses any other file under customers/ with the rule content_category_unsupported.

The model rule

A row names a built-in archetype in model and sets demand on top of it: the tier, what the customer asks for, how it grows and when it leaves. Everything else comes from the model: how the customer talks, how patient it is during an incident, its sales cycle and its compliance needs. Every row rejects unknown keys.

The built-in archetype ids are lowercase words such as hobbyist, freelance_dev, local_business, etsy_seller and agency.

Archetype rows

FieldNotes
idRequired. The item name in the pack.
modelRequired. The built-in archetype the row starts from.
overridesOptional. base:<id> to change a built-in archetype.
nameNew rows only, where it is required. The name players see, at most 48 bytes of plain text.
tierOptional. bronze, silver, gold or platinum. A fixed tier replaces the model’s tier and any range of tiers.
workload_fromOptional, new rows only. A built-in archetype whose main service this one asks for.
extra_workloads_fromOptional, new rows only. Up to 4 built-in archetypes whose main services this one adds. It replaces the model’s extra services; [] asks for the main service alone.
growth_chance_per_check, viral_spike_chance_per_checkOptional, 0 to 1. How often a happy customer asks for more.
plateau_at_allocationOptional, 1 to 1000. The size at which the customer stops asking for more.
payload_ceiling_mult, payload_growth_per_30_days, site_shareOptional. How far and how fast the customer’s traffic grows.
signup_bonus_min, signup_bonus_maxOptional. The signup bonus range in whole dollars, up to 1,000,000. The minimum may not be above the maximum.
competitor_poach_chanceOptional, 0 to 1. The chance per month a competitor takes the customer.
outage_tolerance_minutesOptional. Outage minutes before the customer starts to leave.
referral_chance_when_happyOptional, 0 to 1. The chance per month a happy customer sends you a new prospect.
exposure_weight, attack_event_chance_per_monthOptional. How much attacker attention the customer draws.
requires_multi_azOptional. The contract needs two availability zones.
min_repOptional, new rows only, 0 to 1. The reputation at which this archetype’s prospects start to arrive.
serviceOptional, new rows only. A service offering, as <pack id>:<item>, that the archetype’s workload of that offering’s kind is sold as. See Service packs.

The exact bounds are in content.v1alpha1.schema.json. A value outside them fails with the rule min or max and names the field.

A row asks for services by naming built-in archetypes, not services. Every service a built-in archetype asks for is open to yours, including VMs, managed databases and CDNs. A name in workload_from or extra_workloads_from must be a built-in archetype, or it fails with unknown_model.

Ids, names and overrides

A new row becomes the archetype <pack id>:<id>. Prospects of it arrive like any other: the game adds it to the prospect roster with its min_rep, unless the scenario’s own roster names it already. A pack scenario can name <pack id>:<id> in its prospects roster to place it on its own ladder. Customer names follow the model’s naming pattern.

Each archetype’s name must be its own. A new row fails with archetype_name_taken when its name is a built-in archetype’s name, another row’s name in your pack or in any selected pack, a built-in archetype id such as agency, or shaped like <pack id>:<id>. The game shows the name as plain text.

A row with "overrides": "base:<id>" changes that built-in archetype in place and keeps its id. The text after base: must name a built-in archetype, the row’s model must equal it, and one pack may override a target once. An override keeps its archetype’s name, services and place on the roster, so name, workload_from, extra_workloads_from, min_rep and service fail on an override with override_field_forbidden. If two selected packs override one archetype, the one that loads last wins.

A new row starts from its model as the selected overrides leave it.

Removing a customer pack

A save keeps the customers its packs brought. When you load it without the pack, each customer of one of its archetypes becomes a customer of the built-in archetype the row was built from. It keeps its name, its contract and every workload it runs. Only its persona changes. The game drops waiting prospects of the pack’s archetypes, and undoes any change the pack made to a built-in archetype.

Event packs

Declare "content": ["events", "locale"]. An event pack adds macro events: the big outside shocks, such as a chip shortage or a power crisis, that a scenario draws a few of for each run. The game loads one file and no others from events/:

  • events/templates.json holds { "events": [ ... ] }, at most 32 rows.

The game refuses any other file under events/ with the rule content_category_unsupported.

The model rule

A row names a built-in macro event in model, and the model decides what the event does: which prices and costs go up, how lead times change, and how customers react. The row sets the news text, when the event may start, how long it lasts and how often the draw picks it. Every row rejects unknown keys. An event pack adds no new kind of trouble, only new stories for the kinds the game already has.

The models are nand_shortage, gpu_allocation_crisis, regional_power_crisis, data_residency_law, submarine_cable_cut, major_cve, rival_price_war, acquisition_offer, zero_day_worm and ai_runaway_job.

Event rows

FieldNotes
idRequired. The item name in the pack.
modelRequired. The built-in macro event the row starts from.
start_textRequired. A key of your pack’s locale text, posted to #news when the event starts.
end_textRequired. A key of your pack’s locale text, posted to #news when the event ends.
duration_hours_min, duration_hours_maxOptional, 1 to 2160. How long the event lasts. With neither set the scenario’s range applies; with one set the event lasts exactly that long. The minimum may not be above the maximum.
weightOptional, 1 to 100. How likely the draw is to pick this event. Every built-in event weighs 10, which is also the default.
whenOptional. World facts the event waits for, all of them, on top of the day the draw gives it.

when takes these fields, each optional:

FieldNotes
after_day0 to 3650. The event starts on this day of the run at the earliest.
min_customersUp to 100,000. The event waits until you have this many customers.
min_cash_dollars0 to 1,000,000,000,000. The event waits until you hold this much cash.
min_reputation0 to 1. The event waits until your reputation reaches this.
min_progression_stepUp to 64. The event waits until the campaign reaches this step.

The exact bounds are in content.v1alpha1.schema.json. A value outside them fails with the rule min or max and names the field.

Event text

The text is never written in the row. start_text and end_text name messages in your pack’s locale files, so the text shows in the player’s language and voice like any other locale pack text. See Locale packs.

  • A key that is not your pack’s prefix plus a name with no dash, such as a sentence or another pack’s key, fails with event_text_not_owned.
  • A key that is not in one of your locale/en/ files fails with event_text_missing.
mod-com-example-weather-heat_start = A heatwave is pushing the grid hard. Power costs more until it breaks.
    .eng = Heatwave: grid at peak load, utility power prices up.
mod-com-example-weather-heat_end = The heatwave is over. Power prices are back to normal.

Because an event pack carries locale text, it is client-required: a player who joins a multiplayer world that uses it must have it installed.

When an event runs

A new row becomes the event <pack id>:<id>. Events come from a scenario’s macro event pool. Your events join the draw of every scenario whose pool is turned on and lists your model, or lists no kinds at all. A scenario with no pool, such as the sandbox, never draws them, and a scenario that leaves your model out of its pool does not draw that row.

The draw picks events by weight without picking one kind twice, so your row and its model never run in the same draw. A run’s draw is fixed by its scenario and seed: the same packs and seed always give the same events. With no event packs selected, every scenario draws exactly as before.

Removing an event pack

When you load a save without the pack, the events from it that had not started yet are dropped. An event from it that is running keeps going with its model’s effects and ends with the game’s own text.

Service packs

Declare "content": ["services"]. A service pack adds service offerings and can change the built-in service kinds. The game loads one file and no others from services/:

  • services/services.json holds { "services": [ ... ] }, at most 32 rows.

The game refuses any other file under services/ with the rule content_category_unsupported.

The kind rule

A row names a built-in service kind in model by its tag: vm, fn, obj, db, cdn, k8s or lb. An offering is a product line of that kind. Its units are the kind’s units: they are placed, moved, served and repaired exactly as the kind’s are. A row sets plain values only, the shop name, the unlock gate, the spread grain and the price. It cannot change what a unit is or how it behaves. Every row rejects unknown keys.

Service rows

FieldNotes
idRequired. The item name in the pack.
modelRequired. The built-in kind the offering runs as.
overridesOptional. base:<tag> to change a built-in kind.
nameThe name players see in the shop. Required on a new row, refused on an override. At most 48 bytes, plain text.
unlock_costOptional. The one-time cost to sell the offering while it is locked, in money minor units, see Host rows.
enabled_by_defaultOptional. Whether it sells from the start. A new offering without it does.
spread_grainOptional. The blast radius the kind’s units are spread across: host, switch, az, region, power or router. Only obj, db, cdn, k8s and lb spread their units, so the field fails on vm and fn with service_spread_unsupported. A tenant’s own redundancy contract can still ask for a coarser grain. An offering sets the grain only, never a count of domains: the game shares a kind’s units evenly across the domains at that grain.
price_multiplierOptional, 0.1 to 10. Scales every billed line of the kind.

The exact bounds are in content.v1alpha1.schema.json.

Who buys an offering

A new row becomes the offering <pack id>:<id>. Only a customer of a pack archetype whose service names it buys it, and only for its workload of the offering’s kind. Built-in customers never do. The archetype may come from the same pack or from another selected pack, and service must name an offering of a selected pack, or the selection fails with unknown_service.

An offering that starts locked shows in the shop’s Services page under its kind with an Invest button. Its kind must be sold first. A prospect who buys a locked offering cannot sign until you unlock it.

Each offering’s name must be its own. A name that is a built-in kind’s name, or another offering’s in your pack or in any selected pack, fails with service_name_taken.

Overrides

A row with "overrides": "base:<tag>" changes that built-in kind for every tenant that runs it, and keeps its tag. The text after base: must name a built-in kind, the row’s model must equal it, and one pack may override a kind once. If two selected packs override one kind, the one that loads last wins. An override’s unlock_cost and enabled_by_default replace the scenario’s gate for the kind.

Removing a service pack

A save keeps the tenants that bought an offering. When you load it without the pack, each of them runs on as the built-in kind, with the kind’s own grain and price. Every unit stays where it is. An unlock you paid for is kept, and applies again if the pack comes back.

Locale packs

Declare "content": ["locale"]. A locale pack carries text for your mod in one or more languages, as Fluent files:

com.example.words/
  manifest.json
  locale/en/main.ftl
  locale/de/main.ftl

The game loads locale/<lang>/<name>.ftl and nothing else under locale/. <lang> is a language tag: two or three lowercase letters, then any number of - subtags of two to eight letters or digits, such as en, de or pt-BR. <name> matches the item id pattern above. Any other file under locale/ fails with the rule content_category_unsupported.

The key prefix

Every message in your files is your pack’s prefix followed by a name. The prefix is mod-, then your pack id with each . written as - and each - written as --, then -. The name after it has no dash: use letters, digits and _. The pack com.example.night-shift owns mod-com-example-night--shift-, so its messages look like this:

-pack-name = Night Shift

mod-com-example-night--shift-title = { -pack-name }
mod-com-example-night--shift-greeting = Welcome to the night shift.
    .eng = On call. Pager is live.
mod-com-example-night--shift-open_tickets = { $count ->
    [one] One ticket waits for you.
   *[other] { $count } tickets wait for you.
}

Every key belongs to exactly one pack id: everything before its last dash is the prefix, and the prefix turns back into one id. So no pack can define another pack’s text. No game key starts with mod-, so a pack adds text and never changes the game’s own. A message that is not your prefix plus a dash-free name fails with the rule locale_key_not_namespaced.

Terms, the ids that start with -, are private to your pack and can use any name. A message can use your own terms and messages, not the game’s.

The .eng attribute is the text shown when the player has engineer terms on. Without it, both settings show the message’s value.

Languages

The player’s language picks your text. When your pack has no text for a message in that language, the game shows your en text. So every message in another language must also be in locale/en/, or it fails with the rule locale_key_missing_en.

A pack cannot add a language to the game. Text in a language the game does not ship is never shown, since the player cannot pick that language.

Locale rules

  • A file is at most 256 KiB and a pack’s locale files are at most 1 MiB together. Over either fails with max.
  • A file must be UTF-8 text, or it fails with wrong_type.
  • A file must parse as Fluent. A parse error fails with locale_syntax and the message gives the line.
  • Text a message shows holds no control character other than a newline and no invisible formatting character (bidirectional marks, overrides and isolates, zero-width spaces, tag characters, the byte order mark, the Arabic letter mark, soft hyphens). The zero-width joiner and non-joiner are allowed. The same holds for a string literal, escapes included. Breaking it fails with locale_syntax.
  • A message nests placeables and function calls at most 8 deep, and one entry holds at most 256 { and ( together. Past either fails with locale_syntax.
  • A placeable cannot sit directly inside another placeable, as in { { $n } }, and a select cannot choose on a literal, as in { "x" -> ... }. Both fail with locale_syntax. Write { $n }, and select on a variable or a function.
  • A number in your text is at most 32 characters with at most 18 digits after the point, or it fails with locale_syntax.
  • A message or term defined twice in one language, in one file or two, fails with duplicate_id.

uptime-mod validate runs these same checks.

A locale pack does not change play, so affects_gameplay is false. It is client-required: a player who joins a multiplayer world that uses it must have it installed. See Multiplayer. The game loads the text of every locale pack installed and valid, enabled or not, so a joiner sees the host’s text. The text comes from the same read of your files that the join’s pack hash covers.

Panel and config titles and labels are plain text, or @ and one of your keys. See Panels.

The game reads the text again when the set of installed packs changes, such as after a Workshop install or removal, so a new pack’s words show without a restart.

Art

Declare "content": ["art"]. Art belongs to the pack’s own new hardware items, so the pack also carries those rows. The game loads two kinds of art file, icons and models. Every other file under art/ fails with the rule content_category_unsupported.

A pack with art content is client-required: a player who joins a multiplayer world that uses it must have it installed. See Multiplayer. It cannot also carry a script.

Icons

An icon is a PNG at art/icons/<item>.png, where <item> is the id of one of the pack’s own hardware items. It is at most 64 KiB and at most 256 by 256 pixels. The shop shows it on that item’s row.

Models

A new host or switch can ship its own 3D model, drawn in the rack in place of its base model. A model is three files in art/models/ named after the item: <item>_render.glb, <item>_proxy.glb and <item>.pips.json. A model that breaks any rule makes the whole pack invalid. Equipment models covers how to make the files, every limit and the .pips.json fields.

Dependencies

dependencies maps a pack id to a semver range:

"dependencies": {
  "com.uptime.example-hardware": ">=1.0.0 <2.0.0"
}

The game refuses a pack that depends on itself. A dependency must be installed, must match the range and must be able to load. The player must also select it. The game loads dependencies first. A dependency cycle makes every pack on the cycle incompatible. If a dependency cannot load, the packs that need it cannot load either, and the message carries the root cause.

Archive rules

These rules apply to every file in a pack. The scan checks the pack folder against them before it reads any content.

  • manifest.json must be at the root.
  • Every file the manifest points at must exist: script.entry, each panels entry and config_schema.
  • A path uses only A-Z, a-z, 0-9, ., _, - and /.
  • A path is at most 240 bytes. It may not be empty, absolute or contain a backslash or a percent sign. It may not have a segment that is empty, is ., ends in a dot, or is made only of dots.
  • The part of a segment before its first dot may not be a Windows device name: con, prn, aux, nul, com1 to com9 or lpt1 to lpt9. So the game refuses con.txt too.
  • A pack holds at most 4096 entries.
  • One file may not exceed 32 MiB, and all files together may not exceed 64 MiB. The game counts the bytes it reads, not the size a header claims.
  • The game refuses symlinks and special files. On unix it refuses a file with more than one name as well.
  • Two paths that differ only in letter case collide, and the game refuses them.
  • A file cannot also be a folder.
  • The game skips .DS_Store, Thumbs.db, desktop.ini, .localized and files that start with ._.

Every other file must sit under the folder of a category the manifest declares, or be a file the manifest references. A stray file fails with the rule content_outside_declared_category. A Lua script pack also allows its .lua files, see Lua mods.

What refuses a pack

The scan reports each problem with the pack id, the file, a JSON pointer to the field and a stable rule code. A pack lands in one of three states: valid, invalid or incompatible.

An invalid pack has bad files. Nothing from it loads. These rule codes mark it:

RuleCause
manifestmanifest.json is missing a field, has an unknown field or breaks a manifest rule.
archiveA path, size, count, link or collision rule failed.
unknown_field, missing_field, wrong_typeA row has a key the type does not declare, lacks a required key, or has a value of the wrong type.
min, maxA number is below its floor or above its ceiling.
id_patternAn item id breaks the id pattern.
duplicate_idTwo items, or two overrides of one target, in one pack.
unknown_model, model_family_mismatchmodel names no built-in SKU or archetype, or a SKU of the other family.
override_not_base, override_target_missing, override_model_mismatchAn overrides rule failed.
unknown_skuA scenario names a SKU that exists nowhere.
unknown_archetypeA scenario seeds a customer or prospect under an archetype that exists nowhere.
scenario_id_not_namespaced, scenario_config_key_forbidden, scenario_catalog_override_forbiddenA scenario rule failed.
archetype_name_taken, override_field_forbiddenA customer rule failed. See Customer packs.
event_text_not_owned, event_text_missingAn event’s text is not a key of the pack’s own en text. See Event packs.
service_name_taken, service_spread_unsupported, unknown_serviceA service rule failed. See Service packs.
content_outside_declared_category, content_category_unsupportedA file is in the wrong place, or in a category this build does not load.
locale_syntax, locale_key_not_namespaced, locale_key_missing_enA locale rule failed. See Locale packs.
model_orphan_item, model_missing_file, model_malformed, model_out_of_bounds, model_over_limit, model_external_uri, model_unsupported_feature, model_material_forbidden, model_bad_image, model_outside_envelopeA model’s GLB broke a rule. See Limits and rules.
pips_malformed, pips_over_limit, pips_port_mismatch, pips_iec_mismatch, pips_bay_mismatch, pips_bad_xformA model’s .pips.json is malformed, too large, or does not match its base model. See The .pips.json sidecar.
client_pack_has_scriptA pack with art or locale content also carries a script.

An incompatible pack is well formed but cannot load here. The game marks it when its id is installed twice, its game_version range excludes this build, its api_minor is out of range, a dependency is missing or at the wrong version, or it sits on a cycle. The rule code is incompatible. The related selection errors are mod_unavailable, when a selected id is not installed or is invalid, and runtime_unavailable, when this build has no runtime for a script mod.

The example packs

ModSDK/examples/com.uptime.example-hardware carries two new host SKUs, one new switch SKU and one override. ModSDK/examples/com.uptime.example-scenario carries one scenario and depends on the hardware pack. ModSDK/examples/com.uptime.example-locale carries English and German text. ModSDK/examples/com.uptime.example-customers carries two new archetypes and one override. ModSDK/examples/com.uptime.example-service carries a locked Cold Storage offering over object storage and an archetype that buys it. ModSDK/examples/com.uptime.example-events carries two macro events and their English text. ModSDK/examples/com.uptime.example-model carries one switch SKU with its own model, the three files uptime-mod model writes. See Equipment models. Copy any of them as a starting point. The model files in that pack are game art and are not MIT: see TERMS.md in the SDK folder.

Equipment models

A content pack can give a new host or switch its own 3D model. The rack draws that model in place of the base model the row starts from. This page covers the files, how to model and name the parts, the limits, the .pips.json sidecar field by field, and what happens when a model fails.

A model is part of the art category, see Art. The example pack com.uptime.example-model carries one switch SKU with a model. Its manifest, hardware row and the three model files are the smallest complete case. The model files in it are game art and are not MIT: see TERMS.md in the SDK folder.

The three files

A model is three files in art/models/, all named after the item:

com.example.my-hardware/
  manifest.json
  hardware/hosts.json
  art/models/box_render.glb
  art/models/box_proxy.glb
  art/models/box.pips.json
FileWhat it holds
<item>_render.glbThe near model.
<item>_proxy.glbThe far model, drawn at a distance.
<item>.pips.jsonWhere the ports, lights, fans, power inlets, module bays and nameplate sit.

The manifest lists "content": ["hardware", "art"]. <item> is the id of a new host or switch row in the same pack. A model never changes what the hardware is. Ports, power supplies and bays always come from the base model, so the model must match their counts.

Making the files

Export one GLB from your modelling tool and run uptime-mod model:

uptime-mod model work/box.glb --proxy work/box_proxy.glb --pack com.example.my-hardware --item box

The command reads your node names into the sidecar, merges the drawn nodes into the near model, copies the far model you made, and runs the game’s own pack checks on the result. Naming the nodes lists the names it reads. You write the far model yourself, and the command copies it unchanged, so it must pass the same checks. You can also write all three files by hand.

Authoring conventions

  • Units are metres and the up axis is Y. The glTF format already uses both. In Blender, export with Y up.
  • The front face of the device is at Z = 0 and the body runs back along negative Z. Port jacks, bezels and the nameplate face positive Z.
  • The model sits inside the base model’s rack slot. The slot’s width is a 19-inch rack face, centred on X = 0. Its height is the base model’s rack units at 44.5 mm each, starting at Y = 0. Its depth is the base model’s depth, measured from its shipped render model. When the base model has no measured depth, a host uses 0.740 m and a switch uses its chassis depth. The game allows a little more than the slot: 1 mm at each side, above and below, 15 mm in front for handles and bezels, and 25 mm behind for rear plugs.
  • The render file has one mesh drawn by one node. Every drawn node of your source is placed in model space and merged into one primitive per material, so the material count is the primitive count. Keep the node and material counts low at the source.
  • The game draws every light from the sidecar. A light is a mesh with LED in its node name. uptime-mod model leaves these meshes out of the render file and writes a sidecar entry for each, sized to the mesh’s box. The exception is a LED_Link or LED_Activity light that has no Port_ node to bind to, or whose nearest port is a management pin or Port_0: the command drops it and prints a warning, see Naming the nodes. Give no material an emissive color.
  • Name the parts the generator reads: Port_<N> for cabled ports numbered from 1, Port_<name> for named ports, LED_Link... and LED_Activity... under a port, Bay_<N> for module bays, names ending _IEC or AcInlet for power inlets, FanRotor... for fans, and Nameplate_Plate for the name placard. The full table is in Naming the nodes.
  • A default export from a modelling tool usually fails the limits below. It tends to give each object its own node and primitive, lists extensions the game does not load, and includes cameras or lights. Turn off cameras and lights in the exporter, apply modifiers, join objects that share a material, and keep textures inside the GLB.

Limits and rules

The game checks the model in the pack scan, and again in the client when it loads one. These rules apply to both GLB files unless a row says otherwise.

RuleLimit
File name<item> is the id of a new host or switch row in the same pack. A model for any other id fails with model_orphan_item. So does a model for an override row.
FilesAn item ships all three files or none, or it fails with model_missing_file.
ContainerA GLB 2.0 file with one scene, one mesh and at most one buffer, which is the BIN chunk. Exactly one node draws the mesh.
Data insideNothing loads by uri. A buffer or image that points at another file fails with model_external_uri.
CountsAt most 32 nodes, 32 primitives, 32 materials and 4 images. A texture is a PNG of at most 2048 by 2048 pixels.
Near modelAt most 32 MiB, 524,288 triangles and 524,288 vertices.
Far modelAt most 512 KiB, 8,000 triangles and 12,000 vertices.
GeometryTriangle lists only. The bounds, read from the vertex data and placed by the node, must fit the slot above, or the file fails with model_outside_envelope.
Not allowedAnimation, skins, cameras, morph targets and sparse accessors fail with model_unsupported_feature.
ExtensionsThe only extensions are KHR_texture_transform, KHR_materials_specular and KHR_materials_ior. A model may require none.
MaterialsA material may not emit light or blend its alpha. Use OPAQUE or MASK for alpha. The rule is model_material_forbidden.
NullsA JSON null in the glTF fails with model_malformed, except under an extras key. So does an object written as an array.

A GLB that is cut short or has bytes after its last chunk fails with model_malformed. One that reads past its buffer fails with model_out_of_bounds. A PNG with a bad header, checksum or size fails with model_bad_image. A file, texture or count over a limit in the table fails with model_over_limit. A list longer than its cap reads the same way, for example /materials the list has more than 32 entries.

One failing model makes the whole pack invalid. Nothing in the pack loads until every error is fixed, including its hardware rows. uptime-mod validate lists the errors.

The .pips.json sidecar

The sidecar says where each port, light, fan, inlet, bay and the nameplate sit in model space. A file is at most 256 KiB. It holds one JSON object with these keys. Every key except nameplate is required, and an unknown key fails.

{
  "pips": [
    { "kind": "port_link", "port": 1, "xform": [0.003, 0, 0, 0, 0.003, 0, 0, 0, 0.003, -0.2, 0.02, 0] },
    { "kind": "psu", "port": 1, "xform": [0.003, 0, 0, 0, 0.003, 0, 0, 0, 0.003, 0.2, 0.03, -0.1] }
  ],
  "ports": { "1": [0.012, 0, 0, 0, 0.012, 0, 0, 0, 0.012, -0.2, 0.02, 0] },
  "named_ports": { "Port_BMC": [0.012, 0, 0, 0, 0.012, 0, 0, 0, 0.012, -0.21, 0.02, 0] },
  "fans": [],
  "iec": [[0.02, 0, 0, 0, 0.02, 0, 0, 0, 0.02, 0.2, 0.02, -0.2]],
  "bays": {},
  "nameplate": [0.1, 0, 0, 0, 0.02, 0, 0, 0, 0.001, 0, 0.01, 0]
}

This one is for a base model with one cabled port, one power supply and no module bays. Your counts come from your base model.

KeyHolds
pipsA list of at most 256 status lights, see below.
portsAn object from "1" up to the base model’s cabled port count to a transform. The keys must be exactly that set, or the file fails with pips_port_mismatch.
named_portsAn object from a name to a transform, for ports outside the numbered set, such as Port_BMC. A light bound to one lights from the device port that carries that name, see port_name below. The name is Port_ and then letters, digits or _, at most 64 bytes. It may be empty.
fansA list of at most 16 transforms, one for each fan blade. It may be empty.
iecA list of transforms, one power inlet for each power supply of the base model. Another count fails with pips_iec_mismatch.
baysAn object from "1" up to the base model’s module bay count to a transform. It is empty, or holds exactly that set, or the file fails with pips_bay_mismatch.
nameplateOne transform for the name placard. Optional. Leave the key out for none.

A cabled port is a numbered data port of the base model. A host counts the ports of all its port banks. A switch counts its port_count.

Pip fields

Each entry of pips is an object with these keys, and an unknown key fails.

FieldRequiredHolds
kindyesWhat the light shows: port_link, port_act, activity, power or psu.
portyesA whole number, 0 or more. uptime-mod model writes the port number for port_link and port_act lights on a numbered port, the power supply number (1 is the rightmost) for psu, and 0 otherwise.
port_namenoThe named port a port_link or port_act light belongs to. Same pattern as a named_ports key. The game finds the device port whose label is the name after Port_, ignoring case, so Port_BMC follows a port labelled BMC; Port_MlagKeepalive follows the keepalive port. A name no device port carries passes every check and stays dark. Port_PSU, Port_Mgmt and Port_Out are refused, because their labels repeat on one device and the game binds a light to the first port with that label, so the name cannot say which port is meant.
rolenoA status role such as sys, alm or fan: lowercase letters, digits and _, at most 64 bytes. On an activity light the game paints by it: alm shows a fault and is dark otherwise, fan never shows a fault, and sys or no role follows the device’s indicator. Any other text is accepted and paints as no role.
xformyesA transform, see below.

Transforms

Every transform is a list of exactly 12 numbers. The first nine are the basis columns x, y and z, three numbers each. The last three are the origin. The basis scales a unit box, so a light or port jack is a small basis. The rules, all pips_bad_xform:

  • Every number is finite.
  • Each basis column is at least 0.000001 long and at most 2.0 long.
  • The three columns do not lie in one plane. The volume they span, divided by the product of their lengths, is at least 0.001.
  • The origin sits inside the rack slot, with the same slack as the geometry.

The game checks the origin only. It does not check that the whole light or port fits.

What the sidecar refuses

  • A JSON null anywhere, including "nameplate": null. Leave nameplate out instead. This fails as pips_malformed.
  • An unknown key at any level. Keys that the built-in models carry, such as has_side_panels and rack_anchors, are unknown here, and pack models do not use them.
  • A key given twice in ports, named_ports or bays.
  • An array where an object belongs, such as ports written as a list.
  • A file over 256 KiB, more than 256 pips or more than 16 fans, as pips_over_limit.

When a model fails

The pack scan refuses a bad model and the whole pack with it, see What refuses a pack. That is the normal failure and uptime-mod validate shows it.

The client checks the bytes again when it loads a pack, and then imports them with the engine’s glTF importer. A model that fails the check or the import draws as its base model. The game shows a warning toast once for that item, “The model for X in the mod Y couldn’t be loaded, so it looks like the model it’s based on.” Every other item of the pack is unaffected.

Without a model

A new SKU with no model files draws as its base model. An override of a built-in SKU always draws as that SKU. A pack with art content is client-required, so a player who joins a multiplayer world that uses it must have it installed. See Multiplayer.

Config

A script mod can offer settings. You describe them in a config schema file, and the game checks the player’s values against it, fills in defaults and hands your mod the result in init. A mod with no schema takes no config.

Declare a schema

Name the file in the manifest:

"config_schema": "config.json"

The file is a small subset of JSON Schema, at most 64 KiB:

{
  "type": "object",
  "title": "Autoscaler",
  "additionalProperties": false,
  "properties": {
    "reserve_percent": {
      "type": "integer",
      "title": "Cash reserve",
      "minimum": 0,
      "maximum": 50,
      "multipleOf": 5,
      "default": 10
    },
    "greedy": { "type": "boolean", "title": "Buy early", "default": false },
    "growth_rate": {
      "type": "number",
      "title": "Growth rate",
      "minimum": 0.5,
      "maximum": 4,
      "multipleOf": 0.5,
      "default": 1
    },
    "policy": {
      "type": "string",
      "title": "Policy",
      "enum": ["steady", "fast"],
      "default": "steady"
    }
  }
}

The root is an object. additionalProperties is required and must be false, because a config takes only the keys it declares. title and description are optional on the root and on each property.

A property has a type and a default. Every property needs a default. The type decides the other keywords:

TypeRequiredOptionalThe game draws it as
booleandefaultnoneA toggle.
integerminimum, maximum, defaultmultipleOf, 1 when absentA slider.
numberminimum, maximum, defaultmultipleOfA slider.
stringenum, defaultnoneA select.

These rules apply:

  • A property key matches [a-z0-9_]{1,32}, and the schema has at most 64 properties.
  • Integer bounds and steps are whole numbers no larger than 2^53. The default is inside the bounds and a multiple of the step.
  • A string lists its values in enum, 1 to 32 values. Each value is an id ([a-z0-9_]{1,32}), with no repeats, and the default is one of them. There is no free-text field.
  • A title is at most 64 bytes with no control, bidirectional or zero-width characters. Without a title, the key is the label. A title may instead be @ and a locale key, such as "@mod-com-example-autoscaler-reserve", which shows the pack’s text in the player’s language; @@ starts a title that really begins with an at sign. The key rules are the ones for panel labels in Panels: the key is one of the pack’s own messages or a message of a locale pack it lists in dependencies, and that pack’s en files define it, or the pack fails with locale_key_not_namespaced or locale_key_missing_en. A script mod keeps its words in a second, locale pack and depends on it; the two-pack example is in that section.
  • Any other keyword is an error that names it. So is a keyword that does not fit the type, such as minimum on a string.

The ledger content.v1alpha1.fields lists the accepted keywords and ranges, and content.v1alpha1.schema.json holds the same rules for your editor. See Reference.

What the player’s values must satisfy

The game builds a settings form from the schema in the mod’s entry on the Mods screen. It has a toggle for each boolean, a slider for each integer or number, and a select for each string. A select shows the enum values as its labels.

Each change is saved at once to mods/config/<mod id>.json in the game’s user data folder, as a JSON object that maps a key to a value. The saved values reach the game with the next mod selection, so they apply the next time a run starts or loads. If a mod update removes a key from the schema, the game drops that key from the saved values and still loads the run.

The game checks the values before the mod runs:

  • Text over 64 KiB, a value that is not a JSON object and a key the schema does not declare are refused.
  • A missing key takes its default.
  • A value must have the right type, sit inside its bounds and be a multiple of its step.
  • A string value must be one of the enum values.

A refused config is an error that names the mod, the schema file and the key. A session runs on one config. A load picks up the current values, and if they differ from the ones the save ran with, the load shows a config_changed warning.

What your mod receives

The game encodes the checked values as named msgpack with the keys in sorted order. Each integer property is an integer, and each number property is always a float. The same values give the same bytes, whatever order the player’s JSON listed them in.

In Lua the table is ctx.config:

function M.init(ctx)
  local reserve = ctx.config.reserve_percent
  local greedy = ctx.config.greedy
end

In Rust, Init::config holds the bytes. Decode them with rmp_serde::from_slice into a struct whose fields match your keys. The example autoscaler treats an empty config as the defaults.

A mod with no config_schema gets an empty config, and the game refuses any config sent for it.

Config for a dedicated server

A player sets config through the game’s settings form. A dedicated server has no form, so the operator names a folder with --mod-config:

sim-runner --server --scenario garage-to-glory \
  --mods-dir /srv/uptime/mods --enable-mods com.example.my-mod \
  --mod-config /srv/uptime/mod-config

The folder holds one file for each configured mod, named <mod id>.json, such as com.example.my-mod.json, with the object described above:

{ "reserve_percent": 20, "policy": "fast" }

The runner reads these files at boot:

  • --mod-config needs --mods-dir and --enable-mods.
  • A mod with no file runs on its defaults.
  • A file that fails the checks above stops the boot and the message names the file.
  • A .json file that names a mod the operator did not enable is ignored, and the runner logs one line.
  • No connection can change the config while the server runs.

Lua mods

A Lua mod is a script pack: a manifest and some .lua files. It reads the same snapshot, issues the same commands and gets the same events as a Rust mod, under the same limits. The shared rules are in Script mods. This page covers what is specific to Lua.

Lua 5.4 runs inside a sandbox with no clock, no files and no network. One guest component runs every Lua mod. The game loads your scripts into it when the world starts.

Pack layout

com.example.my-mod/
  manifest.json
  logic/main.lua
  logic/policy.lua

The manifest names the runtime and the entry file:

{
  "manifest_version": 1,
  "id": "com.example.my-mod",
  "version": "1.0.0",
  "api_version": "v1alpha1",
  "api_minor": 2,
  "game_version": ">=0.1.0, <0.3.0",
  "kind": ["script"],
  "script": { "runtime": "lua", "entry": "logic/main.lua" },
  "affects_gameplay": true,
  "commands": ["customers"],
  "events": ["receipts"],
  "reads": ["prospects"]
}

There is no build step. The pack ships its source.

The game bundles every .lua file under the entry’s directory, at any depth. For the entry logic/main.lua that is the folder logic. For an entry at the pack root it is every .lua file in the pack. The bundle may hold at most 64 files and 256 KiB of source in all. A pack that goes over either limit is invalid, with the rule archive.

No other file is allowed. A data file, a readme or a .lua file outside the entry’s folder fails with the rule content_outside_declared_category. The same archive rules as every pack apply, see Content packs.

Before the world starts, the game compiles every file without running it. A syntax error refuses the world and names file:line.

The entry chunk

The entry file returns a table. step is required. init and migrate are optional.

local M = {}

function M.init(ctx)
  -- ctx.config: the mod's config table ({} when there is none)
  -- ctx.state:  the last saved state, or nil on a first run
end

function M.step(ctx)
  for _, p in ipairs(ctx.snapshot.prospects or {}) do
    ctx:issue({ AcceptProspect = { prospect_id = p.id } })
  end
end

function M.migrate(from_schema, state)
  return state
end

return M

The game reads step from the table with a raw lookup, so a metatable cannot change it later. If the entry chunk does not return a table with a step function, init fails and the mod is off.

step runs once per quantum. ctx holds:

NameWhat
ctx.quantum{index, sim_time_ns, seed}
ctx.snapshotThe latest snapshot. It is the same table across the quanta between refreshes.
ctx.eventsThe events for this quantum, in batch order.
ctx:issue(cmd)Queues a command and returns its index, counted from 0. The command lands two quanta later.
ctx:save_state(tbl)Saves the mod’s state. The last call in a step wins.
ctx:panel(id, update)Sets the values of a panel the pack declares. See Panels.
ctx:toast(tone, text)Raises a toast every player sees. tone is "Neutral", "Info", "Good", "Warn" or "Danger". text is 1 to 160 bytes. At most 2 a step. See Toasts.
ctx:log(level, msg)Writes a log line. level is 0 to 4.
ctx:queued()The number of commands queued so far in this step.

Sections you did not list in reads are nil. Always guard them: ctx.snapshot.prospects or {}.

A command is an externally tagged table: { AcceptProspect = { prospect_id = 7 } }. Optional fields may be left out. A command with no data is a plain string. The command list and the fields are in commands.v1alpha1.fields.

Each entry of ctx.events is also tagged. An engine event looks like { Engine = { at_ns, category, event } }, and a receipt looks like { Receipt = { quantum, index, ok, code, ... } }. A lifecycle marker is { Lifecycle = { kind = "run_started" } }. Compare the string at event for an event that has no data:

for _, ev in ipairs(ctx.events) do
  if ev.Engine and ev.Engine.event == "DdosEnded" then
    ctx:log(uptime.LOG.info, "the attack ended")
  end
  if ev.Receipt and not ev.Receipt.ok then
    ctx:log(uptime.LOG.warn, "refused: " .. ev.Receipt.code)
  end
end

The global uptime holds three things:

  • uptime.LOG has trace 0, debug 1, info 2, warn 3 and error 4.
  • uptime.GUEST is a string that names the guest build.
  • uptime.array(t) marks a table so it saves as an array.

Without the mark, a table saves as an array only when its keys are exactly 1 to n, and an empty table saves as an empty array. Use the mark for a table with holes that must stay a list.

A marked table saves only the entries 1 to #t. The encoder drops its string keys, and any integer keys past #t, without a warning.

Several files and require

require loads a file from your bundle. The name is relative to the entry’s folder, and a dot stands for a folder separator. With the entry logic/main.lua:

  • require("policy") loads logic/policy.lua.
  • require("util.sort") loads logic/util/sort.lua.

A file loads once. The next require returns the same value. A file that returns nothing counts as true. require refuses a name that is empty, contains .. or starts with /. A name that matches no bundled file raises an error that names the path it tried. Nothing outside the bundle can be loaded.

The example pack does this: main.lua calls require("policy") in its top lines and builds the policy in init.

The sandbox

Available: the base functions, table, string, math, utf8 and coroutine.

Removed or limited:

  • io, os, debug and package do not exist.
  • dofile and loadfile do not exist.
  • string.dump and math.randomseed do not exist.
  • load reads text only. It never loads bytecode, whatever mode you pass.
  • require loads bundled files only.
  • collectgarbage accepts collect, count, step and isrunning.
  • print writes a log line at level 2, and warn writes one at level 3. Neither touches standard output.

A script cannot reach the clock, the file system or the network, so it cannot read anything a replay does not also have.

Data going in and out

The game passes data to the guest as named msgpack. The guest turns it into Lua tables:

  • Maps become tables and lists become sequences from 1.
  • Integers become Lua integers. Floats become Lua floats.
  • A map key that is nil or NaN is an error.

Commands and saved state go back through the same encoder, with these rules:

  • Keys must be strings or integers. A function, a userdata or a thread is an error.
  • A table that contains itself, or a table with a weak-mode metatable, is an error.
  • A float with a whole value saves as an integer. 5.0 comes back as 5.
  • A string that is not valid UTF-8 saves as binary.
  • The encoder writes map keys in sorted order, so the saved bytes do not depend on the order you built the table in.
  • Within one step, the commands and the state together may be at most 4 MiB. Past that the step fails with an error. The host’s lower caps apply as well.

Determinism rules

A run must replay exactly: the same inputs must give the same commands. The sandbox removes the usual sources of drift. Three things stay with you.

  • Sort when order reaches a command. Do not rely on pairs() order. The order can change when the Lua state is rebuilt, which happens after a load, after an out-of-fuel or trap strike and on a reload, because the tables are filled in a different order. If a loop decides which command goes first, loop over a sorted list of keys. table.sort is not stable, so give it a comparison that never ties.
  • Use math.random for randomness. The guest reseeds it from ctx.quantum.seed at the start of every step, so it draws the same numbers on every replay. Do not seed it yourself. During init and while the entry chunk loads, it draws from a fixed seed.
  • Keep state in save_state. After an out-of-fuel or trap strike, a reload or a load, the Lua state is built from scratch and init runs again with the last saved state. Anything kept only in a local or a global is gone.

The example autoscaler has one pairs() loop, which merges the config over the defaults. It issues no command, so its order cannot change the output. Every loop that issues a command or drops an order walks a list with ipairs.

Saved state holds tables, arrays, strings, integers, floats and booleans.

Errors

An error in step ends that step. The game logs the message with a traceback. The game drops the step’s commands and state, and the mod runs again next quantum. It is not a strike.

Running out of fuel is a strike. Three strikes disable the mod. The full table is in Script mods.

An error in init, or in the entry chunk, disables the mod. The game logs the message.

Limits

The shared limits table is in Script mods. The ones that matter most to a Lua author:

  • A step has 20,000,000 fuel. A script that loops forever runs out and the step is a strike.
  • Loading the scripts and running init has 200,000,000 fuel. The init that rebuilds the instance after a strike gets the same amount, because it parses every script again.
  • The whole mod gets 64 MiB. Lua’s own heap is a fixed region inside that. A script that grows without bound gets Lua’s not enough memory error, which pcall can catch.
  • A step may issue 32 commands. The game drops the rest and sends a rate_limited receipt.
  • Saved state may be 1 MiB. The game refuses a larger state and keeps the old one.

ctx:toast and ctx:panel raise a Lua error for a bad argument or a value over a cap, such as a third toast in one step or a toast text over 160 bytes. The error keeps nothing from that call, so a step that catches it with pcall still lands.

Editor support

ModSDK/lua/types/ holds LuaLS definition files, and ModSDK/lua/.luarc.json points the language server at them. The config sets Lua 5.4, turns off io, os, debug and package, and loads the library folder types. Copy both next to your pack, or point workspace.library at the folder.

FileWhat
uptime.d.luaWritten by hand. The ctx classes, the uptime global and the shape of the entry table.
uptime-snapshot.d.luaGenerated. One class per snapshot type, from snapshot.v1alpha1.schema.json.
uptime-commands.d.luaGenerated. One class per command and the uptime.Command union, from commands.v1alpha1.fields.
uptime-events.d.luaGenerated. The event envelope and one class per delivered engine event, from events.v1alpha1.fields.

The generated files follow the mod API ledgers. Do not edit them.

Dev loop: hot reload

A debug build of sim-runner can reload your scripts while the world runs. Start it with --watch-mods, plus --mods-dir and --enable-mods or a pack selected in the game:

sim-runner --stdio --watch-mods

The runner compares the scripts on disk with the running ones twice a second. When they differ it builds a new Lua state and swaps it in between steps. The log shows [mods] watching when the watch starts and reloaded on a reload.

What a reload does:

  • The scripts run from a new Lua state. init runs again with the last state you saved. Locals and globals start over.
  • If the new scripts do not load, the old ones keep running and the log shows reload failed: with the file and line. Fix the file and save again.
  • A reload is not a strike and does not count toward the rebuild limit. A mod the game has already disabled stays disabled until the world is loaded again.
  • Only the .lua files are watched. A change to manifest.json, such as commands, reads or events, needs a restart.

Hot reload works only in a debug build of the runner, and only in a single-player world. A release build ignores --watch-mods. In a multiplayer world, and in a --server process, the runner logs one line to say it is off.

The example

ModSDK/examples/com.uptime.example-autoscaler-lua is a full Lua mod: a manifest, a main.lua that builds the policy in init and calls it every step, and a policy.lua that holds the logic. It accepts the prospects the fleet can serve and buys a host when free capacity runs low. It declares reads of topbar, prospects and capacity, the three sections its policy uses. It also has a config schema, two panels (the policy and the decisions, with a pause toggle) and state_schema 1.

ModSDK/examples/com.uptime.example-alerts is a small Lua mod that shows the mod UI hooks. It issues no command, so it never affects a run. It counts the open incidents on a HUD card, raises a toast when a DDoS attack starts, and has a dashboard panel that opens on a key with an Acknowledge button on another. See Panels and Toasts.

The Rust autoscaler in ModSDK/examples/example-autoscaler follows the same policy line for line, with the same defaults and the same saved state. The game’s own tests run both and require equal commands and state. See Rust mods.

Rust mods

A Rust mod is a WebAssembly component. You write it against the uptime-mod SDK, build it for wasm32-wasip2 and ship the result as bin/mod.wasm. It reads the same snapshot, issues the same commands and gets the same events as a Lua mod, under the same limits. The shared rules are in Script mods. This page covers what is specific to Rust.

What you need

  • A Rust toolchain with the wasm32-wasip2 target: rustup target add wasm32-wasip2.
  • The SDK crate, uptime-mod. It ships in ModSDK/rust/uptime-mod and is published on crates.io under the same name. uptime-mod new --template rust writes a path dependency on the ModSDK copy into your Cargo.toml; pass --sdk <ModSDK folder> if the command cannot find the folder. To use crates.io instead, write uptime-mod = "0.3" with the version from Versions.
  • The WIT file ModSDK/wit/uptime-mod.wit. The crate carries its own copy in wit/ and reads it for you.

The WIT world

A mod component exports one world, uptime-mod, from package uptime:mod@0.1.0. It imports one interface, host, with a single function: log(level: u8, msg: string). Levels run from 0 (trace) to 4 (error).

It exports three functions:

ExportDoes
init(config, saved-state)Builds the mod. config is bytes. saved-state is the last state, when there is one. Returns ok or an error message.
step(quantum, snapshot, events)Runs one quantum. quantum is {index, sim-time-ns, seed}. snapshot and events are named msgpack. Returns commands, panels, toasts and an optional new state.
migrate(from-schema, state)Rewrites state saved under an older state_schema.

The SDK hides all of this. You implement a trait and call one macro.

A first mod

#![allow(unused)]
fn main() {
struct Hello;

impl uptime_mod::Mod for Hello {
    fn init(_: uptime_mod::Init<'_>) -> Result<Self, String> {
        Ok(Self)
    }

    fn step(&mut self, step: &mut uptime_mod::Step<'_>) -> Result<(), String> {
        for p in step.snapshot.prospects.iter().flatten() {
            step.issue(&uptime_mod::ModCommand::AcceptProspect {
                prospect_id: p.id,
                isolate_dedicate: None,
                isolate_mode: None,
                isolate_segment_id: None,
            })?;
        }
        Ok(())
    }
}

uptime_mod::export_mod!(Hello);
}

Call export_mod! once per crate. The crate type is cdylib.

The SDK

Mod has three methods:

  • init(Init) -> Result<Self, String> builds the mod. Init holds config (bytes) and saved_state (optional bytes). An Err disables the mod with that message.
  • step(&mut self, &mut Step) -> Result<(), String> runs once per quantum.
  • migrate(from_schema, state) -> Result<Vec<u8>, String> has a default that refuses. Override it when you change your state shape.

Step holds:

MemberWhat
quantum{index, sim_time_ns, seed}.
snapshotThe latest snapshot, typed as ModSnapshot.
eventsThe event batch as raw named msgpack bytes. Typed events are not generated yet. The shape is in events.v1alpha1.fields.
rngA stream seeded from quantum.seed.
issue(&ModCommand)Queues a command. It lands two quanta later.
panel(id, &PanelUpdate)Sets the values of a panel the pack declares. See Panels.
toast(Tone, &str) -> Result<(), String>Raises a toast every player sees. The text is 1 to 160 bytes and a step raises at most 2. The call returns an error for a toast that breaks a cap, and the step may carry on. See Toasts.
save_state(Vec<u8>)Returns your state. The last call in a step wins. You choose the encoding.
log(level, msg)Writes a log line.
queued()The number of commands queued so far in this step.

The host sends the snapshot bytes only when the snapshot refreshed. Otherwise it sends an empty list, and the SDK hands you the copy it already decoded. A mod never sees the difference.

Each section in ModSnapshot is an Option. A section you did not list in reads is None. A listed section that is empty is Some of an empty list. uptime_mod::snapshot::SECTIONS holds every name reads may use, and ModSnapshot::sections() the ones a snapshot carries.

uptime_mod::units(&snapshot) gives one row per host with its unit counts and service classes. It is empty when the mod did not read hosts.

ModCommand is an enum with one variant per command. Optional fields are Option and you must write each one, as in the example above.

Every tag enum in the SDK’s snapshot types has an Unknown arm, and every struct ignores fields it does not know. A mod built against an older minor still reads a snapshot from a later one, until a minor declares a break. If you write your own decoder, for the events or for a part of the snapshot, give every enum a way to skip a value it does not know, or one new value will fail the whole batch. #[serde(other)] covers only a bare name such as "Press", not a new kind with a payload such as {"Slider": {...}}. The example wraps each event and each UI value in an untagged enum whose last arm is serde::de::IgnoredAny, and skips that arm.

The SDK’s testing feature builds snapshots from a partial JSON value for unit tests on your own machine. It is for the host target only and is never part of a mod build.

Set the manifest’s api_minor to uptime_mod::snapshot::API_MINOR.

Versions

The crate’s version follows the mod API it reads. Each crate release reads exactly one generation and minor.

Mod APICrate version
v1alpha1, minor M0.(M+1).x: minor 0 is 0.1.x, minor 1 is 0.2.x, minor 2 is 0.3.x
a later alpha or beta generationthe next unused 0.y.x
vN (a stable generation), minor MN.M.x

The last number, x, moves for an SDK fix that leaves the mod API alone.

Under an alpha generation every minor may break, and cargo treats each 0.y as incompatible with the one before, so uptime-mod = "0.3" never moves you to a new minor by itself. To move to a new minor, raise the crate version and api_minor together. From v1 on, a minor only adds, so uptime-mod = "N.M" picks up the later minors of N. When it does, set api_minor to the new uptime_mod::snapshot::API_MINOR.

The SDK in ModSDK/rust/uptime-mod and the crate on crates.io for the same game release are the same code.

The manifest

{
  "manifest_version": 1,
  "id": "com.example.my-mod",
  "version": "1.0.0",
  "api_version": "v1alpha1",
  "api_minor": 2,
  "game_version": ">=0.1.0, <0.3.0",
  "kind": ["script"],
  "script": { "runtime": "component", "entry": "bin/mod.wasm" },
  "affects_gameplay": true,
  "commands": ["finance", "customers"],
  "events": ["receipts", "customers"],
  "reads": ["topbar", "prospects", "capacity"]
}

The pack holds manifest.json and bin/mod.wasm. The game refuses any other file. Your source stays outside the pack. The entry must end in .wasm.

Building to bin/mod.wasm

Build the crate in release mode for the wasip2 target, then copy the result into the pack:

cargo build --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/my_mod.wasm my-pack/bin/mod.wasm

The file name under release/ is your crate name with dashes turned into underscores.

This release profile in your Cargo.toml is a good start for a mod. It builds small and without panic machinery:

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

The SDK installs a panic hook that writes the panic message to your log at level 4. With panic = "abort" the panic then traps, and a trap is a strike.

What the component may import

A component built with std for wasm32-wasip2 imports a set of WASI interfaces. The host serves all of them as inert stubs, and your mod calls none of them in practice. The stubs are:

  • wasi:io error, poll and streams.
  • wasi:clocks wall-clock and monotonic-clock. Both return the quantum’s sim time.
  • wasi:random random and insecure-seed. Both draw from the quantum’s seeded stream.
  • wasi:cli environment (empty), exit (traps), stdin (always closed), stdout and stderr (log lines at levels 2 and 3), and the terminal interfaces (none).

Any other import fails to link, for example the file system or sockets. The error names the import, and the world refuses to start. The host also refuses a component that defines its own resource types.

Limits

The shared limits table is in Script mods. The ones that matter most to a Rust author:

  • A step has 20,000,000 fuel. A step that runs out is a strike.
  • init has 200,000,000 fuel. The init that rebuilds your instance after a strike has 20,000,000, charged to the step that needed it.
  • The whole mod gets 64 MiB of memory, for the linear memory and the GC heap together. Going over disables the mod at once.
  • A step may issue 32 commands, and one encoded command may be 16 KiB.
  • Saved state may be 1 MiB. The game refuses a larger state and keeps the old one.

Returning Err from step drops that step’s commands and state. The game does not count it as a strike.

Determinism rules

A run must replay exactly. The host feeds your mod only things a replay also has. Keep to these rules.

  • Draw random numbers from step.rng. Do not read any other entropy.
  • Do not use the wall clock. The clock stubs return sim time anyway, but your code should use quantum.sim_time_ns.
  • Do not let hash order decide the order of commands. Use BTreeMap and BTreeSet, or sort before you issue. The example autoscaler keeps its pending orders in a Vec sorted by prospect id.
  • Keep what must survive a fault in saved state. After an out-of-fuel or trap strike, or a load, your instance is built again and init gets the last saved state. Anything held only in memory is gone. The example autoscaler keeps its pending orders in saved state for this reason.
  • Encode saved state in a way that does not depend on map order. The example uses rmp_serde::to_vec_named on a plain struct.
  • Do not spawn threads. The host has none to give you.

The host runs the guest with fuel metering, canonical NaN values, no threads and no relaxed SIMD.

The example

ModSDK/examples/example-autoscaler is the source of ModSDK/examples/com.uptime.example-autoscaler/bin/mod.wasm. It accepts the prospects the fleet can serve, at most 8 per quantum, and buys a host when free vCPU or memory runs low. It treats an empty config as the defaults and reads any other config as named msgpack. All of its memory is saved state: the orders in flight, the quantum of its last purchase, the pause switch and the last ten decisions. A mod rebuilt from that state plays on as if it never stopped. It has a config schema, two panels (the policy and the decisions, with a pause toggle) and state_schema 1, with a migrate that reads the schema 0 state.

It declares reads of topbar, prospects and capacity, and commands of finance and customers.

The Lua example, ModSDK/examples/com.uptime.example-autoscaler-lua, is the same policy. See Lua mods.

Mods in other languages

The game runs a script mod as a WebAssembly component. The Rust SDK and the Lua guest are two ways to make one. You can make one in any language that builds a component for the WIT world, such as TinyGo, JavaScript with jco, C or C#. This page says what such a component must do. It uses no SDK.

Read Script mods first. It covers the quantum, commands, receipts, faults and the cost to a run. This page covers only what you need when you write the glue yourself.

Why the world passes bytes

The world passes the snapshot, the events, every command and the config as bytes, and the snapshot, events and commands are msgpack. The config is named msgpack too, and it is empty bytes when the pack has no config_schema, so treat empty as the defaults. Config says how it is built. The saved state is opaque bytes: the format is yours. The world does not pass typed WIT records. That is on purpose. A typed record would break every mod, in every language, each time a field is added or a command changes. Bytes plus a documented encoding let the API grow without a break: a reader skips a key it does not know. The price is that your language gets no generated types. The ledgers and the JSON schema below are your types.

Target the world

The world is uptime-mod in package uptime:mod@0.1.0. The file is ModSDK/wit/uptime-mod.wit. Point your toolchain’s WIT binding generator at it.

The component imports one interface, host, with log(level: u8, msg: string). It exports three functions:

ExportGetsReturns
initconfig: list<u8>, saved-state: option<list<u8>>ok, or an error string
stepquantum (index, sim-time-ns, seed), snapshot: list<u8>, events: list<u8>a step-output, or an error string
migratefrom-schema: u32, state: list<u8>the new state bytes, or an error string

A step-output has four fields:

  • commands: a list of byte lists. Each one is one command.
  • panels: a list of a panel id and the bytes of one panel update. Leave it empty if you draw no panels. Panels has the format.
  • toasts: a list of byte lists. Each one is a toast, a named-msgpack map of tone ("Neutral", "Info", "Good", "Warn" or "Danger") and text. Leave it empty if you raise none. Toasts has the caps. The field is part of the WIT record, so a component built from an older copy of the WIT file needs rebuilding.
  • state: the bytes to save, or none.

A component may import only uptime:mod/host and the WASI 0.2 interfaces a standard toolchain emits: clocks, random, io, cli (stdin, stdout, stderr, environment, exit) and terminal. The game serves them as stubs. The clocks return the quantum’s sim time, random comes from the quantum’s seed, stdout and stderr go to the mod’s log, and exit traps. Any other import, such as the filesystem or sockets, fails to link, and the error names it. Script mods has the rule for determinism.

In the manifest, set script.runtime to component and script.entry to the .wasm file, such as bin/mod.wasm.

The encoding

Everything is msgpack with named fields. A struct is a map keyed by field name. Never use the positional encoding, which writes a struct as an array. A new field would shift every later one.

Rules for all three directions:

  • A struct is a map of field name to value.
  • An enum is externally tagged. A variant with fields is a map with one key, the variant name, whose value is a map of the fields: {"AcceptProspect": {"prospect_id": 7}}. A variant with no fields is the bare name as a string.
  • An optional field that is absent is either a missing key or a nil. Both appear: some fields inside a section are written as nil. Your decoder must treat both as absent. A top-level section is only ever a missing key, never a nil.
  • Integers may be written at any width. Read every width: the encoder picks the smallest that fits.
  • Unknown keys must be skipped. The game adds keys within a minor.

Snapshot

The snapshot is one map. It starts with the envelope, mod_api, quantum and sim_time_ns, then holds the sections you declared in reads. A section you did not declare has no key. A declared section also has no key when the world has nothing for it, such as active_attack when no attack is running. A declared list section that exists with no rows is an empty array. A top-level section is never nil. Your decoder must not require a declared key.

The fields of every section are in snapshot.v1alpha1.fields. The same data as a JSON Schema is in snapshot.v1alpha1.schema.json. Generate your language’s types from the schema if its tools can do that. Both files are in ModSDK/schemas/. The game’s build generates them, so they match the game. This page does not repeat them.

The game sends a new snapshot on the first step and then every fourth quantum. On the quanta between, the host hands your step an empty byte list. Keep the last non-empty snapshot and use it. The envelope’s quantum tells you how old it is.

Events

events is one map, quantum, dropped and events. Each entry of events is a lifecycle marker, a receipt, an engine event or a panel input. events.v1alpha1.fields has the batch and the category that carries each engine event. Engine events arrive only for the categories you listed in the manifest’s events, and receipts only when you list receipts. A panel input always reaches the mod that owns the panel, whatever you listed.

Commands

Each element of commands is one command: the externally tagged map of one variant. commands.v1alpha1.fields lists every command and field. A field that has a default may be omitted. AcceptProspect needs only prospect_id.

The game decodes each command by itself. A command it cannot decode is dropped, and you get an invalid_command receipt with the command’s position. The rest of the step still lands. A command outside the categories your manifest lists gets a permission_denied receipt.

Limits

The caps on a step’s output, the size of one command, the size of saved state and the size of one panel update are the constants in ModSDK/wit/limits.rs. The count of commands a step may return, and every fuel and memory limit, are in the table in Script mods. This page does not copy them.

Time is fuel. Fuel counts wasm operations plus a fixed charge for each call into the host, so a limit breach happens on the same step every run. Your component decodes the snapshot itself, and that costs fuel. Declare the least you need in reads. Script mods has the full limits table, the decode allowance, strikes and rebuilds.

A language runtime that ships a garbage collector or a large standard library spends fuel on its own start up. Measure an empty step before you add logic, and keep the whole component within the memory limit in the Script mods table.

Declare reads

Reads are declared in the manifest, not in the component:

"reads": ["topbar", "prospects", "capacity"]

The game projects only the sections the loaded mods declare and gives you only yours. The names you may use are in Script mods. The game checks each name when it loads the pack. Add a name when your code reads a section, and remove it when it stops.

Save state

Return the bytes you want to keep in state from step. The format is yours: the game never looks inside. The rules:

  • Return none to keep the last state. Return some to replace it.
  • A state over the state cap in limits.rs is refused with a warning, and the game keeps the previous one.
  • After a load, a strike that rebuilds the instance, or a reload, the game builds your component again and calls init with the config and the last accepted state. Anything you did not save is gone.
  • Set state_schema in the manifest to a whole number and raise it when the shape of your state changes. The game calls migrate(from-schema, state) on a throwaway instance to turn old state into the new shape. That instance first runs init(config, none), so init must succeed with no saved state, or the migration fails. migrate cannot read the snapshot or issue commands. If you do not handle a schema, return an error: the game turns the mod off for that save and shows a warning.
  • Keep state a function of your config, your saved state and what each step is given. Then a run replays the same after a load.

A worked example

ModSDK/examples/raw-wat/guest.wat is a complete component with no SDK. It is a few dozen lines of WebAssembly text. Each step it:

  1. Finds quantum in the snapshot and reads the integer after it.
  2. Issues AcceptProspect with that number as the prospect id.
  3. Saves one byte, the count of steps it has run, and restores it in init.

It handles the empty snapshot: it issues nothing and still counts the step. It scans for the key bytes where a real mod would decode the map. That is the only shortcut in it. It reads no optional field, so it never meets a nil. Yours will.

Make a component from it with wasm-tools:

wasm-tools component embed ModSDK/wit guest.wat --world uptime-mod -o embedded.wasm
wasm-tools component new embedded.wasm -o bin/mod.wasm

The game’s tests build this file the same way, load it and run it. They check that the command comes out at every integer width (fixint, u8, u16, u32 and u64) and that the state survives a reload.

Check your component

uptime-mod validate checks the pack and links the component against the game’s imports. uptime-mod run steps it against a scenario and prints each command that landed. See The uptime-mod command.

Script mods

A script mod is code that plays the game for the player. It runs once per quantum, reads a slice of the world and issues commands. This page covers what Lua mods and Rust mods share: the manifest fields, the quantum, declared reads, commands and receipts, limits, faults and the cost to a run.

A script mod can also draw panels, a HUD card and keys and raise toasts.

Language details are in Lua mods and Rust mods. To make a mod in any other language, see Other languages. The manifest rules that all packs share are in Content packs.

The manifest

A script mod sets kind to ["script"] and adds a script block. A mod may be both a script mod and a content pack: kind can list content and script together.

{
  "manifest_version": 1,
  "id": "com.example.my-mod",
  "version": "1.0.0",
  "api_version": "v1alpha1",
  "api_minor": 2,
  "game_version": ">=0.1.0, <0.3.0",
  "kind": ["script"],
  "script": { "runtime": "lua", "entry": "logic/main.lua" },
  "affects_gameplay": true,
  "commands": ["finance", "customers"],
  "events": ["receipts", "customers"],
  "reads": ["topbar", "prospects", "capacity"]
}
  • script.runtime is lua or component. A lua entry ends in .lua. A component entry ends in .wasm.
  • commands lists the command categories the mod may issue: infrastructure, finance, customers, incidents. The gate refuses a command outside them.
  • events lists the event categories the mod is told about. An unlisted category is never encoded for the mod.
  • reads lists the snapshot sections the mod sees. See the next section.
  • state_schema is a number that versions the mod’s saved state. The game migrates a saved state of another schema before the mod sees it. A mod that changes its state shape sets a new number and handles the old one in migrate.
  • A script mod with no commands does not affect gameplay. affects_gameplay must then be false, unless the same mod also carries hardware, scenarios or customers.

Set api_minor to the minor of the SDK you built against. The Rust SDK exports it as uptime_mod::snapshot::API_MINOR, currently 2. The Lua types come from the same ledgers. A mod built against an older minor keeps loading unless a later minor broke a section its reads names; Reference lists the breaks.

The quantum

A mod runs on a grid of sim time. One quantum is 15 seconds of sim time. Boundary k sits at k times 15 seconds. At each boundary the game runs one step of every script mod. Pausing the game stops the grid, because the grid is sim time.

At boundary k the game does these things in order:

  1. It lands the commands the mods returned from step k - 2.
  2. It closes the event batch for k: the engine events since the last boundary, the receipts from step 1 and, on the first boundary of a world, a lifecycle marker.
  3. On every fourth boundary, and on the first boundary of a world, it projects a fresh snapshot of the sections the mods declared.
  4. It hands step k to the mod worker.

The mod worker runs on its own thread and never touches the engine. The game waits for step k - 2 to come back before landing it, so a command lands exactly two quanta after its step, whatever the step cost. Two quanta is 30 seconds of sim time.

Player commands apply as they arrive, so they are already in when a boundary runs.

Declared reads

A mod sees only the sections its reads list names. Any other section is absent: nil in Lua and None in Rust. A section that exists and is empty is an empty list. A mod with no reads gets only the envelope.

The envelope always carries three fields: mod_api (the generation and the minor), quantum (the quantum the snapshot was projected at) and sim_time_ns.

The sections a mod may name are these. A name outside the list makes the pack invalid, with the rule manifest at /reads/<index>.

status, topbar, cage_offer, regions, az_trunks, azs, racks, hosts, switches, appliances, cables, edds, gateways, patch_panels, active_attack, flood_protection, cable_trays, lags, switch_pairs, host_pools, segments, subnets, reservations, device_transit, deliveries, sale_history, active_incidents, active_macro_events, spares, pending_growth_requests, pending_customer_requests, service_gates, service_planes, function_pool, difficulty, min_difficulty, hostility, relocation, migration, degraded_control_planes, stranded_vms, customers, prospects, customers_summary, staff, hire_pool, staff_auto_evacuate, cost_breakdown, cost_rates, financing_breakdown, revenue_breakdown, capacity, service_settings, business_policy, port_flows, workloads and parts.

Each section has the shape of the matching view in snapshot.v1alpha1.fields. A section that is not on the list, such as the cart, haulers, loose items or teaching UI, is never given to a mod.

Two sections carry the ids that some commands need and no other section lists:

  • workloads has one row for each workload that can be moved to another host, with unit_id, kind_tag, id, customer_id and host_id. MigrateWorkload and MigrateWorkloadToAz take kind_tag, id and unit_id back unchanged, with host_id as source_host_id. This works for every service, not only VMs.
  • parts has one row for each device with a part that is not healthy or that has an order, a spare or a swap in progress. The row lists all of that device’s parts. OrderPart, CancelPartOrder and RepairPart take its owner_kind and owner_id and a part’s component_id. A device whose parts are all healthy, with nothing ordered, is not listed, because those commands refuse a healthy part.

The game builds neither section unless a loaded mod declares it.

Rules for reads:

  • No name twice, and at most 64 names.
  • Declare the least you need. The game projects only the union of what the loaded mods declare, and hands each mod only its own sections.
  • The snapshot is copied into the mod and decoded there, and both come out of the step’s fuel. A large section such as hosts costs real fuel on a big fleet.
  • The snapshot refreshes every fourth quantum, which is 60 seconds of sim time. In between, a step sees the same snapshot as before. The envelope’s quantum tells you which quantum it came from.

Events

Each step gets a batch of events: quantum, a dropped count and an events list. dropped counts events the engine’s ring had already trimmed. A mod that sees a gap can resynchronise from the snapshot.

An entry of the list is one of these:

  • A lifecycle marker, save_loaded or run_started.
  • A receipt for a command that landed. See below.
  • An engine event with the time it happened (at_ns), its category and the event itself.

The engine event categories are incidents, customers, finance and builds. Which category carries which event is in events.v1alpha1.fields.

Commands and receipts

A command is an externally tagged value, such as AcceptProspect with a prospect_id. The set of commands a mod may issue is the mod subset of the player’s commands. commands.v1alpha1.fields lists every command and field. The game refuses a command a mod may not use.

Rules for commands:

  • A step may return up to 32 commands. The game drops the rest. The mod gets one rate_limited receipt whose index is 32, the first dropped position. The receipt carries no count, so work it out as the number you queued minus 32.
  • One encoded command may be at most 16 KiB. A step’s commands, panels and toasts together may be at most 256 KiB. The game discards a step over either cap whole and counts a strike.
  • The game drops a command it cannot decode. The mod gets an invalid_command receipt that carries only the command’s position. The decoder’s message reaches neither the mod nor the log.
  • Commands land in a fixed order: mod by mod, and each mod’s commands in the order the step returned them.
  • A landed command goes through the same handler as a player command, with the mod as the actor. The gate refuses a command whose category the manifest did not list, or one the game does not publish to mods. The receipt then carries the code permission_denied.

A receipt tells the mod what happened. It arrives in the batch for the boundary where the command landed, which is two quanta after the step. A receipt has these fields:

FieldMeaning
quantumThe quantum whose step issued the command.
indexThe position of the command in that step’s output.
okWhether the command succeeded.
codeA stable machine code. It is empty on success and never empty on a failure.
msg_keyA localization key for the failure, or empty.
createdEvery entity the command created, in creation order.

On a failure the code is the engine’s code, or else the message key, or else refused.

A command takes effect at the landing, but a snapshot shows it only once a snapshot is projected at or after that boundary. Do not repeat an order while the first one is in flight. The example autoscaler tracks each order by the quantum it issued and keeps that list in saved state.

State and migration

A step may save state. The host keeps the last state a step returned, and the save file keeps it too. State is bytes in the Rust SDK and a Lua table in the Lua guest. A state may be at most 1 MiB. The game refuses a larger one with a warning and keeps the previous state.

Saved state is the only thing that survives a load or a fault. After an out-of-fuel or trap strike, a reload or a load, the game builds the mod again and runs init with the config and the last saved state. A local, a global or a field that you did not save is gone. A mod whose behaviour depends only on its config, its saved state and what each step hands it plays the same after a load as it would have without one.

Two cases skip that rebuild. A mod the save recorded as off stays off, and the load shows a mod_off_in_save warning, unless the mod’s version is newer than the one the save ran. A newer version clears the off flag and the strike counters and resumes. A saved state over 1 MiB is dropped at load, before any migration, and the mod is rebuilt with no state.

The first step after a load gets the whole snapshot again, and the extra fuel that decoding it takes. The next section covers that fuel.

The state schema

state_schema in the manifest is a whole number that names the shape of your saved state. It defaults to 0. Raise it when you change that shape. The save records the schema next to the state.

When a save loads and its schema differs from the manifest’s, the game migrates the state before the mod sees it:

  1. It builds a throwaway instance of your mod and runs init on it with your config and no state.
  2. It calls migrate(from_schema, state) on that instance. from_schema is the schema in the save, and state is the saved state.
  3. What migrate returns becomes the state, and the save records the manifest’s schema.

In Lua, migrate is the M.migrate function of the entry table. In Rust, override Mod::migrate. Without one, in either language, a raised state_schema fails the migration and the mod stays off for that save.

migrate receives two values and has no ctx. It cannot read the snapshot, issue a command or save state. It runs on its own fuel, 100,000,000, and its result must fit the 1 MiB state cap.

A migration fails when migrate returns an error, runs out of fuel, traps or returns too much. It also fails when the save’s schema is newer than the manifest’s, because the game cannot turn a newer shape back. A failed migration keeps the old state, schema and version as saved, turns the mod off for that save and shows a warning. The rest of the world loads.

Decode fuel

A step that receives a new snapshot gets extra fuel to decode it, 180 for each byte of the snapshot, up to the allowance of a snapshot as large as a mod’s memory, 64 MiB. That extra fuel is on top of the 20,000,000 a step has, and it is why a snapshot refresh step can run longer than other steps. A snapshot cannot be larger than the memory limit, so the allowance stops there. Declare the least you need in reads so the allowance covers the decode with room to spare.

Limits

These limits hold for every script mod. Fuel is the deciding limit for time. It counts wasm operations plus a fixed charge for each call into the host, so the same mod hits the same limit on every machine.

LimitValue
Fuel per step20,000,000
Fuel per init200,000,000
Fuel for the init that rebuilds an instance after a strike20,000,000 for a component, 200,000,000 for Lua
Fuel per call into the host2,000, plus 4 per byte moved
Memory per mod64 MiB for the linear memory and the GC heap together
Commands per quantum32
Bytes in one command16 KiB
Bytes in commands, panels and toasts per step256 KiB
Toasts per step2
Bytes of one toast’s text160
Bytes of saved state1 MiB
Live host handles64
Bytes one random call may ask for64 KiB
Log lines per call64
Bytes kept of one log message512
Lines in a mod’s log ring256

A wall-clock backstop also guards each call. It is set far past the fuel limit and never under one second. It exists for a failure of the metering and fuel decides first, even on a loaded machine. If it ever fires, the mod is disabled and the run is not replayable.

Faults, strikes and rebuilds

Each step ends in one of these ways:

OutcomeWhat happens
OkThe commands and state are applied.
The mod returns an errorIn Lua, an error() in step. In Rust, an Err from step. The game drops the step’s commands and state. It is not a strike, and the mod runs again next quantum.
Out of fuelA strike.
A trapA strike.
An output over a capA strike. The instance stays.
A toast or a panel update over its own capNot a strike. The game refuses that toast or update and logs one line. The rest of the step lands.
Memory limitThe mod is disabled at once.
Wall-clock backstopThe mod is disabled at once.
Stack overflowThe mod is disabled at once.
A panic in the hostThe mod is disabled at once.

After an out-of-fuel or trap strike the game drops the instance. The next step builds a fresh instance and runs init again with the mod’s config and its last accepted state. That init runs on its own fuel, which the step is charged for, and it uses up one rebuild.

After an over-cap output strike the instance stays. The game discards that step’s output, builds nothing and uses no rebuild.

The counters:

  • Three strikes disable the mod. Three clean steps in a row clear the strikes.
  • A mod gets three rebuilds per session. A strike that needs a fourth disables the mod.
  • If init fails when the world loads, or when the instance is rebuilt, the mod is disabled.

A disabled mod runs nothing for the rest of that world’s session. It stays loaded and off. The game logs why, with a line for each strike. It does not stop the world.

A world refuses to start when the script host cannot load a component, or when a Lua script does not compile.

Determinism

A run must replay: the same inputs must give the same commands. The host removes the usual sources of drift.

  • The clocks a mod can read return the quantum’s sim time. Nothing reads the wall clock.
  • Random numbers come from a stream seeded by the quantum’s seed. The seed is a fixed hash of the world’s mod seed, the quantum and the mod’s id.
  • The engine runs with fuel metering and canonical NaN values, and with no threads and no relaxed SIMD.
  • Fuel counts operations, so a limit breach is the same breach on every run.

What stays with you: do not let an unordered walk decide the order of commands. Sort first. The language pages give the details.

Testing a mod

The runner has a batch mode for mods. It builds a scenario world with your pack selected, steps it for a number of quanta and prints one JSON line for each command that landed. The line has issued, landed, mod, ok and code. The run is the same every time for the same seed, and the seed is 1 unless you pass one. A golden file holds the expected output. The runner exits 1 and names the first differing line when the output does not match. A bad command line exits 2.

sim-runner --mod-run PACKDIR (--scenario ID | --world SAVE) --quanta N [--seed N] [--golden FILE] [--watch]

Your mod’s log lines go to stderr as [mod-log] <id> k=<quantum> <level>: <text>, so they never change the JSON lines a golden file compares. --watch reloads the pack when its files change, as developer mode does in the game. uptime-mod run wraps this; see the CLI.

Developer mode

Developer mode reloads your mod into a running single-player game when you change its files, without a restart. Turn it on with the Developer mode toggle on the title screen’s Mods panel, then start or load a run. The laptop’s Mods page then shows the mod log, the last reload and what a change needs. It is off in multiplayer and in the demo.

The game looks at your pack folder every half second and sorts each change:

You changedWhat happens
A Lua scriptThe scripts reload between two steps.
A component, the manifest’s script entryThe new component is compiled and reloaded between two steps.
A panel fileThe panels are checked again and swapped in at the next quantum.
A file under art/The game redraws the art.
A scenarioStart a new run to play it.
Anything else: the manifest, hardware rows, the config schemaSave, then load the save.

A reload builds a fresh instance and runs init with the mod’s config and its last saved state, as a rebuild does. It is not a strike and uses no rebuild. The two steps the old code had in flight still land; the new code’s commands land from the quantum after. A reload that fails keeps the old code running and writes reload failed to the log.

A mod the game disabled (strikes, rebuilds, a failed init, a fault) runs again after a reload that comes up, with its counters cleared. A mod that a save keeps off stays off.

A save made after a reload records that developer mode changed the run. Loading it later says the pack changed since the save, as any edited pack does.

What a modded run costs the player

A run that has had a pack that affects gameplay active earns no achievements, posts no leaderboard scores and feeds no Steam stats. The flag stays set for the life of the save, including after a recovery that strips the mod. A pack that does not affect gameplay never sets it. Packs that only carry locale or art, and script mods that declare no commands, are in that group.

Tell players before they install the mod. The flag cannot be cleared once it is set.

Panels

A panel is a screen a script mod draws inside the game. You declare the panel in a JSON file. Your mod fills it with data on every step. The game draws the widgets and checks every value your mod sends. A mod never draws pixels and never runs code in the game’s interface.

Panels belong to script mods. The shared rules for script mods are in Script mods.

Declare panels

List the panel files in the manifest, at most 32:

"panels": ["panels/fleet.json"]

Each file is a JSON object with a panels list and may be at most 64 KiB. Every object refuses keys it does not list. This file declares one panel with every widget kind:

{
  "panels": [
    {
      "id": "fleet",
      "title": "Fleet policy",
      "point": "dashboard",
      "widgets": [
        { "id": "cash", "label": "Cash", "kind": "stat_tile" },
        {
          "id": "queue", "label": "Pending orders", "kind": "table",
          "columns": [
            { "id": "host", "label": "Host" },
            { "id": "age", "label": "Age", "numeric": true }
          ]
        },
        { "id": "load", "label": "Load", "kind": "line_chart", "tone": "info" },
        { "id": "note", "label": "Status", "kind": "text" },
        { "id": "auto", "label": "Buy automatically", "kind": "toggle" },
        { "id": "limit", "label": "Spend limit", "kind": "slider", "min": 0, "max": 100, "step": 5 },
        {
          "id": "mode", "label": "Mode", "kind": "select",
          "options": [ { "id": "fast", "label": "Fast" }, { "id": "slow", "label": "Slow" } ]
        },
        { "id": "go", "label": "Buy now", "kind": "button" }
      ]
    }
  ]
}

A panel has these fields:

FieldMeaning
idThe panel’s name, unique in your mod.
titleThe heading, at most 64 bytes.
pointWhere the panel appears. See the next section.
entityRequired for entity_tab and refused for other points.
keyOptional. A default key that opens the panel. dashboard panels only. See Keys.
widgets1 to 24 widgets. A hud panel has 1 to 3.

A button widget may also carry a key, which presses it. See Keys.

An id matches [a-z0-9_]{1,32}. Panel ids are unique per mod. Widget ids are unique per panel. Column ids are unique per table, and option ids are unique per select.

Extension points

point is one of these:

  • dashboard adds a panel to the dashboard.
  • entity_tab adds a tab to one kind of entity. Set entity to host, rack or service.
  • settings adds a panel to the settings screen.
  • hud adds a small card to the player’s view, outside the ops console. See The HUD point.

A control (toggle, slider, select or button) sits only on a dashboard or settings panel. An entity_tab panel and a hud panel display values and take no input.

Widget kinds

KindExtra fieldsValue your mod writes
stat_tilenoneStat: a value, a detail and a tone.
tablecolumns: 1 to 8, each with id, label and optional numericRows: a list of rows, each with one string per column.
line_chart, bar_chartoptional toneSeries: a list of finite numbers.
textnoneText: a string.
togglenoneControl with Bool.
slidermin, max and step, all requiredControl with Number.
selectoptions: 1 to 32, each with id and labelControl with Choice, an option id.
buttonnoneControl with Press.

A field that does not belong to the widget’s kind is an error. A slider needs finite bounds, min below max and step above 0. The distance from min to max must be a whole number of steps.

A tone is neutral, info, good, warn or danger in the file. In data it is Neutral, Info, Good, Warn or Danger. A tone is never a colour. The game picks the colour.

Fill a panel with data

Call ctx:panel(id, update) in Lua, or step.panel(id, &update) in Rust. The update has a values table that maps a widget id to a value. Each call replaces the whole panel’s data, and the last call for an id in a step wins. A widget you leave out shows no value, and it stays blank after a load or when a player joins. Send every widget you want shown, on every update.

ctx:panel("fleet", {
  values = {
    cash = { Stat = { value = "$12,400", detail = "after rent", tone = "Good" } },
    queue = { Rows = { { "web-1", "3" }, { "web-2", "7" } } },
    load = { Series = { 0.2, 0.4, 0.3 } },
    note = { Text = "All hosts answer." },
    auto = { Control = { Bool = true } },
    limit = { Control = { Number = 40 } },
    mode = { Control = { Choice = "fast" } },
  },
})

In Rust the same update uses the SDK types:

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
use uptime_mod::{PanelUpdate, Tone, WidgetValue};

let mut values = BTreeMap::new();
values.insert(
    "cash".to_owned(),
    WidgetValue::Stat { value: "$12,400".into(), detail: "after rent".into(), tone: Tone::Good },
);
step.panel("fleet", &PanelUpdate { values, entities: None })?;
}

For an entity tab, add entities. It is a list of { id, values }, one row for each entity the tab shows. Entity rows belong to entity_tab panels only, an update holds at most 256 of them, and an entity may appear once.

The control values show the control’s current state. When you change them, the game redraws the control to match.

When data arrives

Panel data lands with the step’s commands, two quanta after the step that wrote it. A quantum is 15 seconds of sim time, so data from step N shows at N + 2. Until then the player sees the previous data.

The game checks each update when the step returns:

  • The panel must be one your mod declares, and each widget id must belong to that panel.
  • Each value must fit its widget. A Rows row must have one cell per column. A Choice must name an option. A Number must be inside the slider’s range.
  • A step may not return the same panel twice.

A refused update writes one line to the host log and changes nothing. The previous data stands, and a refusal is never a strike. The game logs the first 8 refusals of a step and counts the rest.

The game refuses an update that goes over a cap below. It never trims one.

Caps

WhatCap
Panel files in a manifest32
Size of a panel file64 KiB
Widgets in a panel24
Columns in a table8
Rows in a table100
Points in a chart series240
Options in a select32
Entity rows in one update256
Bytes of one panel update16 KiB
Bytes of panel data the world holds for one mod64 KiB
Bytes of one update to a hud panel2 KiB
Commands, panels and toasts in one step256 KiB

An update that would take your mod past 64 KiB of held panel data is refused, and that panel keeps its previous data. A step that goes over 256 KiB in total is discarded whole and counts as a strike. See Script mods. The caps on a hud panel are in The HUD point, and the caps on keys in Keys.

Text rules

All panel text is plain text. The game never reads markup, so a string such as [url=x]y[/url] shows as typed.

TextCap
Panel title, widget label, column label, option label64 bytes
Stat value, table cell128 bytes
Stat detail64 bytes
Text widget1,024 bytes

Labels that name a locale key

A panel title, widget label, column label or option label in a panel file is plain text, or @ followed by a locale key. "label": "@mod-com-example-fleet-rate" shows the pack’s text for that key, in the player’s language. Text starting with @ is a key, so write @@ for a label that really starts with an at sign: "@@handle" shows @handle.

The key must be one of the pack’s own messages, or a message of a locale pack the mod lists in dependencies (see Content packs). Either way the owning pack’s en files must define it. A key in any other namespace, a game key included, fails with locale_key_not_namespaced. A key with no en text, or whose dependency is not installed, fails with locale_key_missing_en. Both name the file and the field. A key reference counts against the 64 byte cap as written. If the player’s packs do not hold the key, the label shows the last word of the key.

A pack that carries locale content cannot also be a script mod, so a script mod ships its words in a second, locale pack and depends on it:

com.example.fleet-words/        kind content, content ["locale"]
  locale/en/main.ftl            mod-com-example-fleet--words-rate = Spend rate
com.example.fleet/              kind script
  manifest.json                 "dependencies": { "com.example.fleet-words": ">=1.0.0" }
  ui/panels.json                { "id": "rate", "label": "@mod-com-example-fleet--words-rate", ... }

The locale pack is client-required and the script mod is not, so a player joining a multiplayer world needs only the text pack. Data a mod writes at run time (stat values, cells, text) is not looked up.

The rules differ for files and data:

  • In a panel file, a title or label that holds a control character or an invisible formatting character (bidirectional marks, overrides and isolates, zero-width spaces, the byte order mark) makes the pack invalid. The zero-width joiner and non-joiner are allowed, since Persian and Indic text need them. The game does not fix it. What you wrote is what shows. A config schema’s titles follow the same rule.
  • In data, the game drops those same characters and normalises the rest to NFC. A text widget keeps its newlines. Every other string is one line. The text must still fit its cap after cleaning, or the update is refused.

The HUD point

A hud panel is a small card on the player’s view. The player sees it while walking the datacenter, with no console open. Use it for one number or one short line that a player should be able to glance at, such as a count of open incidents. A card shows values only. The HUD has no cursor, so it takes no input.

{
  "panels": [
    {
      "id": "hud",
      "title": "Incidents",
      "point": "hud",
      "widgets": [
        { "id": "open", "label": "Open incidents", "kind": "stat_tile" }
      ]
    }
  ]
}

You fill it with ctx:panel or step.panel, like any panel. The update lands two quanta after the step, as When data arrives says, and every player in the world sees the same card. Each player can switch a mod’s card off with “Show on HUD” on the Mods page, and the HUD shows at most 4 cards across all mods.

The pack check refuses a hud panel that breaks one of these rules. Each message starts with the code:

CodeWhen
hud_point_twiceThe mod declares a second hud panel, in any of its panel files.
hud_widget_countThe panel has more than 3 widgets.
hud_widget_kindA widget is anything but a stat_tile or a text.
key_not_allowedThe panel carries a key. A card cannot be opened.

A hud panel also refuses entity, as every panel that is not an entity_tab does.

The caps are tighter than for other panels:

WhatCap
hud panels per mod1
Widgets in the panel3
Bytes of one update2 KiB
Values in one update3
A text value128 bytes, on one line

An update over a HUD cap is refused whole, like any other: one line in the host log, and the previous data stands. A line break in a text value on the HUD refuses the update. The card’s data counts against the same 64 KiB of held panel data as the mod’s other panels.

An update with an empty values clears the card, in Lua (values = {}) and in Rust (an empty map) alike. A mod uses it to take an alert down when there is nothing to show.

Player input

A player presses a control, and your mod learns about it as an event. The event arrives in your mod’s next batch, after the receipts, whatever your events list says. Only the mod that owns the panel gets it.

for _, ev in ipairs(ctx.events) do
  local input = ev.UiInput
  if input and input.panel == "fleet" then
    if input.widget == "auto" then
      auto_buy = input.value.Bool
    elseif input.widget == "go" and input.value == "Press" then
      want_host = true
    end
  end
end

A UiInput entry has at_ns, player, panel, widget and value. The value is "Press" for a button, { Bool = ... } for a toggle, { Number = ... } for a slider and { Choice = "id" } for a select. Keep the result in saved state. The input is a one-time event, and the game does not repeat it.

A command you issue in response lands two quanta after the step that read the input. A panel update written in the same step lands with that command, two quanta after the step.

The game checks an input before your mod sees it. It refuses an input in these cases, and your mod gets nothing:

ReasonWhen
unknown_mod, unknown_panel, unknown_widgetThe id does not exist in the world.
not_a_controlThe widget only displays.
wrong_type, bad_value, unknown_choiceThe value does not fit the control.
out_of_range, off_stepA slider value is outside the range or between two steps.
rateA connection sent more than 8 inputs at once, or more than 4 a second over time.
queueYour mod already has 32 inputs waiting for this quantum.
not_runningThe mod’s worker has stopped.
dedicated, not_joinedSee the next section.

Who can press

Every player who has joined the world can press a control: the host and every guest. The event’s player is the seat of the player who pressed it, and 0 is the host. The game sets it from the connection, so a client cannot press as someone else. A connection that has not been accepted into the world is refused with not_joined.

If a control should only work for the host, check player in your mod and ignore the input when it is not 0. The game does not do this for you.

A dedicated server has no host player, so its panels show values and take no input (dedicated).

A mod cannot press its own controls. Input comes from player requests only, and no mod command presses a control.

The demo has no mods, and a demo build refuses panel input.

Keys

A mod can ask for a key. A key does one of two things:

  • A key on a dashboard panel opens that panel in an overlay, from anywhere in the world. The player does not walk to the laptop.
  • A key on a button widget of a dashboard panel presses the button, from anywhere. The panel does not need to be open.

Both are for the mod’s own panels. A mod key never opens a panel that belongs to the game or to another mod.

{
  "id": "board",
  "title": "Alert board",
  "point": "dashboard",
  "key": "F6",
  "widgets": [
    { "id": "ack", "label": "Acknowledge", "kind": "button", "key": "F7" }
  ]
}

A key on any other panel or widget is refused with key_not_allowed: a settings, entity_tab or hud panel, or a widget that is not a button.

A press key gives your mod the same UiInput event as a click on the button: the panel, the widget, the value "Press" and the player who pressed. The same checks apply, including the input limits in Player input. A dedicated server takes no input, so a press key does nothing there.

What a key looks like

key is a default. It is one base key with optional modifiers, written in this order and each at most once:

ctrl+  alt+  shift+  BASE

BASE is one of A to Z, 0 to 9 or F1 to F12, in capitals. F6, shift+F7 and ctrl+alt+J are keys. f6, Ctrl+F6, alt+ctrl+J and Space are not.

Caps and refusals

A mod declares at most 4 keys in all its panel files. The pack check refuses these, and each message starts with the code:

CodeWhen
key_not_allowedThe key is not on a dashboard panel or on a button of one.
key_bad_chordThe key does not follow the grammar above.
key_reservedThe base key is one the game binds.
key_twiceAnother key of this mod has the same default.
key_capThis would be the mod’s fifth key.

The game binds these base keys, and a mod default on one is refused whatever its modifiers, so ctrl+F is refused as well as F:

A, B, D, E, F, G, K, M, O, Q, R, S, U, W, 1, 2, 3, 4, F1, F2, F4, F8, F10, F12, and the keys Escape, Tab and Shift.

That list can grow with the game. The source of truth is RESERVED_BASE_KEYS in sim_core::mod_ui, and uptime-mod validate reports a refused key with the code key_reserved.

The game matches its own keys by the physical key and ignores modifiers, which is why a modifier does not free a reserved key. Among a mod’s own keys a modifier does count: F6 and ctrl+F6 are two separate keys and a press of one never fires the other.

What the player controls

Only the default travels with the world. The binding is the player’s own, kept in their settings, so two players in one world can use different keys.

  • A player can rebind a mod key, or turn it off, in Controls. The pane lists each mod’s keys under the mod.
  • A default that the game already uses, or that an earlier mod in the world’s load order has taken, does not bind. The key starts unbound and the player picks one in Controls. A mod cannot take a key from the game or from another mod.
  • A player’s own choice wins over another mod’s key: binding a chord to a mod key takes it from any other mod key. A key the game itself uses cannot be given to a mod key, whatever the modifiers.

So write your panel and your button so they work without the key, and say nothing in the UI that depends on a particular key. The alerts example’s card says “open the board” and not “press F6”.

The example

ModSDK/examples/com.uptime.example-alerts declares a hud card, a dashboard panel that opens on F6, and an Acknowledge button on F7. It raises a toast too. See Toasts.

Toasts

A toast is a short notice that appears on screen for a few seconds and goes away. A script mod raises one from its step. The game shows it to every player, the host and the guests alike. Use a toast for a thing the player should notice now, such as an attack starting. Keep a record of anything the player may want later in a panel, because a toast leaves no trace.

A toast is a screen-corner notice in the game’s own style. A mod picks the tone and the words and nothing else. It cannot set a colour, a duration, an icon or a button.

Toasts belong to script mods. The shared rules for script mods are in Script mods. A toast needs no entry in the manifest and no panel file.

Raise a toast

In Lua, call ctx:toast(tone, text):

for _, ev in ipairs(ctx.events) do
  local e = ev.Engine and ev.Engine.event
  if type(e) == "table" and e.DdosStarted then
    ctx:toast("Danger", "DDoS attack: " .. e.DdosStarted.gbps .. " Gbps")
  end
end

In Rust, call step.toast(tone, text). It returns an error when the toast breaks a cap, so you learn at the call:

#![allow(unused)]
fn main() {
use uptime_mod::Tone;

if let Err(why) = step.toast(Tone::Warn, "Cash is below the reserve") {
    step.log(3, &why);
}
}

Handle the error as shown. Returning it from step with ? would drop the step’s commands and state.

A component in any other language puts each toast in the toasts field of its step-output. See Other languages.

Tone

The tone says how serious the notice is. It is one of these, spelled as shown:

ToneUse it for
NeutralA plain fact.
InfoSomething worth knowing.
GoodSomething that went well.
WarnSomething that needs attention soon.
DangerSomething that is going wrong now.

A tone is never a colour. The game picks the look. An unknown tone is a Lua error, and in Rust it does not compile, because Tone is an enum.

Text

The text is plain text of 1 to 160 bytes, on one line. The game never reads markup, so [b]x[/b] shows as typed. The cleaning is the one panel labels get in data: the game drops every control character, a newline included, and the invisible formatting characters, turns the rest into NFC and refuses a text that is too long after that. A text that is empty after cleaning is refused. The text is not looked up as a locale key, so it shows in the language you wrote.

The game shows the mod’s id with the toast, so a player can tell who raised it.

A mod has one toast on screen at a time. A newer toast from the same mod replaces the one still showing, so when a step raises two, the player sees the second.

Caps

WhatCap
Toasts in one step2
Bytes of one toast’s text, after cleaning160
Toasts the world holdsThe newest 16, across all mods
Commands, panels and toasts in one step256 KiB

The Lua guest and the Rust SDK refuse a third toast, or a text that is empty or over 160 bytes, at the call. In Lua that is an error you can catch with pcall. A step that catches it still lands.

The game checks every toast again when the step returns, because a component in another language has no SDK to refuse for it:

  • A step that returns more than 2 toasts has all of its toasts refused. The game never keeps the first two. It writes one line to the host log, toast refused: toasts_over_cap, and the rest of the step lands.
  • A toast whose text is empty or too long after cleaning is refused on its own, with one log line, and its neighbour in the same step still lands.
  • A refused toast is never a strike.
  • The toasts count toward the 256 KiB a step may return. A step over that is discarded whole and counts as a strike, as it does for commands and panels. See Script mods.

One quantum is 15 seconds of sim time, and a step can raise at most 2 toasts, so a mod cannot raise more than one toast every 7.5 sim seconds. The world also holds only 16 toasts at a time. Raise a toast for a change, not on every step, because a player can mute a mod that is noisy.

When a toast arrives

A toast lands with the step’s commands, two quanta after the step that raised it. A quantum is 15 seconds of sim time, so a toast from step N shows at N + 2, about 30 seconds of sim time later. See Panels.

Toasts are part of the world. A save keeps the toasts that have landed. A toast raised by a step that was still in flight when the game saved lands after the load. The game never shows a player the same toast twice, and a player who loads or joins a world does not see the toasts that landed before. They see only the ones that land afterwards.

A mod that stops, or that the game disables, loses the toasts it had in the world. A save that loads without the mod drops them too.

What a player controls

A toast is transient. It never goes into the comms panel’s history, and a mod cannot read one back, not even its own. A player can mute a mod’s toasts, one mod at a time, from the Mods page. A muted mod keeps running and keeps raising toasts, and the player sees none of them.

Because of this, never put the only copy of something important in a toast. A player may have muted you, may have looked away or may have joined later.

Toasts in multiplayer

Every joined player gets the same toast, and a mod cannot aim one at a single player. A dedicated server shows toasts to its players too. A mute is a choice each player makes on their own screen. See Multiplayer.

The example

ModSDK/examples/com.uptime.example-alerts raises one Danger toast when a DDoS attack starts. It also shows a HUD card and two keys. See Panels.

Mods in multiplayer

In a multiplayer world the host picks the packs and the host’s game runs the world. This page tells you what other players must install to join a world that uses your mod, what they see when they cannot, and how your mod’s panels behave with several people in the room.

Which packs a joiner needs

A joiner needs only the packs that change what their own screen draws: packs that carry art or locale content. The game calls these client-required packs. Every other pack reaches the joiner through the world itself, so it needs no install:

Your packJoiner must install it
Hardware, scenarios or bothNo. The world carries the catalog.
A script modNo. The script runs on the host.
A script mod with panels, a HUD card, keys or toastsNo. They travel in the world’s data.
Anything with art or locale contentYes, at the same version as the host.

The game loads two kinds of art file, a pack icon under art/icons/ and an equipment model under art/models/, and Fluent text under locale/. See Content packs and Equipment models.

The host’s world records each client-required pack with its version and hash. A joiner’s game states which of these packs it holds when it connects, and the host’s game compares. A copy that differs in a single file has a different hash and is refused.

A pack cannot carry art and a script

A pack that declares art or locale content cannot also be a script mod. The game refuses the manifest with the rule client_pack_has_script, and uptime-mod validate shows it. The game’s message says why: a pack that every player downloads cannot carry a script. Split the work into two packs. Make the script pack depend on the art pack if it needs the art.

When a joiner is refused

A joiner who lacks a client-required pack is refused before it enters the world. The host’s game sends one of two messages, both in plain words:

  • Missing: “The host’s world uses packs you don’t have”, followed by the pack ids and versions.
  • Mismatch: “Your copy of a pack the host uses doesn’t match theirs”, followed by the pack id, the host’s version and the joiner’s.

The refusal also carries the Workshop item id of each pack, when the pack was uploaded with uptime-mod upload. The package file records the id. See Workshop. A pack that never went to the Workshop gets no id and no button.

The dependency dialog

The refusal opens the Packs needed to join dialog. It has one row for each pack:

  • A missing pack reads “name version, not installed”. A pack at another version reads “name: the host has X, you have Y”.
  • A row with a Workshop item id has Subscribe (or Update for a mismatch) and Open in Workshop. A row without one shows the text and no button, since the game has nowhere to fetch it from.
  • Retry joins again with whatever is installed by then. Back returns to the title screen. The dialog never joins on its own.

Subscribing downloads a pack. It does not turn it on. A joiner’s game claims every usable client-required pack on the machine, switched on or not, so subscribing is enough.

The lobby block

A joiner usually learns about missing packs before the host starts the world. The host’s game publishes the list of client-required packs in the lobby. A joiner who lacks one sees the same sentence in the room, in place of the waiting caption, and its Ready button is disabled.

The game checks again when a Workshop item the joiner is waiting for finishes installing. For any other change, the room has a Check again button. Ready returns once nothing is missing. If the host has already started the world, the joiner enters on its own once the missing packs are installed or Check again clears the block. If the host starts the world while the block stands, the joiner stays in the room.

A host with no client-required packs publishes nothing, and an older host that publishes nothing blocks nobody in the lobby. The runner still checks at the join.

When the host changes world

If the host loads a different world while guests are connected, the game checks every guest again against the new world. A guest who lacks one of its client-required packs is disconnected with the same missing or mismatch message.

Script mods run on the host only

A script mod runs once, in the host’s game, or in the server process on a dedicated server. Guests do not run it, do not need its files and cannot see its code. They see what it does: the commands it issues change the shared world like any other command, and its panels, HUD card and toasts appear on their screens.

This has two results for you:

  • On a dedicated server the operator sets your mod’s config, and no connection can change it. See Config.
  • Hot reload is off in a multiplayer world. See Lua mods.

Panels in multiplayer

A panel’s spec and data go to every player in the world, so a guest sees your panel with no pack installed. Who may press its controls depends on the world:

WorldWho can press
Single playerThe player.
A hosted worldEvery joined player, the host and the guests.
A dedicated serverNobody. Its panels are view-only.

The UiInput event your mod receives carries a player field. It is the seat of the player who pressed. The host is seat 0, in a single-player world as well, and each guest has a seat of their own. The game fills player in from the connection, so a client cannot press as someone else.

Check player before a control does something only the host should decide. A button that buys hardware, changes a policy or clears state is host business in most mods, and every guest can press it. Compare against 0:

for _, ev in ipairs(ctx.events) do
  local input = ev.UiInput
  if input and input.panel == "fleet" and input.widget == "go" then
    if input.player == 0 then
      want_host = true
    end
    -- a guest's press is ignored
  end
end

A control that every player may use, such as a toggle that changes a panel’s own view, needs no check.

The game refuses a press from a connection that has not finished joining (not_joined) and any press on a dedicated server (dedicated). Your mod sees neither. The limits that apply to each connection are the ones in Panels: a burst of 8 inputs, 4 a second over time, and 32 waiting for your mod in one quantum across all players. A press from a guest therefore counts against the same 32 as the host’s.

On a dedicated server a player’s panel shows its controls disabled, with the tooltip “Mod panels on this server are view-only.”

HUD, toasts and keys in multiplayer

A hud panel and a toast are part of the world, like a panel. Every joined player gets the same ones, the host and the guests alike, with no pack installed. A dedicated server shows them to its players too. Nothing is aimed at one player: a mod cannot show a toast to only the guest who pressed a button, and it cannot give one player a different HUD card.

Each player chooses for themselves what to see. A player can mute a mod’s toasts and switch its HUD card off (“Show on HUD” on the Mods page), and both choices are on their own screen. They change nothing for anyone else. The HUD shows at most 4 cards across all mods.

A key is a setting of the player’s own client. The world carries only the key a mod asks for as a default. Each player binds, rebinds or turns off a mod’s key in their own Controls, so two players can use different keys for the same mod, and a key one player turned off still works for the other. A key opens the panel on the player’s own screen. A press key sends the press as that player, so UiInput.player is their seat, and your mod must check it when a press is host business, as Panels in multiplayer says.

On a dedicated server the overlay’s controls are disabled, as in the laptop, so a press key sends nothing there.

A player who joins mid-session sees the HUD card at once. They see only the toasts that land after they join, not the ones that landed before.

The uptime-mod command

uptime-mod makes, checks, packages, runs and uploads a mod. It ships in the game’s ModSDK/bin folder. It checks a pack with the same code the game uses. When validate passes a pack with no warnings, the game loads that folder as it is. When it passes with warnings, the game loads the package output and refuses the folder itself.

uptime-mod new --template content|lua|rust --id <mod id> [--title <title>]
               [--sdk <ModSDK folder>] [<dir>]
uptime-mod validate <dir> [--json]
uptime-mod package <dir> [--out <dir>]
uptime-mod run <dir> (--scenario <id> | --world <save>) --quanta <n>
               [--seed <n>] [--golden <file>] [--watch] [--runner <path>]
uptime-mod upload <dir> [--visibility private|friends|public]
                  [--change-note <text>] [--preview <image>] [--dry-run]
uptime-mod model <source.glb> --proxy <proxy.glb> --pack <dir> --item <item id>

uptime-mod --help prints this usage with a line on each command, and uptime-mod <command> --help prints the same text.

Exit codes

CodeMeaning
0The command finished.
1The pack has problems, or a step failed.
2The command line is wrong, or run found no runner.

run passes on the exit code of the game’s runner. See run.

new

new makes a pack folder from a template.

uptime-mod new --template lua --id com.example.my-mod
  • --template is content (a hardware pack), lua or rust (a script mod). Each template is a working mod that validate accepts, except rust, which needs a build first. The command reports bin/mod.wasm as missing and tells you how to build it.
  • --id is a reverse-DNS id such as com.example.my-mod.
  • --title sets the display name. Without it, the title comes from the last part of the id, so com.example.cool-gear becomes Cool Gear. A title cannot hold quotes, backslashes or line breaks.
  • --sdk is the ModSDK folder. Only the rust template uses it: the new Cargo.toml gets the absolute path of rust/uptime-mod in that folder. Without --sdk, the command uses UPTIME_SDK, then the ModSDK folder this uptime-mod runs from. If it finds none, it exits 1 and asks for --sdk.
  • <dir> is the folder to make. It defaults to the id. The command refuses a folder that exists and is not empty.

The command replaces {{id}} and {{title}} in the template files, and {{sdk_path}} in the rust template, and changes nothing else.

validate

validate checks a pack the way the game does and lists every problem.

uptime-mod validate com.example.my-mod

It runs the game’s pack scan, which covers the manifest, the schemas, the archive rules and the API minor. For a script mod it then compiles the script the way the game does before a world runs. A component must link against the game’s imports, and a Lua pack must compile with no syntax error.

Each problem is one line with the file, the field, the rule in brackets and a message. A clean pack prints ok: with the id, the version and the pack hash.

--json prints one JSON report with ok, id, version, hash, a problems list and a warnings list. Each problem and each warning has file, field, rule and message.

validate checks the pack without the files that belong to building a mod: dotfiles, Thumbs.db, desktop.ini, and the top-level src, target, tests, dist, Cargo.toml, Cargo.lock and workshop.json. package leaves the same files out, and writes a hard-linked file as a plain copy.

The game reads a pack folder as it is. It skips only the files an operating system makes on its own (.DS_Store, Thumbs.db, desktop.ini, .localized and ._*), so it refuses a folder that holds any of the other build files or a hard link. When the pack itself is clean, validate also scans the folder as it is and prints each thing the game would refuse there as a warning: line. Those warnings do not fail the command, but they mean the folder must not be copied or linked into the mods folder. Put the package output there instead.

The exit code is 0 for a clean pack and 1 for any problem.

package

package validates the pack, then writes the files the game accepts to <out>/<id>-<version>/. The default <out> is dist.

uptime-mod package com.example.my-mod --out dist
  • The folder holds the accepted files and uptime-package.json, which records the id, the version and a SHA-256 hash of the files.
  • The output depends only on the pack’s files. It is sorted and carries no timestamps, so two runs give identical bytes.
  • The command rescans its output and checks that it gives the same hash.
  • It replaces an earlier package of the same name. It refuses a folder that exists and holds something else.
  • It refuses an --out whose package folder would be the pack folder itself, would hold it, or would sit among the pack’s own files. A dist folder inside the pack is fine.

A pack with any problem is not packaged, and the exit code is 1.

run

run plays your pack in a scenario and prints what your mod did. It validates the pack, stages a copy under the pack id and starts the game’s own sim-runner in batch mode on that copy.

uptime-mod run com.example.my-mod --scenario garage-to-glory --quanta 200

The runner prints one JSON line for each command that landed, with issued, landed, mod, ok and code. A run is the same every time for the same seed, and the seed is 1 unless you pass --seed.

Your mod’s own log lines (ctx:log in Lua, host.log in a component) go to stderr, one per line, after the quantum that wrote them:

[mod-log] com.example.my-mod k=12 info: capacity low: buying a host

The level is debug, info, warn or error. Control characters are dropped and a line is cut at 512 bytes. The JSON lines on stdout stay as they were, so a --golden file is not affected.

FlagMeaning
--scenario <id>A built-in scenario to build the world from.
--world <save>A save file to load instead.
--quanta <n>How many 15 second quanta to step.
--seed <n>The scenario seed. --world ignores it.
--golden <file>A file of expected output. The runner exits 1 and names the first differing line when the output does not match.
--watchCopy changes from your pack folder into the running copy, so the runner reloads them between steps. Lua scripts, a component and panel files reload. A change to anything else prints a line saying what it needs: a new run for a scenario, a restart for the manifest or hardware.
--runner <path>The sim-runner to use.

The runner’s exit code becomes the command’s: 0 for a finished run, 1 for a golden mismatch and 2 for anything else it refuses. A pack with problems exits 1 before the runner starts, and the output says nothing was run.

uptime-mod looks for the runner in this order:

  1. --runner <path>.
  2. The UPTIME_RUNNER environment variable.
  3. The game folder that holds ModSDK: bin/<triple>/sim-runner, where <triple> is the target for your machine, such as aarch64-apple-darwin or x86_64-pc-windows-gnu.
  4. macOS only: the same game folder, Uptime.app/Contents/MacOS/bin/<triple>/sim-runner, where the app bundle keeps its runner.

When no runner is found, the command exits 2 and lists every place it looked.

A stop signal (Ctrl-C) goes to the runner, and run removes its staged copy when the runner exits. A second stop ends a runner that ignored the first.

upload

upload packages the pack the way package does, checks the result again and publishes it as a Steam Workshop item. --dry-run prints what it would send and sends nothing.

uptime-mod upload com.example.my-mod --dry-run

The item id is kept in workshop.json in your pack folder, so each later upload updates the same item. A new item is private. Exit codes are 0 for a finished upload or dry run, 1 when the pack or Steam refuses it and 2 for a wrong command line. Workshop has the flags, visibility, updates and the legal agreement.

model

model builds the three files of a new host or switch model from the GLB you export from your modelling tool, writes them into the pack, then checks them the way the game does. Equipment models covers the conventions and limits.

uptime-mod model work/box.glb --proxy work/box_proxy.glb --pack com.example.my-mod --item box
  • <source.glb> is your full model: the body, the ports, the lights and the marker empties, as one .glb with its data and textures inside. Keep it outside the pack folder. The command refuses a source inside --pack, since the game refuses any file a pack does not load.
  • --proxy is the far model you made by hand. The command copies it unchanged.
  • --pack is the pack folder. The files go in its art/models/ folder, and the command replaces any earlier files of the same item.
  • --item is the id of a new host or switch in the pack’s hardware rows. An override of a built-in SKU cannot ship a model.

It writes:

FileWhat it holds
art/models/<item>_render.glbThe near model. Every drawn node is placed in model space and merged into one primitive per material, under one mesh node. The command leaves out LED lenses, empties and every extension the game does not load, and prints a warning for each extension it drops. It keeps KHR_texture_transform, KHR_materials_specular and KHR_materials_ior.
art/models/<item>.pips.jsonWhere the port lights, ports, fans, power inlets, module bays and nameplate sit, read from your node names.
art/models/<item>_proxy.glbThe copy of --proxy.

Naming the nodes

The sidecar comes from the node names alone, so name them the way the base models are named. The command ignores a trailing suffix such as .001.

Node nameRead as
Port_1, Port_2, …A cabled data port, numbered from 1. Its position is the cable anchor.
Port_<name>, such as Port_BMCA named port. Port_Console, Port_USB_Mgmt, Port_Mgmt and Port_iDRAC are management pins and carry no lights: the command drops an LED_Link or LED_Activity light nearest to one, a light nearest to Port_0, and every such light in a source with no Port_ node at all, and prints a warning: line naming each light it drops. The game lights a named port’s light from the device port whose label is the name after Port_, so Port_BMC follows a port labelled BMC. Port_MlagKeepalive follows the keepalive port. A name no device port carries stays dark. Port_PSU, Port_Mgmt and Port_Out are refused, because their labels repeat on one device and the game would bind the light to the first match: the command drops a light nearest to one with a warning: line, and .pips.json validation refuses it.
A mesh named LED_Link... or LED_Activity...The link or activity light of the port it sits under (a Port_<N> or PortSlot_<N> parent), or else of the nearest port.
A mesh with LED and PSU in its name or materialA PSU light. The command numbers PSU lights from the rightmost, as it numbers the inlets.
A mesh with LED and power or _PWR in its name, or _PWR in its materialThe power light.
A mesh StatusLED_SYS, StatusLED_ALM or StatusLED_FANThe system, alarm or fan status light. The command writes the role to the sidecar and the game paints the light by it: the alarm light shows a fault and is dark otherwise, the fan light never shows a fault, and the system light follows the device’s indicator. Any other mesh with LED in its name is a status light with no role.
A node ending _IEC or AcInlet, or _RearIEC_<n>A power inlet, one per PSU.
Bay_1, Bay_2, …A module bay frame.
FanRotor...A spinning fan blade.
A mesh Nameplate_Plate, or else a node _NameDisplayThe name placard. With neither, the sidecar has no nameplate.

Every light is a mesh with LED in its name. Its box sets the size of the light the game draws. The command also leaves out of the render model any primitive whose material has LED in its name.

The checks

After writing, the command runs the game’s pack scan and prints what it refuses in this item’s model, grouped by rule. For example:

refused [model_over_limit]:
  art/models/box_render.glb /materials the list has more than 32 entries
box: 1 model problem(s)

A clean model prints ok: the game's checks pass box's model. The counts must match the base model the row names: the same number of cabled ports, one inlet per PSU, and no bays or the base model’s bays. The model must fit the base model’s rack slot, with at most 32 materials. Equipment models lists every limit and the .pips.json fields. A default export usually needs work before it passes:

  • Join or reuse materials until there are 32 or fewer. The command merges by material, so the material count is the primitive count.
  • The game draws the lights from the sidecar, so no material needs to glow. The game refuses a material that emits light or blends its alpha.
  • A texture is a PNG inside the GLB, at most 2048 by 2048.

The command counts problems elsewhere in the pack but does not list them. Run validate for those. The exit code is 0 when the model passes, 1 when the game refuses it or a step failed, and 2 for a wrong command line.

Reference

The mod API is described by a set of generated files. This page says what each one is and when to read it. The game’s build generates all of them, and none is edited by hand. They are in ModSDK/schemas/.

Ledgers

A ledger records every field of one part of the mod API, with a type and whether it is required. Each line has a name, a type, required or optional, and @ with the minor the field first appeared in. A minor that broke a part of the API adds a breaking line for it. The first lines of each file say which way the data flows and which generation and minor it describes.

FileWhat it isWho writes, who reads
manifest.v1alpha1.fieldsEvery manifest.json field, with its type, its accepted size and the words an enumerated field takes.A mod writes, the host reads.
commands.v1alpha1.fieldsEvery command a mod may issue, with every field, and the ModToast a step may return.A mod writes, the host reads.
events.v1alpha1.fieldsThe event batch envelope and the category that carries each engine event.The host writes, a mod reads.
snapshot.v1alpha1.fieldsEvery field of every snapshot section.The host writes, a mod reads.
content.v1alpha1.fieldsThe hardware rows, the panel file (its points, including hud, and the key of a panel or button) and the config schema file, with the range each number accepts.A pack writes, the host reads.
scenario.v1alpha1.fieldsEvery field a scenario pack’s RON file may set.A pack writes, the host reads.

The direction decides what counts as a break. When the host reads, removing a field or adding a required one breaks every mod that was valid, and a new optional field does not. When a mod reads, a new field does not break a mod that ignores unknown fields.

A command field that names a workload or a part takes an id from the snapshot. The migrate commands’ unit_id comes from the workloads section, and the part commands’ component_id from the parts section. A mod reads either only when its manifest’s reads names it. See Script mods.

Schemas

FileWhat it is
snapshot.v1alpha1.schema.jsonA JSON Schema for the whole mod snapshot. The Lua type stubs and the Rust snapshot types are generated from it.
content.v1alpha1.schema.jsonA JSON Schema for the hardware pack files. Point your editor at it to get completion and the bounds on every number.

Leaves

The ledgers do not cover a pack model’s .pips.json sidecar. Its fields, limits and rule codes are in Equipment models. uptime-mod validate and uptime-mod model check it with the game’s own code.

scenario.v1alpha1.leaves lists every value a scenario RON file may set, traced from the scenario types. The scenario ledger is built from it. Read it when you need the exact name and type of a nested scenario field.

How versions work

Every part of the mod API shares one generation, v1alpha1, and one minor. The header of each ledger says generation v1alpha1, minor 1, shipped true. Minors 0 and 1 have shipped: their frozen copies are <surface>.v1alpha1.0.fields and <surface>.v1alpha1.1.fields. The next change to any ledger opens minor 2.

When a minor ships, each ledger gets a frozen copy and a digest. After that, any change to any file moves the minor. The game then checks each mod’s api_minor:

  • The game refuses by name a mod newer than the game.
  • The game refuses by name a mod older than the latest break on data it uses. A break in one snapshot section reaches only the script mods whose reads name that section. A break on one scenario field reaches only the packs whose scenario files set it. Any other break reaches every mod written against that part of the API.
  • A mod built against an older minor, with no break on data it uses, still loads.

Minor 1 breaks two things. In active_incidents, articles became article_ids. In scenario files, pricing.load_balancer.per_million_requests_microcents and tuning.eviction_refund_cap_months are gone. A mod built against minor 0 that reads active_incidents, or sets either field, must be rebuilt against minor 1. Every other minor-0 mod still loads.

This SDK reads minor 1. Set api_minor in your manifest to the minor of the SDK you built against. In the Rust SDK that is uptime_mod::snapshot::API_MINOR. See Content packs for the load rule.

While the generation is v1alpha1, a minor may contain a break. The loader then applies the rules in the list above.

Other pages

Sharing a mod on the Workshop

The Steam Workshop is where players find your mod. One pack is one Workshop item. This page covers uploading, who can see the item, updating it, and what a player sees after subscribing. The Workshop is not part of the demo.

Upload

uptime-mod upload packages your pack the way package does, checks the result again, and publishes it.

uptime-mod upload <dir> [--visibility private|friends|public]
                  [--change-note <text>] [--preview <image>] [--dry-run]
  • The upload needs Steam running and signed in to an account that owns Uptime. The uptime-mod in the game’s ModSDK/bin folder can reach Steam. A copy built without Steam stops with a message and still runs --dry-run.
  • --dry-run prints the plan and sends nothing. It also writes nothing into your folder. Run it first. The quickstart does.
  • --change-note is the text Steam shows for this version. It may hold line breaks and is at most 8000 bytes.
  • --preview is the item’s picture. It must be a JPG, PNG or GIF image of at most 1 MiB. Steam keeps the picture between uploads, so pass --preview again only to change it. An item uploaded without one has no picture until you add one.
  • The exit code is 0 for a finished upload or dry run, 1 when the pack or Steam refuses it, and 2 when the command line is wrong. Nothing is sent when the pack has problems.

Steam sets the item’s title and description on the first upload only. The title is your pack id and the description is the id and version. Edit them on the item’s Workshop page. Later uploads leave them alone. Add more screenshots or a video on the item’s page too; the upload sets only the main picture.

The item carries tags that come from your manifest: its kinds (for example content or script), its content categories, and lua or rust for a script mod. It also carries the key-value tags mod_id and version, so an item can be found by its pack id.

Visibility

A new item is private. Only you can see it. Pass --visibility friends or --visibility public to change that. An upload without --visibility keeps the item’s current setting, so a public item stays public when you push an update.

Steam keeps a new item hidden from everyone but you until you accept the Workshop legal agreement. When Steam says you have not, the upload still finishes and prints a warning with the link. Accept the agreement once for your account, and your items become visible at the setting you chose.

Rules for shared mods

Anyone can share a mod on the Workshop. These rules apply to every mod you upload, on top of Steam’s Subscriber Agreement and the Workshop legal agreement.

  • Share only what you have the right to share. Your mod must be your own work, or work its owner lets you share. Don’t include art, models, music or code from other games or sites without permission. The Uptime art in the SDK examples may go into Uptime mods, as the SDK’s Terms say.
  • Your mod stays yours. By uploading it you let RubyRackGames load and run it for every player who subscribes. You also let us show its name, picture and description, with your name, when we talk about Uptime mods.
  • Keep it safe. A script mod runs in a sandbox. Don’t try to get out of it, read or send a player’s data, or slow down or crash the game on purpose.
  • Keep it decent. No illegal content, hate, harassment, sexual content, or other people’s private details.
  • Don’t pass a mod off as official. Don’t use the Uptime or RubyRackGames name or logo in a way that makes a mod look like ours. “A mod for Uptime” is fine.
  • Mods are made by players. RubyRackGames doesn’t check or vouch for them and gives no warranty for them. Each player turns a mod on themselves.

To report a mod, use the Report button on its Workshop page. If a mod breaks these rules, or someone shows that it uses their work without permission, we can remove it from the Uptime Workshop. We tell the author why when we can.

We may change these rules. The version on this page is the one that applies.

Updates

The first upload makes the item and writes its id to workshop.json in your pack folder:

{
  "item_id": 3456789012,
  "published": true
}

Every later upload from that folder updates the same item. The id stays stable as long as workshop.json stays. Keep the file with your source and commit it. Do not edit it, and do not copy it to another folder you upload from: two folders with the same id update one item. The command refuses a file that is not a nonzero item_id. If you delete the file, the next upload makes a new item and the old one stays on the Workshop.

workshop.json never goes into the package. The upload does record the item id in the package’s uptime-package.json. That is how a player who is refused at a multiplayer join gets a Subscribe button for your pack. See Multiplayer.

Raise version in your manifest for each release. Players and the game tell two copies of a pack apart by its version and its hash.

What a player sees

A player subscribes on the item’s Workshop page. Steam downloads the item. The game then lists it in the Mods panel with a Workshop badge, next to the packs from the local mods folder. A pack from the Workshop goes through the same checks as a local pack, so a pack that validate accepts loads, and one it refuses does not.

A new item is listed off. Subscribing never turns a pack on. The player turns it on in the Mods panel, which is also where a pack that changes gameplay shows as one.

The Status column of the panel tells the player where the item stands:

StatusMeaning
OKThe pack is installed, current and usable.
Needs updateSteam has a newer version it has not fetched yet. The old copy stays usable until Steam swaps it.
DownloadingSteam is fetching the item.
Not downloadedThe player is subscribed and Steam has not started the download.
No pack foundThe item is installed, but its folder holds no pack the game can read.
ShadowedThe player has a local pack with the same id. The local copy is used while it can load, and the Workshop copy cannot be turned on. When the local copy cannot load and the Workshop copy can, the Workshop copy is used instead, and the local row shows Shadowed with its own problems.
Can’t loadThe pack is well formed but this game version cannot load it.
InvalidThe pack has problems. Select the row to read them.

Selecting a Workshop row shows an Open in Workshop button that opens the item’s page.

No pack found usually means the item was uploaded from a folder that was not a packaged pack. Upload with uptime-mod upload, which packages for you.

A dedicated server

A dedicated server needs no Workshop code. The operator downloads the item with steamcmd and points the runner at the folder it lands in. The steamcmd steps below are the operator’s procedure with Valve’s tool. The game does not run or test them. Only the sim-runner flags are the game’s. Steam keeps the app’s Workshop items in steamapps/workshop/content/4813880/<item id>/, and each of those folders is one pack, which is the layout --mods-dir reads.

An anonymous download works only if the Uptime app allows anonymous Workshop downloads, which is a Steamworks partner setting the publisher controls. Otherwise log in with an account that owns Uptime: replace +login anonymous with +login <account>.

steamcmd +login anonymous +workshop_download_item 4813880 3456789012 +quit
sim-runner --server --scenario garage-to-glory \
  --mods-dir <steamcmd folder>/steamapps/workshop/content/4813880 \
  --enable-mods com.example.my-mod
  • --enable-mods takes pack ids, not item ids.
  • The runner needs both flags together, and a world to build (--scenario or --world). It stops with a message when a pack is invalid.
  • The server does not update items itself. The operator runs steamcmd again to fetch a new version, then restarts the server.
  • A server that runs script mods can set their options with --mod-config. See Config.