Chem O’DunReferences and Guides

Talking with MD and AI scripts

This page is also published as Talking with MD and AI scripts on the Egosoft wiki.

An extension's UI code is Lua - a file registered through ui.xml into the menus environment. Its game logic is XML: Mission Director cues and AI scripts. Sooner or later one needs something from the other, and the way across is not obvious from either side on its own.

Three channels, each of them working in both directions:

Contents

The channels

Direction You call It arrives at What it can carry
Scripts → Lua <raise_lua_event> the function you handed to RegisterEvent one string, number or component
Lua → scripts AddUITriggeredEvent <event_ui_triggered> a screen name, a control name, one extra value
Lua → any object SignalObject <event_object_signalled> the object plus up to three params, and a param may be a whole table
Scripts → any object <signal_objects> <event_object_signalled> the same
either side, no wake-up GetNPCBlackboard / SetNPCBlackboard $variables on an entity anything - tables, lists, components, even cue references

The two doorbells cannot carry structure. The mailbox carries anything but nobody notices when it changes. So the pattern almost every mod with both sides ends up on uses both at once: park the payload in the mailbox, then ring the doorbell - in that order, so the value is in place by the time the handler runs.

Every Lua function on this page belongs to the menus environment. The HUD (ui/core) has none of them, apart from AddUITriggeredEvent, which it got in 9.00.

↑ Contents

The pieces on disk

Everything below belongs to one fictional extension, example_bridge:

extensions/example_bridge/
    content.xml
    ui.xml
    ui/example_bridge.lua
    md/example_bridge.xml
    aiscripts/example.order.with.bridge.listener.xml

ui.xml is what gets the Lua file loaded into the menus environment:

<?xml version="1.0" encoding="utf-8"?>
<addon name="example_bridge" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:noNamespaceSchemaLocation="../../ui/core/addon.xsd">
  <environment type="menus">
    <file name="ui/example_bridge.lua" />
  </environment>
</addon>

The MD script and the AI script need no registration - the game picks up anything in md/ and aiscripts/.

↑ Contents

Doorbell one: scripts wake Lua

md/example_bridge.xml:

<?xml version="1.0" encoding="utf-8"?>
<mdscript name="Example_Bridge" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <cues>

    <cue name="Greet_Lua" instantiate="true">
      <conditions>
        <check_any>
          <event_game_loaded />
          <event_game_started />
        </check_any>
      </conditions>
      <actions>
        <raise_lua_event name="'ExampleBridge.Greeting'" param="player.name" />
      </actions>
    </cue>

  </cues>
</mdscript>

ui/example_bridge.lua:

local function onGreeting(_, param)
  DebugError("ExampleBridge: MD says the player is " .. tostring(param))
end

RegisterEvent("ExampleBridge.Greeting", onGreeting)

Worth knowing about this pair:

↑ Contents

Doorbell two: Lua wakes the scripts

Lua:

AddUITriggeredEvent("ExampleBridge", "panel_opened", "trade")

MD:

<cue name="On_Panel_Opened" instantiate="true">
  <conditions>
    <event_ui_triggered screen="'ExampleBridge'" control="'panel_opened'" />
  </conditions>
  <actions>
    <debug_text text="'ExampleBridge: the %s panel was opened'.[event.param3]"
      chance="100" filter="general" />
  </actions>
</cue>

The three arguments land as event.param ('ExampleBridge'), event.param2 ('panel_opened') and event.param3 ('trade'). The script side exposes param, param2 and param3, so one extra value is what fits.

Two more things about this one:

↑ Contents

The mailbox: script variables on an entity

This is the part that is usually explained backwards. GetNPCBlackboard is not a separate store the engine keeps for the UI. It is the ordinary script variables held on an entity, seen from Lua. Despite the function name nothing about it is NPC-specific - any entity carries them, and player.entity is simply the one most mods reach for. These two lines touch exactly the same thing:

