Skip to content

UI Lua framework

How does X4’s UI work? Every menu, HUD widget, and on-screen indicator is implemented in Lua, running inside ui/addons/*/. This overview maps the layers: Helper API, FFI, the event bus to MD, and where the SN Mod Support APIs plug in.

For per-API details see Helper API, Globals, FFI, SN APIs.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Layer 4: Mod menus (community) β”‚
β”‚ - SN APIs (Simple Menu API, etc.) β”‚
β”‚ - Custom Lua menus β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓ uses
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Layer 3: Vanilla menus β”‚
β”‚ - ui/addons/*/menu_*.lua β”‚
β”‚ - Built on Helper API β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓ uses
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Layer 2: Helper API β”‚
β”‚ - ui/addons/ego_detailmonitor/helper.lua β”‚
β”‚ - ~200 functions for table/frame/button β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓ uses
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Layer 1: Engine bindings (Globals/FFI) β”‚
β”‚ - RegisterEvent, SetScript β”‚
β”‚ - ffi.C.GetComponentData, etc. β”‚
β”‚ - C strings, raw pointers β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each layer builds on the previous. Most mod menus use Layer 4 (SN APIs), which builds on the rest.

The engine injects globals into every Lua script’s scope:

  • RegisterEvent(name, handler) β€” subscribe to MD events
  • AddUITriggeredEvent(name, value) β€” fire MD events from Lua
  • SetScript(slot, fn) β€” register lifecycle handlers
  • CallEventScripts(name, args) β€” invoke registered scripts
  • ffi.C β€” direct C function access (LuaJIT FFI)

These are the lowest-level access points β€” engine state via FFI, MD bridge via event functions, lifecycle via SetScript.

See Globals and FFI for details.

ui/addons/ego_detailmonitor/helper.lua defines ~200 Lua functions wrapping the raw engine calls into menu-building primitives:

  • Helper.createFrameHandle(...) β€” wraps engine frame creation
  • Helper.createTable(...) β€” wraps engine table widget
  • Helper.registerMenu(menu) β€” wraps engine menu registration

Helper is uncomfortably low-level for mod authors β€” it works directly with widget IDs (FFI integers) and exposes engine quirks. But it’s stable across versions.

See Helper API for the reference.

Each vanilla menu is a Lua file in ui/addons/*/menu_*.lua (e.g. menu_map.lua, menu_object.lua, menu_options.lua). Each menu:

  1. Registers itself via Helper.registerMenu(menu)
  2. Defines onShowMenu to build the layout
  3. Defines onUpdate for per-frame ticks
  4. Defines cleanup to free resources

Vanilla menus are the reference implementation of β€œhow to build a complete menu with Helper”. Modders adding custom menus typically read vanilla code for patterns.

User opens menu
↓
Engine calls onShowMenu(menu)
↓
Menu builds frames + tables + widgets
↓
Engine calls onUpdate(dt) per frame
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User interacts (click, scroll) β”‚
β”‚ - Engine fires UI events β”‚
β”‚ - Menu callbacks run β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User closes menu β”‚
β”‚ - Engine calls cleanup() β”‚
β”‚ - Menu frees widget scripts β”‚
β”‚ and references β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The lifecycle is strict β€” failing to call Helper.removeAllWidgetScripts(menu) in cleanup leaks callbacks across menu invocations.

Mods typically use the SN Mod Support APIs instead of raw Helper:

  • Simple Menu API (SMA) β€” describe menus from MD, no Lua needed
  • Simple Menu Options β€” settings menus
  • Interact Menu API β€” right-click context menu
  • Hotkey API β€” custom keybindings

SN APIs hide the Helper / FFI complexity. Most mods don’t touch Layer 2 directly anymore.

A separate concern from menu building: how does MD signal to Lua menus that data changed?

MD side:
<raise_lua_event
name="'mlog_my_event'"
param="$data"/>
↓
Engine routes to Lua side
↓
Lua handler (registered via RegisterEvent):
function onMyEvent(_, data)
-- update menu state
end
↓
Lua side:
AddUITriggeredEvent(
"mlog_my_event_handled",
"ok")
↓
Engine routes back to MD
↓
MD cue:
<event_ui_triggered
screen="'mlog_my_event_handled'"
control="'ok'"/>

The bus is string-based β€” typos silently fail. Convention is mlog_ prefix on event names.

See Globals for the full pattern.

onShowMenu β†’ AddUITriggeredEvent("get state")
↓
MD cue β†’ raise_lua_event("state data", $state)
↓
Lua handler updates menu

Bi-directional: Lua asks β†’ MD provides.

MD detects change β†’ raise_lua_event("update")
↓
Lua handler refreshes display

One-way: MD pushes when something changes.

onShowMenu β†’ read from Userdata API β†’ display
↓
onUserChange β†’ write to Userdata API

The Userdata API (one of the SN APIs) provides per-save persistent storage outside MD vars.

Raw Helper is hard and undocumented. SN APIs are friendlier and version-stable. Use them unless you specifically need Helper’s flexibility.

Lua-side variables don’t survive save/load. Persistent state must go through:

  • MD vars (via event bus)
  • Userdata API
  • The engine’s preference system (Userdata wraps this)

X4 9.x’s sandbox restricts some FFI calls (GetWareData notably). For data lookups, prefer the MD-bridge pattern (MD resolves, Lua receives via event) over raw FFI. See FFI gotchas.

If your menu leaks references (e.g. doesn’t nil-out menu.infoFrame), you’ll see β€œinvalid fontstring” spam in debug.log. Vanilla pattern in cleanup:

function cleanup()
RemoveAllUITriggeredEvent()
menu.frame = nil
menu.infoFrame = nil -- critical
end

Per-frame work in onUpdate runs on every visible frame. Heavy logic (FFI lookups, table rebuilds) tanks framerate. Batch updates on state-change events instead.

  • Helper API β€” Layer 2 reference
  • Globals β€” Layer 1 event functions
  • FFI β€” Layer 1 C function calls
  • SN APIs β€” Layer 4 friendly wrappers