Chem O’DunReferences and Guides

Catching input actions and states

This article is also published as Catching input actions and states on the Egosoft wiki.

Every key the player presses goes through the input system first. A key is mapped to an action (one event per press: pause, open the map, deploy a satellite) or to a state (something that is on for as long as the key is held: accelerate, strafe, the container magnet). The list of actions and states is part of the game and cannot be extended by an extension. The game handles each of them itself, and nothing about that changes with an extension installed.

An extension can still have its own handler for them. The menus side of the UI has a hook for exactly this, and the game's own menus use it: the chat window opens on its action, the interact menu reads two states while it is open. The hook delivers the id of the action or state and, for a state, whether it just became active or inactive. The game's own handling runs as before; the callback is an addition to it.

This article covers what arrives in Lua, how an id is attached to the hook, and what is easy to get wrong. The examples are the container magnet, INPUT_STATE_LOOTMAGNET, and the pause action, INPUT_ACTION_PAUSE.

Contents

Actions and states

The ids are defined by the game. libraries/contexts.xml lists every action, state and range inside the contexts it belongs to; libraries/inputmap.xml holds the default key of each one. An id that is not in that list does not exist: a binding to an unknown name is refused with an error in the log.

A context is a set of inputs that is active together. INPUT_CONTEXT_BASE is on all the time and holds the pause and screenshot actions; INPUT_CONTEXT_SHIP is on while the player pilots a ship and holds the flight states, the container magnet among them. A key only resolves to an id while a context holding that id is active, which matters later: the magnet state does not exist while the player walks on a platform or sits docked.

The two kinds behave differently at the hook:

Kind Fires Arrives as
action once per press (id, true)
state on the press edge and on the release edge (id, true) then (id, false)

Where the callback arrives

The widget layer, ui/widget/lua/widget_fullscreen.lua, keeps a list of hotkey scripts. An extension registers one from its Lua file:

SetScript("onHotkey", myHandler)
-- and later, if the extension wants out:
RemoveScript("onHotkey", myHandler)

Every registered script is called with the same two arguments, id as a string and active as a boolean, for every attached action or state that fires. A script that errors does not stop the others; the error lands in the log with the id in it. Registering the same function twice logs an error and keeps the first registration.

There is no per-id registration: a handler sees every id attached through the binding below and picks what it wants by the id string. The vanilla menus do the same, each one ignoring what is not its own.

The game's own handling has already happened when the callback runs. For the pause action, the game is paused inside the callback; for the magnet state, the magnet is on by the time the true edge arrives, and the feedback text is already on its way to the message ticker.

Attaching an id to the hook

What makes the game call the hook for a given id is a binding inside a bindinggroup of an addon's ui.xml. The debug log addon's file is the smallest example in the game:

<bindinggroup id="debuglog" context="INPUT_CONTEXT_ADDON_DEBUGLOG" alwaysactive="true">
  <binding action="INPUT_ACTION_OPEN_DEBUG_LOG" />
</bindinggroup>

A binding names an action or a state. When the addon loads, each one is looked up in the game's list and attached to the dispatcher that calls the onHotkey scripts. The attachment is keyed by the id alone: once the magnet state is bound, its edges arrive whenever the state fires in the ship context, whatever context the group itself names. A group of one's own with context="INPUT_CONTEXT_BASE" and alwaysactive="true" is enough; the context and alwaysactive attributes are required by the schema, and the base context is on all the time.

Two rules come with it:

The way to get it there is to ship a complete copy of a vanilla ego_* addon's ui.xml with a group of one's own added, in a subst_ catalog, since the file replaces the original rather than patching it; a <diff> of that file is not applied. Any of the vanilla addon files will do; the smallest ones, such as the target monitor's, carry no group of their own, so the copy is the original plus the new group. How those catalogs differ from ext_ ones, and why two extensions replacing the same file cannot both take effect, is in Catalogs. The copy has to match the game version it ships for.

A new catalog is read when the game starts. Reloading the UI does not pick it up.

Example: an input watcher

A fictional extension input_watch reports how long the container magnet was held to the script side, and counts pauses on the Lua side. The split is deliberate: scripts do not run while the game is paused, so a reaction to the pause itself has to live in Lua, while the magnet is released in a running game and the script side can take it from there. Four files.

ui.xml of the extension

The extension's own addon file, registering its Lua as usual:

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

ui/addons/ego_targetmonitor/ui.xml, packed into subst_01.cat

The vanilla file with one group added. It is the whole file:

<?xml version="1.0" encoding="UTF-8"?>
<addon name="ego_targetmonitor" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../core/coreaddon.xsd">
  <environment type="menus">
    <file name="targetmonitor.lua" />
    <bindinggroup id="inputwatch" context="INPUT_CONTEXT_BASE" alwaysactive="true">
      <binding state="INPUT_STATE_LOOTMAGNET" />
      <binding action="INPUT_ACTION_PAUSE" />
    </bindinggroup>
  </environment>
</addon>

The catalog holds that one entry at that path.

ui/input_watch.lua

local ffi = require("ffi")
local C = ffi.C
ffi.cdef[[
  const char* GetMappedInputName(const char* functionkey);
]]

local inputWatch = {
  heldSince = nil,
  pauses = 0,
  lastPause = nil,
}

local function onHotkey(id, active)
  if id == "INPUT_STATE_LOOTMAGNET" then
    if active then
      inputWatch.heldSince = getElapsedTime()
    elseif inputWatch.heldSince then
      local held = getElapsedTime() - inputWatch.heldSince
      inputWatch.heldSince = nil
      AddUITriggeredEvent("InputWatch", "released", held)
    end
  elseif id == "INPUT_ACTION_PAUSE" then
    -- an action arrives once, with active == true; the game is already
    -- paused here, so this stays on the Lua side
    inputWatch.pauses = inputWatch.pauses + 1
    inputWatch.lastPause = getElapsedTime()
  end
end

local function init()
  SetScript("onHotkey", onHotkey)
  -- the key the player has mapped, for any text that names it
  inputWatch.keyName = ffi.string(C.GetMappedInputName("INPUT_STATE_LOOTMAGNET"))
end

init()

getElapsedTime() is real time, so a pause does not stretch the measured hold. The key name is read once for a display text; the $INPUT_STATE_LOOTMAGNET$ placeholder inside a text page entry resolves to the same name when the text is read, as the game's own tutorial lines do.

md/input_watch.xml

The script side receives the magnet event:

<?xml version="1.0" encoding="utf-8"?>
<mdscript name="InputWatch" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="md.xsd">
  <cues>
    <cue name="MagnetReleased" instantiate="true">
      <conditions>
        <event_ui_triggered screen="'InputWatch'" control="'released'" />
      </conditions>
      <actions>
        <debug_text text="'magnet held for %s s'.[event.param3]" filter="general" />
      </actions>
    </cue>
  </cues>
</mdscript>

With the files in place and the game started, holding the magnet key in a ship writes the held time on release, the magnet itself attracts containers as before, and each press of the pause key still pauses and is counted in Lua.

What to expect

Back to UI Modding support · Catalogs for the subst_ packaging.