<set_value name="player.entity.$ExampleBridgeConfig" exact="..." />
GetNPCBlackboard(playerId, "$ExampleBridgeConfig")

A full pair. MD writes a config table and rings:

<cue name="Write_Config" instantiate="true">
  <conditions>
    <event_game_loaded />
  </conditions>
  <actions>
    <set_value name="player.entity.$ExampleBridgeConfig" exact="table[
      $enabled  = true,
      $maxRange = 40,
      $label    = 'Nearby ships'
    ]" />
    <raise_lua_event name="'ExampleBridge.ConfigChanged'" />
  </actions>
</cue>

Lua reads it:

local ffi = require("ffi")
local C = ffi.C

ffi.cdef [[
  typedef uint64_t UniverseID;
  UniverseID GetPlayerID(void);
]]

local playerId = 0

local function onConfigChanged()
  playerId = ConvertStringTo64Bit(tostring(C.GetPlayerID()))

  local cfg = GetNPCBlackboard(playerId, "$ExampleBridgeConfig") or {}

  local enabled  = (cfg.enabled == true) or (cfg.enabled == 1)
  local maxRange = tonumber(cfg.maxRange) or 0
  local label    = tostring(cfg.label or "")

  DebugError(string.format("ExampleBridge: enabled=%s maxRange=%s label=%s",
    tostring(enabled), tostring(maxRange), label))
end

RegisterEvent("ExampleBridge.ConfigChanged", onConfigChanged)

Three things in that pair that are easy to lose an evening to:

↑ Contents

What survives the crossing

Whichever channel carries a value, it gets re-typed on the way through. Most of that is invisible until it is not, and it is where the time goes.

Objects

Coming from a script, a component arrives as a LuaID. Everything in the UI's own game API wants a 64-bit id instead, so it gets converted on arrival - a single value from an event param:

local function onSetTarget(_, object)
  local component = ConvertStringTo64Bit(tostring(object))
  ...
end

and a whole list handed over through the blackboard, the same way:

local sectors = GetNPCBlackboard(playerId, "$ExampleBridgeDataExchange") or {}
for i = 1, #sectors do
  sectors[i] = ConvertStringTo64Bit(tostring(sectors[i]))
end

Going back, there are two routes and both of them work.

Convert in Lua, and the script side has nothing to do:

local data = { object = ConvertStringToLuaID(tostring(component)) }
AddUITriggeredEvent("ExampleBridge", "destroy_object", data)
<destroy_object object="event.param3.$object" explosion="false" />

Or send the raw 64-bit id and put the prefix on at the script level:

AddUITriggeredEvent("ExampleBridge", "reveal", { gate64a, gate64b })
<do_for_each name="$id" in="event.param3">
  <set_known object="component.{$id}" known="true" />
</do_for_each>

The first suits anything the script side is going to keep working with; the second suits the case where Lua already holds 64-bit ids and the script needs each one once.

The part that is easy to get backwards: which conversion Lua needs depends on where the value came from, not on what it looks like.

Using the string form on a LuaID out of that second group crashes rather than returning nil.

Enumerations

Enums cross as plain strings, both ways, without their prefix. faction.argon reaches Lua as "argon". Going back, Lua sends the bare string and the script side puts the prefix back with the lookup braces:

local data = {
  ship      = "ship_arg_l_destroyer_01_a_macro",
  ownerId   = "argon",
  ownerRace = "argon",
  role      = "marine",
}
AddUITriggeredEvent("ExampleBridge", "spawn_ship", data)
<set_value name="$data" exact="event.param3" />
<create_ship name="$ship" macro="$data.$ship" sector="$sector">
  <owner exact="faction.{$data.$ownerId}" overridenpc="true" />
  <pilot>
    <select race="race.{$data.$ownerRace}" tags="tag.aipilot" />
  </pilot>
</create_ship>
<create_npc_template object="$ship" role="entityrole.{$data.$role}" name="$npcTemplate" macro="$roleMacro" />

