If you want to create UI mods from scratch, it can be unclear where the proper entry points into the UI or where data from the Sim is acquired by the UI. There can also be difficulties in debugging. This page introduces the UI framework and how to avoid these issues.
You should already know how to structure a mod and hook files from the general modding introduction.
The general introduction teaches that you can hook files and functions, but what do you hook when you want to create a UI mod? While you can hook functions for existing UI elements, this does not let you create a mod from scratch.
SetFrontEndData and GetFrontEndData (used for replay ID, replay filenames, and campaign briefings) and the Prefs file (used by mod manager and FAF client to record active UI mods).StartGameUI from uimain.lua is called, and its main job is to create the "World UI Provider", an object that has the lua callbacks for the loading phases of the game (loading game Sim rules ("in transit" screen), waiting for other players, and finally create the game interface).CreateGameInterface callback in the UI Provider runs when the game finishes loading. The main function of interest is CreateUI of gamemain.lua.In order of importance:
CreateUI of gamemain.lua.StartGameUI, because uimain.lua is loaded in the front end, but you can hook CreateWldUIProvider of gamemain.lua. Changes here would most likely be preferred as contributions to FAF game repository (GitHub).UserSync.lua, which ends up being a hook in the Session Init phase, but you should not need to use this.After hooking CreateUI (or a function), what do you write in your hook? This depends on the what you want to accomplish, but the fundamental structure can be similar for most mods to make debugging (and testing) easier.
Since hooking is file concatenation, if an error occurs in your code, the line numbers in the error message will go past the end of the actual file and you have to decipher where that large line number falls within your own mod. To avoid this, put your code in a separate file and import it.
For example, instead of:
function ComplexFn(obj, str)
local ret = oldComplexFn(obj, val)
if obj.a > 1 or str == "" then
obj.window:Hide()
end
return ret
end
do:
function ComplexFn(obj, str)
local ret = oldComplexFn(obj, str)
local myModule = import("/mods/yourmod/modules/file.lua")
myModule.ModifyComplexFnObj(obj, str)
return ret
end
If your mod has an error and hooks CreateUI or a similar initialization-type function, the initialization you hooked won't continue for any other code coming after, be it from other mods or something you wrote. This can sometimes break the UI in such a way that you need to close the game to get the UI to work or errors to stop flooding the log.
You can avoid this by wrapping your function in a pcall:
function ComplexFn(obj)
local ret = oldComplexFn(obj)
local myModule = import("/mods/yourmod/modules/file.lua")
local ok, msg = pcall(myModule.ModifyComplexFnObj -- no () since we're passing in the function
, obj, val
)
if not ok then WARN(msg) end -- complex mods may need to do more than this when an error occurs
return ret
end
Errors that happen during imports are usually not handled by whatever imported it, and they tend to spread errors because they break entire files instead of just one function. So don't do complex operations or call functions other than import. Use functions to encapsulate your code, and then call those functions wherever you are importing your mod.
Instead of:
-- module file
ModuleObj = {}
ModuleObj.a = CreateA() -- complex function that could error as you are working on the mod
ModuleObj.b = CreateB()
-- hooked file
local module = import("/mods/yourmod/modules/file.lua") -- if this import errors it breaks the entire hooked file
function Hooked()
return module.ModuleObj
end
do:
-- module file
local moduleObj
function GetModuleObj()
if not moduleObj then
moduleObj = {}
moduleObj.a = CreateA()
moduleObj.b = CreateB()
end
return moduleObj
end
-- hooked file
local module = import("/mods/yourmod/modules/file.lua")
function Hooked()
return module.GetModuleObj()
end
The disk watcher lets you immediately test changes you make if it is set up correctly. This is done using the __moduleinfo table that every imported file (a "module") has, using the member functions OnReload and OnDirty.
OnDirty is called when the disk watcher detects a change in your module. It is an opportunity for you to clean up anything in the current state of the moduleOnReload is called when a dirtied module is imported again. It is called from the dirty module's state and has the new module as a parameter. It is an opportunity to set up the new module and pass data from the old module to the new one.In conjunction, you can use these to make a UI element that goes back to where you were looking after you make a change, for example the AutoLobbyInterface class.
Basic Example:
local myObject
function __moduleinfo.OnDirty()
myObject:Destroy()
import(__moduleinfo.name)
end
function __moduleinfo.OnReload(newModule)
local newObject = newModule.CreateObject()
newObject:RestoreState(myObject.State)
end
These examples re-import the module in the
OnDirtyfunction, but this should only be done if the module is not a dependency, because when a module is dirtied it dirties all modules that depend on it, and dependent modules usually initialize the module. Modules become dependencies if imported during import or after setting__moduleinfo.track_imports = true.
There are two ways to get data from the Sim: Engine functions and the Sync table.
Engine functions give relatively simple data about units in your own army.
This includes: "Rollover info" (blueprint id, resource consumption/production, health, shields, fuel, work progress, focused unit, kills stat, ammunition stats, custom name, and army index), command capabilities (what orders can be given), build capabilities, build rate, assisting units, attached units (for transports), pause state, fire state, auto-build mode, auto-surface mode, submerge state, overcharge availability, command queue, unit creator, unit position, unit's completion progress, and unit blueprint.
Rollover info can be given about units outside your army by calling
GetRolloverInfo()when mousing over the unit, but info about resources, shields, fuel, work progress, focus, ammo, and kills is removed.
There are also a few engine functions that give information about the game, including: scenario info, game speed, game tick/time, mouse world pos,
You may have noticed GetScriptBit and ToggleScriptBit. Script bits are used to toggle common abilities like shields, intel, stealth, cloak, production, jamming, and other special ones. Even though they usually follow the normal game rules for how they're used, their implementation in the sim is arbitrary lua, and the sim can toggle them as well. There is a table in the unit blueprint that defines how the toggles should be displayed in the UI when the script bits do not act exactly as expected.
The GetStat function returns simple info about units as defined in the sim, including veterancy level/experience, HP regen, and reclaimed resource amounts.
The Sync table is the way complex, custom info is sent from the sim to the UI.
This includes: what mass is on the map, game score data, game results (army defeat/victory), built unit enhancements, in-progress building/enhancement notifications, nuke launch notifications, mass fabricator status, objective timer, pings, replayed chat, game restrictions, pauses, diplomacy, campaign transmissions, and more.
The best way to mod the Sync table is to write a function and then add it using AddOnSyncHashedCallback.
local function OnNukeLaunched() end
AddOnSyncHashedCallback(OnNukeLaunched -- the callback
, "NukeLaunchData" -- Category to listen to
, "MyMod_OnNukeLaunched" -- Identifier to allow us to be replaced
)
If you clean up your UI element mid-session, you should remove the callback using RemoveOnSyncHashedCallback.
RemoveOnSyncHashedCallback("NukeLaunchData", "MyMod_OnNukeLaunched")
The old way to mod it is to hook UserSync.lua's function OnSync.
The global UnitData table contains persistent data for units, and is keyed with the entity ID. Although it is very versatile, it is currently only used for Seraphim T1 Scout Selen deselection when cloaked (key: LowPriority), name of currently set weapon priority (key: WepPriority), Eye of Rhianne ability status (key: Abilities; no UI example usage), and status of active buff names (key: Buffs; no UI example usage).
Basic example:
function GetUnitWepPriorityName(userUnit)
local id = userUnit:GetEntityId()
return UnitData[id].WepPriority
end
The following sections are brief and could be expanded into their own pages.
With knowledge of where to hook and how to get data, you now just need to put the data on screen visually.
UI elements are made up of Controls, which are pretty much boxes of various types with a defined height, width, and position. There are various classes that also add text or images.
Choose a UI element you like as an example, but be wary of old code styles.
Mods made by 4z0t have advanced features such as animations and a centralized mod options system, but they use a custom library.
A lot of FAF's UI is outdated so the code style is not good. One up-to-date UI element is the autolobby interface, but it is rather simple.
Mods made by 4z0t are more complex and generally have good code style, but they use a custom library.
uiutil.lua Various utility functions to make UI scripts easier and more consistentlayouthelpers.lua Layouter class: ReusedLayoutFor An extremely helpful design pattern for laying out components, it is intended to make UI code readable, maintainable, robust, and easily diagnosableLazyVars are common (even created by the engine) and used to dynamically calculate UI elements so that they can update when they are moved around, resized, or have text input/changed. They can also be used to manage state.
They are difficult to debug. Setting ExtendedErrorMessages to true in lazyvar.lua can help slightly. The most common lazyvar error comes from uninitialized position/height/width of a Control.
Just like the code style, a lot of FAF's UI is old and difficult to understand or expand. Newer examples include the autolobby interface.
There exist hotkeys to cycle skins.
You can add a hotkey (called a key action in the code) for your mod by using the keymapper to permanently save it in the user key actions (a table in the preferences file).
local SetUserKeyAction = import("/lua/keymap/keymapper.lua").SetUserKeyAction
local keyAction = {
action = "UI_Lua import('/mods/yourmod/modules/file.lua').DoMyHotkey()", -- console command to run
category = "MyMod", -- category under which the action will be grouped in the hotkeys menu (F1)
}
SetUserKeyAction("ModdedAction1" -- Name of action used for display and searching
, keyAction)
If you want to give your key action a better display name you can use key descriptions. This supports localization.
local keyDescriptions = import('/lua/keymap/keydescriptions.lua').keyDescriptions
keyDescriptions.ModdedAction1 = '<LOC key_ModdedAction1>Perform action from my mod'
If you want to assign a key to an action you can use the keymapper's SetUserKeyMapping function.
local keymapper = import("/lua/keymap/keymapper.lua")
local current = keymapper.GetCurrentKeyBinding("ModdedAction1")
keymapper.SetUserKeyMapping("Ctrl-Shift-Alt-M", current, "ModdedAction1")
To create a custom key mapping system, you should use IN_AddKeyMapTable, IN_RemoveKeyMapTable, and IN_ClearKeyMap. It would be a fundamental UI change, any improvements would be welcomed as contributions to the game repository.
Besides the aforementioned hotkeys:
IsKeyDownuimain.lua function SetEscapeHandler for the escape keyEdit class implements a text editor, but it can be used to capture all keyboard input.The engine uses arrow keys for camera translation, spacebar for camera rotation, and alt + ~ (in EN locale) for ToggleConsole.
uimain.lua functions AddOnMouseClickedFunc and RemoveOnMouseClickedFunc.PostDragger to assign a Dragger object as the active one.HandleEvent function that is called when the mouse moves over or clicks on the control. The return value of the function determines if anything happens to the underlying Control.
If you want to support multiple languages, you can use the LOC function to localize text inputs (although most UI util functions elements already localize internally) and hook the strings_db.lua file in the folder of the corresponding language.
Many engine capabilities are not available as global engine functions, instead they are available through console commands. Many of the older/more basic hotkeys and game settings use console commands directly instead of scripting their behavior.
You can invoke commands by passing a string to the function ConExecute and you can read the output by adding a function using AddConsoleOutputReciever (remove it after you're done with RemoveConsoleOutputReciever).
To get information about units, you need to get the unit object first. This is done by selecting units, which can be done by the user by clicking in the world view or scripted with engine functions or console commands.
Whenever the selection is changed, the function OnSelectionChanged in gamemain.lua is called. This includes selection changes done both by the user clicking in the world view and engine functions/console commands selecting something.
To avoid running the callback when your code changes selection, you can use the function Hidden from selection.lua or SetIgnoreSelection from gamemain.lua. Hidden is a callback code style, where you pass your function that selects something and operates on it into Hidden. SetIgnoreSelection is a toggle, and your code has to manually indicate when to start and stop ignoring selection changes.
To work the selection in code, you can use UISelectionByCategory to select new units, SelectUnits and AddSelectUnits to select units you previously saved in a variable, and GetSelectedUnits to get the current selection.
When the user issues a transport drop order, the engine checks the "Session Extra Select List" to determine what units to drop. You can interact with this list using AddToSessionExtraSelectList, RemoveFromSessionExtraSelectList, and ClearSessionExtraSelectList.
You can get transport-related data with GetAttachedUnitsList and UserUnit:HasUnloadCommandQueuedUp.
You can replace icons programmatically using a function or by blueprint ID using a table.