Skip to content

Mission runtime ​

The runtime is the Lua that plays a coilbox scenario inside the engine. It is coilbox-authored and game-agnostic, and the game archive bundles a copy of it. Coilbox writes the files into a loose .sdd game folder and updates them from there.

This page is for two readers. The first half is the adoption contract, for anyone maintaining a game deciding whether to take it. The second half is the architecture, for anyone working on coilbox.

For authoring, see Scenarios.

What adopting the runtime costs ​

  • Three folders of Lua, all of it named coilbox_* so you can see at a glance what came from here.
  • One guard in whatever your game calls Spring.GameOver() from.
  • One guard in whatever your game starts a team with. Two, if starting a team is a sequence of pre-game phases rather than a call.
  • Nothing in a normal game. Without the coilbox_mission modoption the gadget's chunk returns false before it defines a callin or reads a file, so the gadget handler drops it. The cost of the runtime in an ordinary match is reading one file at load.
  • One unit per human-played team in a mission. The runtime places an invisible anchor so the engine cannot end the mission early. It shows in that team's unit count and nowhere else (issue #820).
  • Nothing for your own systems, unless you want them. Declaring condition and action types of your own is two files and no coilbox release, and a game that declares none loses nothing.

A game that has not adopted the runtime is not shut out of scenarios. It just cannot play them itself, and everything goes through coilbox's test mutator instead, which is a development route and never a distribution one.

The adoption contract ​

1. Bundle the runtime in your game archive ​

Take luarules/, luaui/ and missions/, and nothing else. In practice you do not copy them by hand: open Content > Games, pick your game, and use the Mission runtime section. That writes:

<yourgame>.sdd/
  luarules/
    gadgets/coilbox_mission_runtime.lua
    mission_runtime/coilbox_*.lua
  luaui/
    widgets/coilbox_mission_ui.lua
    mission_ui/coilbox_panel_model.lua
  missions/
    runtime.lua

The button reads Install the mission runtime, Update the mission runtime, Reinstall the mission runtime or Replace with coilbox's mission runtime, depending on what it found. Underneath it, coilbox says which version your game bundles, which version it ships, and which condition and action types each supports.

Only .sdd games can be installed into. A packaged .sd7 or .sdz cannot be written into at all, so coilbox offers the test mutator there and says why. Build the runtime into your own source and the question does not arise, which is what a game shipping its own missions does.

An update removes stale runtime files, and only those. A file is coilbox's to remove if it sits under luarules/mission_runtime/ or its name starts with coilbox_, and the new runtime did not just write it. Your own gadgets and any compiled missions/<id>/mission.lua are never touched. The path comparison is case-insensitive, so a game that spells the folder LuaRules/Gadgets/ survives.

On Linux a game can end up with two spellings of one of these folders: the LuaRules/ your game ships and a luarules/ an older coilbox wrote beside it. The engine reads a path case-insensitively, so those are one folder to it and it loads one copy of any file that is in both. Coilbox offers to merge them in the same section: it previews the runtime files under the folder it no longer writes to, and only removes them on an explicit confirm, then installs again into your own. Nothing of yours is listed or removed.

Coilbox never reports what it meant to write. After installing it evaluates missions/runtime.lua back out of the folder, through the same sandboxed Spring Lua reader the rest of the app uses, and shows you what that file actually says.

The tests under lua/mission-runtime/tests/ are deliberately outside the three folders you bundle, and are never installed.

Licence ​

The runtime is offered under the MIT licence or the GNU General Public License version 2 or any later version, at your choice. Each runtime file names this in its license field. The notice and the GPL text are in lua/LICENSE.md and lua/COPYING in the coilbox repository. When coilbox installs the runtime into your game, it also writes a coilbox-licence/ folder at the game's root. The folder holds a notice worded for a game (NOTICE.md), the MIT text (LICENSE-MIT) and the GPL text (COPYING). It sits apart from any LICENSE or COPYING of your own, and the notice says it covers only the files coilbox wrote. Each install replaces the folder, so keep your own notes outside it. Leave it in your archive when you distribute the game. The coilbox app carries the same three files in a coilbox-licence folder beside the runtime it ships, and the blueprint widget install writes them to LuaUI/coilbox-licence/ in a content folder.