faction.{...}, race.{...}, ware.{...}, macro.{...}, entityrole.{...}, tag.{...}, class.{...} - all the same shape.

Watch the attribute you are feeding, though. <create_ship macro="$data.$ship"> takes the bare name, while <generate_loadout macro="macro.{$data.$ship}"> wants it wrapped. The schema type of that particular attribute decides, so there is no single rule to memorise - only the habit of checking.

Booleans

A script's true reaches Lua as 1, and false as 0. Since 0 is truthy in Lua, the obvious check is wrong in exactly the case that matters:

-- true even when the script said false
if cfg.enabled then

-- correct
local enabled = (cfg.enabled == true) or (cfg.enabled == 1)

The other way, a Lua true compares equal to the script side's true. Both tests together cost nothing on anything shared, and the same variable tends to get written from both sides over a mod's life:

<do_if value="@player.entity.$ExampleBridgeDebug == true or
              @player.entity.$ExampleBridgeDebug == 1">

Nothing at all

Writing nil from Lua removes the script variable outright - SetNPCBlackboard(playerId, "$ExampleBridgeRequests", nil) leaves the same state as <remove_value name="player.entity.$ExampleBridgeRequests" />, and the ? test on the script side goes back to false. That is what makes drain-and-clear work from either end.

Numbers

Numbers cross as numbers, and the script side's units come off. A position that is position.[-38186.99m, ...] on the script side arrives in Lua as plain x, y, z, and plain numbers go straight back in:

local data = { position = { x = offset.x, y = offset.y, z = offset.z } }
<position x="$data.$position.$x" y="$data.$position.$y" z="$data.$position.$z" />

↑ Contents

The full round trip

This is where both doorbells and the mailbox come together, and it is the shape most mods settle into. The menu has a "park this ship" button; the rules for what parking means live in MD, so Lua asks and waits for an answer.

Lua sends the request:

local function requestPark(shipId)
  local queue = GetNPCBlackboard(playerId, "$ExampleBridgeRequests") or {}

  queue[#queue + 1] = {
    command = "park",
    ship    = ConvertStringToLuaID(tostring(shipId)),
  }

  SetNPCBlackboard(playerId, "$ExampleBridgeRequests", queue)
  AddUITriggeredEvent("ExampleBridge", "request")
end

MD does the work and answers:

<cue name="On_Request" instantiate="true">
  <conditions>
    <event_ui_triggered screen="'ExampleBridge'" control="'request'" />
  </conditions>
  <actions>
    <create_list name="$answers" />

    <do_for_each name="$request" in="player.entity.$ExampleBridgeRequests">
      <set_value name="$ship" exact="@$request.$ship" />
      <do_if value="@$request.$command == 'park' and
                    @$ship.exists and $ship.isclass.{class.ship}">
        <create_order object="$ship" id="'Wait'" immediate="true" />
        <append_to_list name="$answers" exact="table[$ship = $ship, $result = 'ok']" />
      </do_if>
      <do_else>
        <append_to_list name="$answers" exact="table[$ship = $ship, $result = 'rejected']" />
      </do_else>
    </do_for_each>

    <remove_value name="player.entity.$ExampleBridgeRequests" />
    <set_value name="player.entity.$ExampleBridgeAnswers" exact="$answers" />
    <raise_lua_event name="'ExampleBridge.Answers'" />
  </actions>
</cue>

Lua picks the answers up:

local function onAnswers()
  local answers = GetNPCBlackboard(playerId, "$ExampleBridgeAnswers") or {}
  SetNPCBlackboard(playerId, "$ExampleBridgeAnswers", nil)

  for _, answer in ipairs(answers) do
    DebugError(string.format("ExampleBridge: %s -> %s",
      tostring(answer.ship), tostring(answer.result)))
  end
end

RegisterEvent("ExampleBridge.Answers", onAnswers)

