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.
The four layers
Section titled βThe four layersββββββββββββββββββββββββββββββββββββββββββββββ 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.
Layer 1: engine bindings
Section titled βLayer 1: engine bindingsβThe engine injects globals into every Lua scriptβs scope:
RegisterEvent(name, handler)β subscribe to MD eventsAddUITriggeredEvent(name, value)β fire MD events from LuaSetScript(slot, fn)β register lifecycle handlersCallEventScripts(name, args)β invoke registered scriptsffi.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.
Layer 2: Helper API
Section titled βLayer 2: Helper APIβui/addons/ego_detailmonitor/helper.lua defines ~200 Lua functions wrapping the raw engine calls into menu-building primitives:
Helper.createFrameHandle(...)β wraps engine frame creationHelper.createTable(...)β wraps engine table widgetHelper.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.
Layer 3: vanilla menus
Section titled βLayer 3: vanilla menusβ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:
- Registers itself via
Helper.registerMenu(menu) - Defines
onShowMenuto build the layout - Defines
onUpdatefor per-frame ticks - Defines
cleanupto 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.
Menu lifecycle
Section titled βMenu lifecycleβ 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.
Layer 4: mod menus
Section titled βLayer 4: mod menusβ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.
The MD β Lua event bus
Section titled βThe MD β Lua event busβ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.
Common patterns
Section titled βCommon patternsβMenu reads MD state
Section titled βMenu reads MD stateβ onShowMenu β AddUITriggeredEvent("get state") β MD cue β raise_lua_event("state data", $state) β Lua handler updates menuBi-directional: Lua asks β MD provides.
MD pushes UI updates
Section titled βMD pushes UI updatesβ MD detects change β raise_lua_event("update") β Lua handler refreshes displayOne-way: MD pushes when something changes.
Persistent settings via Userdata API
Section titled βPersistent settings via Userdata APIβ onShowMenu β read from Userdata API β display β onUserChange β write to Userdata APIThe Userdata API (one of the SN APIs) provides per-save persistent storage outside MD vars.
Why this matters for modders
Section titled βWhy this matters for moddersβUse SN APIs by default
Section titled βUse SN APIs by defaultβRaw Helper is hard and undocumented. SN APIs are friendlier and version-stable. Use them unless you specifically need Helperβs flexibility.
Lua-side state is volatile
Section titled βLua-side state is volatileβ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)
FFI is sandbox-restricted
Section titled βFFI is sandbox-restrictedβ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.
Custom menus need cleanup
Section titled βCustom menus need cleanupβ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 -- criticalendPerformance
Section titled βPerformanceβ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.
Cross-references
Section titled βCross-referencesβ- Helper API β Layer 2 reference
- Globals β Layer 1 event functions
- FFI β Layer 1 C function calls
- SN APIs β Layer 4 friendly wrappers
Related architectural overviews
Section titled βRelated architectural overviewsβ- Save migration β Lua state canβt be migrated; MD vars can