Missions your game ships ​

missions/ holds the runtime's own runtime.lua, and one folder per mission your game ships:

missions/
  runtime.lua
  first-contact/
    mission.lua
    scenario.json
    kesh.png
    kesh-briefing.ogg

mission.lua is the compiled mission, which is what the runtime loads. scenario.json is the document it was compiled from, and it is what makes the mission editable rather than only playable. The dialogue portraits and clips sit beside them, because a compiled mission names them as bare filenames and the engine resolves those next to mission.lua.

A game that ships missions this way plays them itself whatever its archive format, since there is nothing for coilbox to write. That is the one route open to a packaged .sd7 or .sdz. Coilbox writes these folders for you when an author moves a mission into the game, so hand-building one is only for a game that generates its missions some other way.

The folder name is what the coilbox_mission mod option carries, so it is the mission's identity as far as a launch is concerned. Do not name one after a UUID: coilbox reads a UUID folder as a compiled mission it wrote itself while testing, and offers to clear it out.

2. Guard your game over ​

The runtime decides when a mission ends. Add one guard to whatever calls Spring.GameOver() in your game, so your own end conditions do not end a mission that has not finished:

lua
if not (GG.CoilboxMission and GG.CoilboxMission.id) then
    Spring.GameOver(winners)
end

GG.CoilboxMission exists only when the runtime loaded a mission, which only happens when the coilbox_mission modoption is set. In a normal game the guard is one nil check.

Read it at the moment you would end the game rather than caching it at load. The runtime's gadget sits at layer = 1000, so it initialises after yours and the table is not there yet while your own Initialize runs.

This has now been through a real game's end conditions, on Splinter Faction (issue #772). It is one line in one file, because Splinter Faction aliases the engine call once and both of its endings go through the alias. LuaRules/Gadgets/game_end.lua line 69 reads:

lua
local GameOver = Spring.GameOver

and becomes:

lua
local spGameOver = Spring.GameOver
local function GameOver(winners)
    if GG.CoilboxMission then return end
    spGameOver(winners)
end

That covers both call sites, the last ally team standing at line 322 and the everyone-left case at line 465, and nothing else in the game calls Spring.GameOver at all. This guard and the two below are not in Splinter Faction upstream. They live here as scripts/sf-proof/splinterfaction-guards.patch, which is what the adoption proof applies and what a maintainer would be sent. The snippets on this page are the shape of each change. The patch is the change itself, comments and all. Look for the alias before you go patching call sites: a game that reads Spring.GameOver into a local is the common shape, and the local is the cheaper place to guard.

Without it a mission ends when your game says so. In the proof the player wiped the enemy out 700 frames before the mission's own timer, and Splinter Faction's game_end declared the win: Spring.IsGameOver()=true coilbox_mission_over=0. With the guard the same run reads Spring.IsGameOver()=false and the mission ends itself.

The engine also ends a game itself when an ally team has nothing left, and that is not something you can guard. The runtime handles it with the anchor unit instead, and that half holds whether or not you have added your guard, because being demoted to spectator mid-mission is the damage even when nobody declares a winner.

The anchor only holds if your game counts it. If your own emptiness check discounts unit defs, the anchor has to be a def it counts. Splinter Faction's game_end.lua discounts anything carrying customParams.decoration, and no unit in it does today. The runtime cannot read that rule, so it watches for the result instead: a mission team your game kills while a mission is still running is logged as an error naming the def the team was anchored with (issue #933). If you see one, the fix is on your side.

3. Do not start a team the mission has already started ​

A scenario says which of its teams it places the opening units for. Ask before your own start gadget spawns, and spawn nothing when the answer is true:

lua
if not (GG.CoilboxMission and GG.CoilboxMission.suppressesStart(teamID)) then
    SpawnStartUnit(teamID)
end

Called where you would spawn, once per team. The answer is the scenario's, so it holds for the whole mission rather than for a window, and you get the same answer whatever frame you ask on.

The runtime cannot do this for you. The engine offers no veto on Spring.CreateUnit: AllowUnitCreation is consulted for builders and factories only, never for the call a start gadget makes. So all the runtime can do by itself is destroy what your game just made, and destroying a real commander is worse than never spawning one. Splinter Faction's LuaRules/Gadgets/game_team_com_ends.lua line 66 answers an ally team's last commander dying with Spring.KillTeam(teamID), which takes the player's own units and their seat in the game with it (issue #884).