Four details in that shape that are easy to get wrong:

↑ Contents

Signals: reaching one object, and the AI script flying it

The doorbells are addressed to the game as a whole. SignalObject is addressed to one object, and everything running on that object hears it - including its AI script. This is how a UI talks to a specific ship rather than to the mod in general.

The object does not have to be a ship, though, and that is what makes signals usable as a general channel too. C.GetPlayerID() in Lua and player.entity in a script are the same entity, so signalling it gives a bridge that needs no target at all:

SignalObject(playerId, "ExampleBridge.SomethingHappened")
<cue name="On_Something" instantiate="true">
  <conditions>
    <event_object_signalled object="player.entity" param="'ExampleBridge.SomethingHappened'" />
  </conditions>
  <actions>
    ...
  </actions>
</cue>

That covers the same ground as AddUITriggeredEvent, with one useful difference: param2 takes a table, so a structured payload can travel with the signal instead of being parked first. The rest of this section is the aimed-at-a-ship case, where the AI script is the listener.

Lua tells one ship what to fetch:

local function sendOrders(shipId, wareName, amount)
  local ship  = ConvertStringTo64Bit(tostring(shipId))
  local pilot = ConvertIDTo64Bit(GetComponentData(ship, "pilot"))

  SetNPCBlackboard(pilot, "$exampleBridgeOrders", {
    ware   = wareName,
    amount = amount,
  })

  SignalObject(ship, "ExampleBridge.NewOrders")
end

The handler goes wherever a script is already running on that pilot - your mod's own order script is the natural place for it, in its <interrupts> block. aiscripts/example.order.with.bridge.listener.xml:

<?xml version="1.0" encoding="utf-8"?>
<aiscript name="example.order.with.bridge.listener" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:noNamespaceSchemaLocation="aiscripts.xsd" version="1">
  ...
  <interrupts>
    ...
    <handler>
      <conditions>
        <event_object_signalled object="this.assignedcontrolled"
          param="'ExampleBridge.NewOrders'" />
      </conditions>
      <actions>
        <set_value name="$orders" exact="@this.$exampleBridgeOrders" />
        <debug_text text="'ExampleBridge: %s told to fetch %s x %s'.
                          [this.assignedcontrolled.debugname, @$orders.$amount, @$orders.$ware]"
          chance="100" filter="scripts" />
        <remove_value name="this.$exampleBridgeOrders" />
        <raise_lua_event name="'ExampleBridge.OrdersTaken'" param="this.assignedcontrolled" />
      </actions>
    </handler>
    ...
  </interrupts>

  <attention min="unknown">
    <actions>
      ...
    </actions>
  </attention>
</aiscript>

And the confirmation coming back into Lua:

RegisterEvent("ExampleBridge.OrdersTaken", function(_, param)
  local ship = ConvertStringTo64Bit(tostring(param))
  DebugError("ExampleBridge: orders taken by " .. tostring(ship))
end)

The asymmetry in that pair is the part that takes longest to see:

SignalObject(ship, "ExampleBridge.NewOrders", { ware = "energycells", amount = 500 })
<event_object_signalled object="this.assignedcontrolled" param="'ExampleBridge.NewOrders'" />
...
<set_value name="$ware" exact="@event.param2.$ware" />

That is the one place where structured data crosses without the mailbox. It has no equivalent for raise_lua_event going the other way, which is why the round trip above needs the blackboard and this does not.

The script side signals objects the same way, with <signal_objects>:

<signal_objects object="$ship" param="'ExampleBridge.NewOrders'"
  param2="table[$ware = ware.energycells, $amount = 500]" delay="200ms" />

The delay attribute is there for a reason: a signal sent with no delay from inside a cue can re-trigger that same cue and recurse forever.

↑ Contents

Gotchas

The conversion traps are all in What survives the crossing. What is left over:

↑ Contents

Back to UI Modding support · Lua Globals Reference for the signatures of every function named here.