Lua API

This gives a quick overview of Lua, which Jester is primarily coded in; and explains the Jester API itself, as well as how to create custom mods.

Get started with Lua

Useful links:

Tables

Lua in itself is a fairly simple language with not too many features. It primarily revolves around the use of tables. Tables can be compared to arrays, lists and dictionaries, or maps.

-- dictionary
local person = {
  name = "John",
  age = 20,
}

-- array/list
local fruits = { "Orange", "Apple", "Lemon" }

Arrays also implicitly decay to dictionaries with ascending keys 1, 2, etc.

Access can be either in a lookup-style person["age"] = 21, or like fields person.age = 21.

In Lua, indices start counting at 1:

print(fruits[1]) -- Orange

The length of a table can be accessed using #:

-- appending to a table
fruits[#fruits + 1] = "Cherry"

Anything not explicitly set is given the value nil.

Syntax example

function ageCheck(name, age)
  if age < 18 then
    print("Sorry", name)
  else
    print("Okay", name)
  end
end

Classes

Lua itself does not provide classes. However, we created a framework to add class-like structures to Lua:

local Class = require('base.Class')

local Person = Class()
Person.name = nil -- fields
Person.age = nil

function Person:Constructor(name, age)
  self.name = name
  self.age = age
end

Person:Seal() -- Prevent adding/removing more values/functions to it

The framework also supports inheritance:

local Class = require('base.Class')
local Behavior = require('base.Behavior')

local AssistAAR = Class(Behavior) -- inherits from Behavior

Debugging

Unfortunately, we do not have any Lua debugger setup. One has to rely on caveman debugging with prints.

To aid in that, Jester offers an in-game console UI (RCTRL+L). This console displays any string logged via Log(...).

Further, it offers a console prompt allowing execution of Lua code.

jester_console_hello_world

The prompt can also be used to inspect the running code, for example by entering a command such as

Log(GetJester().behaviors[require('radar.MoveRadarAntenna')].current_antenna_degrees.value)

jester_console_inspect

🚧 HB UI does not support the full keyboard yet, for example ().[]:"' cannot be entered. It is thus recommended to prepare the prompt in an external text editor and simply copy-paste it into the UI instead.

We also provide a Lua playground in WizardJester.lua, which is always executed directly on startup.

It is also possible to edit Lua files while DCS runs, without restarting the game. Simply edit a LUA file and then reload the DCS mission with CTRL+R and the new Lua file will be effective.

User Mods

Add content

Custom Jester logic is placed in the Saved Games folder, within the jester\mods subfolder. The full path might for example look like:

C:\Users\John Doe\Saved Games\DCS_F4E\jester\mods

Any Lua file placed in this folder will be made available and can be loaded through for example require 'MyFile' within Lua.

Any Lua file placed in the subfolder jester\mods\init will not only be made available, but also be executed when spawning into the aircraft. This mechanism allows announcing your custom content and adding it to Jester through a callback register called mod_init:

-- Place this in a LUA file in jester\mods\init
mod_init[#mod_init+1] = function(jester)
  -- Executed at spawn, use 'jester' to register your logic
  Log("Hello World!")
end

💡 When the jester\mods folder does not exist, it will be automatically created on first spawn of the aircraft. Further, the folder will be pre-populated with a simple ExampleMod.

Replace content

Existing behavior of Jester can be replaced by simply adding a Lua file under the same name than the original file you want to replace to the jester\mods folder.

For example, in order to replace MoveRadarAntenna.lua (e.g. G:\DCS World OpenBeta\Mods\aircraft\F-4E\Jester\radar\MoveRadarAntenna.lua) with custom logic, place a file that is called MoveRadarAntenna.lua as well into the modding folder (e.g. C:\Users\John Doe\Saved Games\DCS_F4E\jester\mods\radar\MoveRadarAntenna.lua).

Now, when the existing logic tries to load this file using require 'radar.MoveRadarAntenna', your custom file will be prioritized and loaded instead. To get back the original behavior, simply delete your custom file.

🟡 CAUTION: It is not possible to replace any files from the following folders:

  • base
  • memory
  • senses
  • stats

Attempting to do so results in a warning message being shown and all Jester mods getting disabled.

Jester Modding Repository

To share mods with others or propose integration of mods into the base game, content can be uploaded to the public repository Heatblur-Simulations/jester-modding.

This repository also contains the source files of Jester to aid modders in learning the Api, but also to enable modification of existing logic.

Jester API

Jesters logic is divided into 6 layers of abstraction:

  • Intention (WIP)
  • Plan (WIP)
  • Situation
  • Behavior
  • Task
  • Action

The original logic is located in the DCS Mod-Folder, for example:

G:\DCS World OpenBeta\Mods\aircraft\F-4E\Jester

Example

As an example that touches most of the layers, we want to create a feature that lets Jester report the current speed every couple of seconds during flight.

Therefore, we start with a Situation. A situation needs an activation and deactivation Condition:

-- Airborne.lua
local Class = require 'base.Class'
local Condition = require 'base.Condition'

local Airborne = {}
Airborne.True = Class(Condition)
Airborne.False = Class(Condition)

function IsAirborne()
  -- details on observations later
  return GetJester().awareness:GetObservation("airborne") or false
end

function Airborne.True:Check()
  return IsAirborne() -- activation condition
end

function Airborne.False:Check()
  return not IsAirborne() -- deactivation condition
end

Airborne.True:Seal()
Airborne.False:Seal()
return Airborne

Activation and deactivation conditions do not necessarily have to be the same.

Now, we can use this condition in our Flight situation and add our desired behavior:

-- Flight.lua
local Class = require 'base.Class'
local Situation = require 'base.Situation'
local Airborne = require 'conditions.Airborne'
local ReportSpeed = require 'behaviors.ReportSpeed'
-- behavior will be defined in the next step

local Flight = Class(Situation)

-- it simply expects a class with a :Check() method
Flight:AddActivationConditions(Airborne.True:new())
Flight:AddDeactivationConditions(Airborne.False:new())

function Flight:OnActivation()
  self:AddBehavior(ReportSpeed) -- start our behavior
end

function Flight:OnDeactivation()
  self:RemoveBehavior(ReportSpeed) -- stop our behavior
end

Flight:Seal()
return Flight

The situation also has to be registered in F-4E_WSO.lua (WIP):

-- in F-4E_WSO.lua
...
function CreateF4E_WSOJester()
  ...
  wso::AddSituations(Flight:new())
  ...
end

Now, we can define our behavior:

-- ReportSpeed.lua
local Class = require('base.Class')
local Behavior = require('base.Behavior')
local SaySpeed = require('tasks.common.SaySpeed')
-- Task will be defined in the next step

local ReportSpeed = Class(Behavior)

function ReportSpeed:Constructor()
  Behavior.Constructor(self)
end

function ReportSpeed:Tick()
  -- this is called periodically
  local task = SaySpeed:new(...) -- access to speed explained later
  GetJester():AddTask(task)
end

ReportSpeed:Seal()
return ReportSpeed

Now, this would let Jester say something on every tick, a bit too verbose. To improve on this, the Urge-system has been created. We can wrap our task in an Urge and it will only be called on a set interval (which is automatically applied some variance based on Jesters fixation and stress level):

-- ReportSpeed.lua
local Class = require('base.Class')
local Behavior = require('base.Behavior')
local Urge = require('base.Urge') -- added
local StressReaction = require('base.StressReaction') -- added
local SaySpeed = require('tasks.common.SaySpeed')

local ReportSpeed = Class(Behavior)

function ReportSpeed:Constructor()
  Behavior.Constructor(self)

  -- logic of the behavior
  local say_speed = function ()
    -- very simple in this case,
    -- but could also trigger multiple tasks based on conditions, if desired
    local task = SaySpeed:new(...)
    GetJester():AddTask(task)
    return {task}
  end

  -- define the urge
  self.urge = Urge:new({
    time_to_release = s(10), -- baseline interval (10s now)
    on_release_function = say_speed, -- what to execute
    stress_reaction = StressReaction.ignorance, -- how important is this to Jester
  })
  self.urge:Restart() -- start it
end

function ReportSpeed:Tick()
  -- we could also modify the urge now, if desired
  -- for example increasing the stress level
  self.urge:Tick() -- tick it
end

ReportSpeed:Seal()
return ReportSpeed

The next step is to create the actual Task that will take care of reporting the given speed:

-- SaySpeed.lua
local Class = require('base.Class')
local Task = require('base.Task')
local SayAction = require('actions.SayAction')

local SaySpeed = Class(Task)

function SaySpeed:Constructor(speed)
  Task.Constructor(self)

  local on_activation = function()
    if speed < kt(500) then
      -- see PhrasesList.txt for all supported voice lines
      self:AddAction(SayAction('awareness/wereslow'))
    else
      self:AddAction(SayAction('awareness/werefast'))
    end
  end

  self:AddOnActivationCallback(on_activation)
end

SaySpeed:Seal()
return SaySpeed

The last part is the final Action, in our case SayAction. Actions are usually very generic and basic. In most cases, the existing SayAction will be all thats needed. Refer to SayAction.lua for how it works.

If a behavior has no extra need for a specific task and just wants to say a phrase, one can also directly use SayTask:

-- in a Behaviors logic
...
local task = SayTask:new('misc/outoffuel')
GetJester():AddTask(task)
...

LReal and units

A very common need is to work with real values and units, such as speed or time. Therefore, we have LReals, with units defined in LUnit.

local time = min(15)
local speed = kt(500)
local fuel = lb(12000)

if time > s(10) then
  print("foo")
end

time = time - s(40)

Careful when doing scalar operations:

-- correct
time *= 2

-- incorrect
time *= s(2)

Latter would result in an invalid LReal, which can be checked for using time:IsValid().

If necessary, values can be converted to another unit:

local timeInSeconds = time:ConvertTo(s)
print("Time:", timeInSeconds)

time.value would access the raw underlying number.

Accessing properties

Lua has full access to all Propertys defined in our components and can access them easily with GetProperty:

function GetTotalFuelQuantity()
  local gauge_readout = GetProperty(
    "/Pilot Fuel Quantity Indicator/Fuel Meter", -- path
    "Internal Fuel Quantity Indication" -- property name
  ).value

  return gauge_readout or lb(10000)
end

GetProperty expects the full path to the component within the component-tree (that are all names of parent components), they must start with / to indicate an absolute path.

The returned value is a wrapper Property object. Access to the underlying value (in this case a LReal with unit Pounds) is given by GetProperty(...).value.

See the properties_snapshot.json file in the Heatblur-Simulations/jester-modding repository for a full list of all readable properties.

properties_snapshot

💡 Open the file with a browser to skim and search through it.

Observations and Senses

Additionally to direct property access, Jester has an Observation-System. The system allows to make frequently used data accessible in an easy way, or also to provide more complex data, i.e. coming from the DCS SDK.

Observations are part of Senses, of which Jester has several (eyes, ears, …). As of now, most of them are WIP.

local isAirborne = GetJester().awareness:GetObservation("airborne") or false

Interactions

One key aspect of Jester is that he can interact with the cockpit by clicking switches, buttons and turning knobs.

Therefore, the API offers two approaches.

Component Interactions

The preferred way to interact with the cockpit is via the component system.

To allow interaction, a manipulator has to be registered at F_4E_WSO_Cockpit.lua:

-- ChaffMode: OFF, SGL, MULT, PROG
self:AddManipulator(
  "Chaff Mode",
  {component_path = "/WSO Cockpit/WSO Left Console/AN_ALE-40 CCU/Chaff Mode Knob"}
)

After that, it can easily be interacted with, for example:

task:AddAction(SwitchAction:new("Chaff Mode", "MULT"))
-- or in short
task:Click("Chaff Mode", "MULT")

or reading its current value:

local cockpit = GetJester():GetCockpit()
local chaff_mode = cockpit:GetManipulator("Chaff Mode"):GetState()

Raw Interactions

If the desired switch does not support the component interface yet, one can instead fall back on a raw interface that invokes DCS commands directly, as if the player would have triggered a bind manually.

-- sends value 1 via command WSO_EJECT_INSTANT to device EJECTION_SEAT_SYSTEM
ClickRaw(devices.EJECTION_SEAT_SYSTEM, device_commands.WSO_EJECT_INSTANT, 1)

-- sends the value corresponding to position 2 on a 7-position knob
ClickRawKnob(devices.HUD_AN_ASG_26, device_commands.HUD_SelectHUDMode, 2, 7)

See devices.lua for all available devices and likewise command_defs.lua for the commands.

In general, Knobs and 2-pos switches use the range [0, 1] for values, while 3-pos switches often (but not always) use [-1, +1]. For 3-pos switches -1 is usually used to move a 3-pos switch down, +1 to move it up - but some switches have a different orientation. See default.lua and clickabledata.lua to learn more about a specific switch and how it reacts to values.

Events

Next to clicking switches, Jester can react to events send either from C++ or also from within Lua. The system follows a simple observer/listener pattern:

ListenTo("go_silent", "Radar", function(task)
  task:Click("Radar Power", "STBY")
end)

with:

if is_aar then
  DispatchEvent("go_silent")
end

Task API

A core aspect of writing logic for Jester revolves around using the Task class. Tasks consist of a sequence of Actions. A task can be paused, resumed or cancelled entirely by the system if necessary.

Actions are, by design, executed asynchronously. Executing a click will take some time and not execute instantly. In particular, adding a click action to a task will not block the code, it simply gets added to the chain of actions to execute eventually.

This concept is similar to Future-APIs in other languages and Task offers a fluent-API to deal with it conveniently.

Consider the following example:

local task = Task:new()
task:Roger()
  :Click("Radar Power", "OPER")
  :Wait(min(4))
  :Click("Screen Mode", "radar")
  :Say("phrases/radar_ready")
  :Then(function()
    self.scan_for_bandits = true
  end)

Among other functions, the API offers:

  • AddAction - any Action, basis for the API
  • Then - anonymous function
  • Wait - time
  • WaitUntil - predicate
  • Say - phrase
  • Roger
  • CantDo
  • Click - name, state
  • ClickFast - name, state
  • ClickShort - name, state
  • ClickShortFast - name, state

Refer to Task.lua for details.

UI

Jester provides two types of user interfaces. A wheel with selectable options and a dialog with questions and selectable answers that are shown on demand. See Wheel UI and Dialog UI for more.