It removes an unasked-for start anyway, from load until the end of game frame 1, so a game that has not added the guard and spawns at frame 0 still works. Treat that as a fallback and nothing more. It is ahead of the commander bookkeeping only because it is that early, and no frame number is reliably ahead of a rule the runtime cannot see.

If your start is a sequence of phases ​

A game that picks a faction, or asks the player where to land, before it spawns has a second guard to add. A mission chose both before the game loaded, so those phases have nothing left to ask, and running them anyway leaves a picker sitting over the first minute of a mission that is already playing (issue #888).

Skip the sequence outright:

lua
if GG.CoilboxMission and GG.CoilboxMission.suppressesEveryStart() then
    -- go straight to whatever "the pre-game is over" is in your game
end

suppressesEveryStart() is the same question asked about the game rather than about one team: true when the mission owns the start of every non-Gaia team in it. A pre-game phase is global, so it is the whole game that decides whether one is worth running. A game with a team the mission says nothing about gets false, because that team still has a start to decide.

This is yours to skip rather than the runtime's to stop. A phase machine is your gadget's own state and your own rules params, and the runtime can reach neither. What it can do is answer the question, and GG.CoilboxMission is on the table from the moment the runtime initialises, which is before any gadget's GameStart.

On Splinter Faction the sequence is a faction-choice phase with a 900-frame deadline and then a start-spot placement phase with another, so its start units land on frame 1800. LuaRules/Gadgets/game_spawn.lua takes both guards: the spawn one at the top of SpawnStartUnit, and the phase one at the top of gadget:GameStart, after the teams are collected and before it loads the map's start spots.

lua
if GG.CoilboxMission and GG.CoilboxMission.suppressesEveryStart() then
    phase = "done"
    Spring.SetGameRulesParam("phase", "done")
    return
end

phase is that game's own rules param, and both its pickers read it: each removes itself as soon as the phase is not its own. Writing done is also how the rest of the game learns the match is running, so its research ledger and its survival AI start at frame 0 instead of at 1800.

scripts/mission-sf-proof.sh reads that param back out. With both guards the phase is done at frame 2 and the game never loaded a start spot. Without the second one it reads faction at frame 2 and placement at frame 1000.

4. Optional: declare your own condition and action types ​

Your game has systems coilbox has never heard of. Splinter Faction has research points, a weather model and a faction chooser, and none of them mean anything to a generic runtime. Declare trigger types for them and a mission author can use them the way they use unit_dead, with a form for the parameters and no coilbox release in between (issue #776).

Two files, both yours:

<yourgame>.sdd/
  missions/extensions.lua                    what the types are called and what they take
  luarules/mission_extensions/<yours>.lua     what they do

Neither is coilbox's to overwrite. Keep them out of luarules/mission_runtime/ and do not name them coilbox_*, because those are the two things a runtime update deletes when it stops shipping a file.

The declaration ​

Data only, no globals and no engine calls, exactly like missions/runtime.lua beside it. It is read twice: by the runtime at load, and by coilbox's editor to build its palette.

lua
return {
  -- The code below, by VFS path.
  handler = "luarules/mission_extensions/research.lua",

  conditions = {
    {
      type = "sf_research_above",
      label = "Research above",
      description = "The team's research points have passed this number",
      params = {
        { name = "team", kind = "teamId" },
        { name = "amount", kind = "number" },
      },
    },
  },

  actions = {
    {
      type = "sf_grant_research",
      label = "Grant research",
      description = "Pay research points into a team's ledger",
      params = {
        { name = "team", kind = "teamId" },
        { name = "amount", kind = "number" },
      },
    },
  },
}
  • type is the name a trigger stores and the runtime dispatches on. Prefix it with something of your own, the way both of these do, so it cannot collide with a type a later coilbox adds.
  • label is what the palette calls it, and description is the line under it. Both are optional. A type with no label is read off its name, so sf_weather shows as "Sf weather".
  • params is a list rather than a table, because it is drawn in the order you write it.

A parameter's kind is one of:

kindthe field the author gets
stringa text box
numbera number box
booleana switch
stringsa list of typed values
pointa place clicked on the map
ordersthe order editor a group's own orders use
enuma dropdown over your values = { ... }
zoneId actorId groupId triggerId objectiveId dialogueId teamId varNamea dropdown over the scenario's own zones, actors, groups, triggers, objectives, dialogue lines, teams or variables

Add optional = true for a parameter an author may leave out. Your code then gets nil and applies its own default.

The id kinds are the ones worth reaching for. A zoneId parameter is picked from the zones the scenario has, so it cannot name one that does not exist, and it follows a rename. Anything a parameter of an id kind holds is checked at compile time before the mission is launched.

The handler ​

Code, so the runtime runs it in an environment that reaches the engine and GG. It returns implementations keyed by type name:

lua
return {
  conditions = {
    sf_research_above = {
      -- The events this reacts to. Leave it out and it is evaluated on the
      -- polled tick, twice a second, which is what an aggregate wants.
      -- events = { "unit_destroyed" },
      test = function(params, ctx)
        local team = ctx.teamOf(params.team)
        return team ~= nil and GG.Research.Get(team) > (tonumber(params.amount) or 0)
      end,
    },
  },

  actions = {
    sf_grant_research = function(params, ctx)
      local team = ctx.teamOf(params.team)
      if team then
        GG.Research.Add(team, tonumber(params.amount) or 0, "mission")
      end
    end,
  },
}

params is what the author filled in, verbatim. ctx is shared with the rest of the runtime and carries:

  • ctx.teamOf(name), the engine team number for a team the scenario names. Trigger parameters carry the author's team names, so a teamId parameter goes through here before the engine sees it.
  • ctx.state, which is GG.CoilboxMission: the mission, its actors and their units, its zones, vars, groups and objectives, and the handles that drive them. lua/mission-runtime/README.md documents every one.
  • ctx.engine, the trigger engine. ctx.engine:event(name, payload) raises an event of your own, which a condition of yours can then declare in its events.
  • ctx.frame, the game frame, and ctx.event when this pass came from an event.

Your globals stay yours: the handler runs in a table of its own that falls through to the gadget's environment, so it can read GG and call Spring, and anything it sets does not land in the runtime.

The boundary ​

An extension adds a game concept, never an engine one. Everything engine-level, spawns, orders, zones, sight, restrictions, game over, camera, markers and rules params, stays in the generic runtime.

That is enforced rather than asked for. A declaration naming a type missions/runtime.lua declares is refused with an error in the infolog, whichever of the two lists it is in, and the runtime's own version is what the mission runs. The editor refuses the same names and says so above the trigger list.

Two smaller rules follow from the same idea, that what runs is what an author can edit:

  • The handler may only implement types the declaration lists. One it implements and the declaration does not is reported and not registered.
  • A type the declaration lists and the handler does not implement is reported. The condition never holds and the action does nothing, which is what any unimplemented type does.

What the author sees ​

Your types are in the two pickers with the rest, labelled and described as you wrote them, and their parameters are edited by the same fields a built-in type's are. They are never greyed out on a runtime version, because you implement them: they run on whichever route the scenario takes, since coilbox's test mutator is stacked on your game and reads your declaration through it.

Coilbox reads the declaration out of a loose .sdd only, through the same sandboxed Lua the version marker goes through. A packaged .sd7 or .sdz runs its extensions fine and cannot have them read by the editor, which is the same limit that applies to the version marker.

A declaration coilbox cannot make sense of costs you only the part that is wrong. A type whose parameter names a kind this coilbox has no field for is dropped, with the reason shown above the trigger list, and the rest of the file is offered as usual.

How the game knows it is a mission ​

One modoption, written by coilbox into the start script it already generates:

coilbox_mission = <scenario id>

The gadget reads Spring.GetModOptions().coilbox_mission, trims it, and refuses anything that is not a plain name, because the id becomes part of a VFS path. With a valid id it includes missions/runtime.lua and missions/<id>/mission.lua, refuses a mission needing a newer runtime than it is, and publishes both on GG.CoilboxMission for the rest of the runtime and for your own Lua.

A second modoption says how hard to play it:

coilbox_difficulty = easy | normal | hard

Coilbox writes it only for a scenario that varies by difficulty. Absent, or a word this runtime cannot rank, means normal. It is not in the compiled mission, because one scenario is meant to be played at every difficulty and a mission your game ships in a packaged archive cannot be rewritten per launch.

GG.CoilboxMission.difficulty is the level, and GG.CoilboxMission.difficultyGate(range) answers whether a { atLeast, atMost } range applies to this run. Use the gate rather than reading the modoption yourself, so your own Lua and the runtime agree about what "hard and up" means.

mission on that table is the compiled scenario exactly as coilbox emitted it, so a misbehaving mission can be diagnosed by reading missions/<id>/mission.lua beside the scenario in coilbox.

The gadget sits at layer = 1000, behind your own gadgets, because it is overriding the game rather than pre-empting it. It wants the last word on starting resources.

What the runtime takes over ​

Enough to be worth knowing before you adopt it:

  • The start. Every actor, base building, per-team start unit and non-dormant group is created at game start. A building is put through Spring.Pos2BuildPos on the way, so it sits on the build grid and can be rebuilt where it stood.

  • Starting resources, for every mission team. At game frame 1 each team the scenario declares has its bank set to the scenario's number, defaulting to nothing. That is how the game's usual opening bank is suppressed. Free income, if the scenario asks for it, is paid every frame, and a mission may move a bank, change what a team is paid or change how much it can hold at any point with give_resources, set_income and give_storage.

  • Anything a mission asks your own Lua for. call_lua names a function by dotted path, walked from the gadget's environment, so GG.YourGame.DoThing reaches the function your gadget put there. It is called with the trigger's parameters and the runtime's shared context, and a name your game has no function for is reported rather than fatal.

  • Commanders, only where asked. A team whose scenario entry sets noCommander is a team the runtime starts and your game does not, which is contract item 3. Teams without the flag keep the commander your game gave them.

    Add the guard and nothing else happens. Skip it and the runtime falls back to removing what your game spawned, from load until the end of game frame 1, touching only creations with no builder. That window is no use to a game that spawns later than frame 1: Splinter Faction spawns on frame 1800, and its commanders arrived despite noCommander until it took the guard (issue #884).

  • Game over, through your guard and the anchor unit.

  • AllowUnitCreation and AllowCommand, only when a mission restricts something. Both callins are hot, and a mission that restricts nothing defines neither. A build order the mission forbids is dropped rather than kept, so a factory queue moves on to the order behind it and a builder does not stand at the site retrying. A command the mission withholds never reaches the unit, and the orders the runtime gives its own groups are not held to it.

  • The build icons for defs a mission forbids, greyed out. A mission with a buildable restriction sets disabled on those command descriptions, on every builder as it arrives on a team and on every unit of a team an unlock_unit frees a def for. The icons are edited in place, never removed, so your build menu keeps its order.

    If your game greys build icons of its own, say a tech tree or a supply limit, the two do not arbitrate and the last writer holds the icon. The runtime never un-greys an icon it did not grey itself, so a lock of yours survives a mission finishing with the def beside it. Going the other way, a repaint of yours after the runtime's would leave a forbidden def looking available, so a mission with a buildable restriction puts its own greys back every 15 frames. The runtime is at layer 1000, behind your gadgets, so it holds the icon on any frame it repaints and the wrong-looking half second in between is the whole of the cost. AllowUnitCreation is what actually holds either way, and the icon is only the sign in front of it.

    Splinter Faction is where this was measured. Its game_sticky_tech_progression.lua rewrites disabled on every tech-gated icon whenever a team's tech changes, deciding each from its tech alone, and scripts/mission-sf-proof.sh grants a tech mid-mission and reads the icons back.

Everything else your game does carries on. The runtime does not touch your economy, your unit definitions or your own gadgets.

The three artefacts ​

For coilbox contributors. The runtime is one of three pieces that have to stay in step.

  1. The runtime lives in this repo under lua/mission-runtime/ and ships as a Tauri resource. lua/mission-runtime/README.md is its own reference and documents every module in detail.
  2. The install path is Content > Games, above. Coilbox writes the files in, reads the version marker back, and shows what the installed runtime supports.
  3. The test mutator is a generated game, coilbox-mission-test.sdd, in the content root's games/ folder. It carries a generated modinfo.lua depending on the base game, the runtime, and the one scenario under test. Everything else comes from the base game. The folder name is a compile-time constant in the Rust that writes it, so that code can only ever write to coilbox's own game, and deleting the folder undoes everything it ever did. It follows the same pattern as the lego builder's scratch game.

Compile and validate ​

A scenario is JSON in coilbox's app data (scenario/scenarios/<id>.json, with dialogue media under scenario/media/<id>/). Playing or testing one compiles it to a Lua table literal at missions/<id>/mission.lua, so the runtime needs one VFS.Include and no parser.

The emitted file is a single return { ... } under a two-line header. It is deterministic: array order is document order, every author-keyed table is emitted in sorted key order, Lua keywords are bracketed so a trigger's repeat becomes ["repeat"], and a non-finite number throws rather than emitting. Two things are added at compile time that the document cannot carry: each participant's engine team number, and the game and map names.

The compile step doubles as the validator. After writing, coilbox reads the file back out of the game folder with coilbox-springlua, which evaluates loose Spring Lua against a rooted VFS, and asserts that every id reference resolves before the engine sees it: every actor, group and base names a team that has an engine team number, every order target names an actor, a named base building or a group, and every trigger parameter of an id kind resolves against its registry. An unknown step type is skipped, because it belongs to a game extension.

The validator is the same code path the engine will take, not a second implementation that can disagree with it. A scenario that does not validate is not launched, and the reasons are shown in editor terms rather than as compiled paths.

Dialogue portraits and clips are copied into missions/<id>/ beside the compiled file, on both routes, so the engine finds them in the same place either way.

Version negotiation ​

missions/runtime.lua is data with no globals and no engine calls, so it evaluates identically in the engine and in coilbox's sandboxed reader. It declares:

lua
return {
  version = 3,
  schemaVersion = 1,
  conditions = { ... },
  actions = { ... },
}

Coilbox reads two of these: the one in the game (installed) and the one this build ships (available). Every type is then in one of three states. supported means the installed runtime declares it. added means coilbox's does and the installed one does not, so installing or updating brings it. extra means the installed runtime declares it and coilbox does not, so the game is ahead of this build.

A scenario records runtimeVersion, the lowest runtime that can play it, computed from the trigger types it uses and from the format features it uses. It is recomputed on every save, by the one function every write goes through, so a stored document always names the runtime it actually needs. Every launch-set type is version 1. Naming a base building needs 2. Version 3 is four format changes released together: a var read where a number goes, a team on camera_pan and map_marker, an uncontested zone_held_for, and the release_group action. Version 6 is the difficulty ranges, asked for only by a scenario that carries one. Version 7 is five actions and one format change: call_lua, build_unit, give_resources, set_income, give_storage, and a condition that carries negate = true. Version 8 is two conditions that read what a player did: unit_selected and command_given. Version 9 is what lets a lesson stop and wait: the pause_game and unpause_game actions, the dialogue_dismissed condition, and a dialogue action that carries hold = true.

Three rules keep this honest, and all three matter to anyone adding a type:

  • Adding a condition or action means adding it to missions/runtime.lua and bumping version in the same change. So does a format feature an older runtime would read past, because it would play the mission with a piece of it missing.
  • A format feature that needs a newer runtime raises the floor in requiredRuntimeVersion, on the scenarios that actually use it. Version 2 is not asked for by every scenario with a base, only by one that names a building, and version 3 only by one that uses one of the four things it added.
  • A type that has shipped is never removed. A scenario asking for it would then silently do nothing, which is the failure the whole capability table exists to prevent.

Features that land in one release share one bump. Four separate bumps would give a game four floors to chase and coilbox four gates to reason about, for work that arrives at a player's machine all at once.

The editor greys types the target runtime cannot run, and a game bundling a runtime older than a scenario needs is treated as a game with no runtime at all, so it falls to the mutator rather than playing a mission with silently dead triggers.

How the runtime is tested ​

Two suites, and they answer different questions.

sh
scripts/mission-tests.sh

Every tests/*_test.lua file, each in its own luajit, against a stub of the slice of the engine the runtime touches. This is what CI runs, and it proves the runtime's own logic. mission_trigger_test.lua runs the fixtures in src/scenario/fixtures/missions/, which are files coilbox's own compiler emitted, so the runtime is proved against the shape it will actually get.

sh
scripts/mission-headless.sh

A real engine. It builds a scratch game out of the runtime plus the compiled fixtures and plays each one in spring-headless, which simulates with no OpenGL context. A probe gadget stands in for the player: it walks a unit into a zone, kills an actor, hands one over, tells a factory and a builder to make something the mission forbids, waits for the mission to unlock one of those, reads the build menus back off both, and checks what the runtime did about it. Nothing in CI runs this, because a runner has no engine, no game and no map. The script's header lists the environment variables that point it at them.

A build menu is read through Spring.GetUnitCmdDescs, which is the list the engine draws one from, so what a run settles is that the engine no longer offers the command on that unit. Nothing here can settle what a player sees. The machine has no OpenGL context, and a game that replaces the engine's build menu with its own LuaUI is free to draw a disabled icon however it likes.

The probe's unsynced half checks the widget, because the engine's Script.LuaUI is unsynced and a widget's registered global is the only thing about it a gadget can see. That needs the game's LuaUI to load, and a game's entry point includes LuaUI/*.lua files a current engine no longer ships, so the script links the data directory's own LuaUI/ into the run. Without one it says so and skips the widget checks.

The probe also gives an order as the player rather than as the runtime, which is the only way a withheld command can be proved: everything synced Lua gives is fromLua, and the runtime lets all of that through. The engine lets a Lua handle put an order on the wire only when the local player controls that handle's team, and a gadget's is every team at once, so the harness turns god mode on to make the local player that controller. It changes who may order what and nothing about how an order is carried or judged.

sh
scripts/mission-clients.sh

Three real clients in one game, which is the only way to watch a client drop something. camera_pan and map_marker take a team, and every client is handed the same message: which of them acts on it is decided against Spring.GetMyTeamID(), so a one-client run can only read what arrived. This plays one ambush across three spring-headless processes, a host on the team the fixture names, a second player on the other team, and a spectator watching as the first, and each makes its own claim in its own log.

What it counts is the engine calls the runtime's unsynced half made, because nothing else can be read back: a local map marker has no Lua getter, and the camera in a headless client drifts on its own. The probe replaces Spring.SetCameraTarget and Spring.MarkerAddPoint with wrappers that count and pass through, which it can do because it loads behind the runtime and both look the call up in the same table.

The spectator is the case the format documents rather than decides: a spectator gets whatever the engine currently has them watching as, and this run has them on the team the mission named, so they see the camera move.

sh
scripts/mission-sf-proof.sh

The adoption proof, and it runs the other way round from the two above. Both of those play a runtime the harness itself laid down, which settles the runtime's behaviour and says nothing about adoption. This one plays the runtime out of a real game: the scratch mutator carries only a probe, and it needs a loose Splinter Faction. It copies src/scenario/fixtures/missions/splinter/mission.lua to missions/splinter/mission.lua in that game, which is where coilbox's launch path puts a compiled mission, and removes it again unless you pass --keep-mission.

It installs the runtime into that game on the way in, through scripts/mission-runtime-install.sh. A game keeps the runtime it was last given, so a proof that trusted what was there measured a runtime that had drifted behind main and stayed green for everything the fixtures happen not to use (issue #934). The install is not a shell copy of the real one: the script runs the scenario plugin's own install through a cargo example, so a harness run writes what the Install the mission runtime button writes. The other two Splinter Faction scripts install the same way, for the same reason.

sh
scripts/mission-sf-extension.sh

The same game again, asking whether a game's own trigger types run. It plays a mission whose action pays research points into Splinter Faction's ledger and whose condition waits on the balance, and the number it quotes is that game's own researchPoints rules param, which nothing in coilbox can write. The declaration and the handler are in scripts/sf-extension/ rather than in the game, because they are the proof's rather than Splinter Faction's, and the script copies all three files in and takes them out again unless you pass --keep.

It also proves the boundary by running into it. The declaration names time_elapsed on purpose, and the mission's first trigger waits on one, so a run where an extension could redefine an engine-level type is a run where nothing happens.

sh
scripts/mission-sf-jericho.sh

The same shape again, on the same game, asking the other half of the question. The adoption proof asks whether Splinter Faction can host the runtime. This plays Silence the Jericho, the first mission authored end to end in the Scenario Builder (issue #773), and asks whether it is won: the dormant patrols spawn and wake, the trigger zones fire, the radio lines are said, and destroying the Jericho structure ends the mission with the player's ally team the winner. The mission is src/scenario/fixtures/jericho.json and its compiled Lua is in the corpus beside it. It needs the same setup as the proof below, plus AcidicQuarry 5.17 in maps/, because a scenario's zones are map coordinates.

To play the same mission in coilbox rather than headless, import it: Play the example mission is the download and the setup, and the setup is the proof's.

Running the proof yourself ​

The proof needs a game that has taken both halves of the contract. The script puts the runtime in itself. The three guards are Splinter Faction's change to make and are not upstream yet, so this repo keeps them as a patch, scripts/sf-proof/splinterfaction-guards.patch. That file is the exact text of all three, and it is what a maintainer would be sent.

  1. Clone Splinter Faction into your Spring data folder as games/SplinterFaction.sdd.
  2. Run scripts/mission-sf-proof.sh --apply-guards. It installs the runtime, applies the patch, names the three guards it added and prints the command that undoes them.
  3. You need spring-headless, cargo, and a map in maps/. The script's header lists the environment variables if yours are somewhere other than ~/.spring.

Without --apply-guards the script checks for the guards and stops, listing what is missing and the git apply line that adds it. The runtime goes in unasked and the guards do not, because the runtime is coilbox's own files in coilbox's own folders and the guards are edits to the game's gadgets. A proof that quietly rewrites someone's gadget is worse than one that refuses and tells you what to apply. Either way the script says what it wrote, and a re-run writes the same thing twice.

The patch is stored with this repo's LF line endings while Splinter Faction checks .lua out with CRLF, so both the script and the command it prints apply it with --ignore-whitespace.

The adoption proof has found what a second game costs. Every general claim held on a game the runtime had never seen: the gadget loaded out of the game's own LuaRules, read its own version marker, published the mission, placed the scenario's units, overrode the game's opening bank, kept the game's game_end out of the ending once the guard was in, and ended the mission itself with the right winner.

What did not hold was the start, twice over, and both are why contract item 3 exists. Splinter Faction spawns 1800 frames after the runtime stops watching, so its commanders arrived despite noCommander (issue #884). Then, once it was spawning nothing, its faction picker and its spot picker still ran to frame 1800 over a mission that was already playing (issue #888). Neither is fixable from inside the runtime, and no amount of running against Balanced Annihilation would have found either.

The headless run has found bugs the stub could not. gift_units moved nothing between teams that were not allied, and the runtime never noticed, because it asked for a share and threw the refusal away. The stub agreed with everything, so all fifteen suites passed on it. The fix was to ask for a capture instead, and to report a refusal (issue #857). This is the whole argument for keeping the headless harness: a stub can only ever agree with the reading of the engine's source that was written into it.

What is still unproven ​

The mission UI widget is loaded and run by a real game's own widget handler in the headless run, which is as far as a run with no screen goes (issue #850). It initialises out of the game's own luaui/widgets/, registers the global the runtime's dialogue call reaches, draws every frame of the mission without raising, and takes itself back off in a game with no mission.

What is left is what a screen would show:

  • Nobody has looked at the panels. That the drawing lands where it should, that the panels do not sit on top of the game's own UI, and that a portrait loads are still open. coilbox_panel_model.lua is what decides all of that before the drawing, and it is pure and tested.
  • A line of dialogue is proved to the seam, not past it. When the runtime hands a line to LuaUI the widget's global is there to take it. What the widget's queue then does with the line is panel_test.lua's, because a headless run has nothing to read it back with.

A font and a mouse still need a real screen, so those two will always need a person to settle.

Further reference ​

lua/mission-runtime/README.md in this repository is the runtime's own documentation, module by module: the trigger engine's arming and cascade rules, how zones are sampled, what crosses between the synced and unsynced halves, how the anchor and the reveal spotter are chosen, and every handle a game's own Lua can drive the mission through.

MIT-licensed, and the Lua that runs in the engine is MIT or GPL-2.0-or-later. Bundles pr-downloader (GPL-2.0-or-later, © the Spring/Recoil authors).