Scripting

From FreeSpace Wiki
Revision as of 19:56, 24 May 2020 by M!m (talk | contribs) (Add initial documentation for asynchronous coroutines)
Jump to: navigation, search
This feature requires FreeSpace Open

Scripting allows a modder to run custom code in the engine that can read and modify the game state to produce new features.

Scripts are either specified via Scripting.tbl or as standalone lua files.

General Info

  • Scripting hooks are not executed on a separate thread (at least as of this writing). They are executed at given points in the code, and all other execution will not take place until a scripting hook has finished. Therefore, large and complicated scripts will most likely cause noticeable lag or slowdown when executed.
Note: Information on this page does not apply to pre-3.6.10 builds


Useful references

Standalone Lua scripts

Since writing Lua scripts using the table can lead to some issues while developing scripts (such as wrong syntax highlighting or wrong line numbers in error messages) the engine also supports loading Lua scripts directly and executing them when the initial game data has been loaded. Since that alone does not allow to register hooks, there is a scripting API that allows to do that from Lua code. See engine.addHook in scripting.html.

This allows to write a non-trivial script without using the global namespace which always risks contamination by other other scripts writing to the same global variables. Since the hook functions are defined in the same file as the other hook functions they can use the local variables declared in the top level scope without having to expose them to the global namespace.

Basics

Frames

One of the most important things to understand when reading this guide is the concept of "frames". (Experienced modders can probably skip this part) In gameplay, motion is achieved by making incremental changes every fraction of a second. A ship will be moved slightly, and then drawn to the screen. This happens 30-120 times a second; anything lower, and the game will become choppy and difficult to play.

There is also a difference in the moving of objects in-game, and the moving of objects onscreen. All ships are first "moved", in memory. This probably consists of physics calculations, that change the ship's position based on if their engines are turned on, or if they run into another object. After this, the game then renders the now-moved ships to what is referred to as a "backbuffer" - a place in memory that takes the place of the computer screen. Once rendering has finished, the backbuffer is instantly drawn to the screen. This prevents objects from flickering as they are drawn. You do not have to worry about flipping the backbuffer in scripting; FS2_Open will take care of that for you.

For scripting, it is important to understand that each block of code will therefore be executed several times a second, but only once per frame. However, the exact number of times per second will depend on how much work the computer has to do (And therefore how much time it takes) for each frame. To determine how much time you should consider to have passed, you must use the ba.getFrametime() function; which will return the approximate time the frame will take, in seconds. In addition, if you "move" a ship after it has already been rendered, the change will not become apparent until the next frame.

This does mean that if you want to make a block of text move across the screen, at a rate of 5 pixels per second, all you have to do is multiply the speed by the frametime:

#Global Hooks
$Global: [
   --If the g_WheeXPosition variable does not exist,
   --it means we are on the first frame.
   --If that is the case, set it to zero.
   if not g_WheeXPosition then
      g_WheeXPosition = 0
   end

   --Move the text
   g_WheeXPosition = g_WheeXPosition + (5 * ba.getFrametime())

   --Draw "WHEE!!!"
   gr.drawString("WHEE!!!", g_WheeXPosition, 10)
]
#End


Hooks

Scripting "hooks" form the basis of the scripting system. A "hook" simply refers to a point in the code where code to execute scripting has been added. The hook may then be added to a table, or added to a SEXP. In general usage, "hook" refers to an entry in scripting.tbl.

All hooks take the form of an identifer (such as $Global:), followed by brackets to determine what type of scripting is being executed. A lack of brackets also indicates a type of scripting execution. All current bracket configurations are listed below. Note that "$Hook:" is used merely as a placeholder, and should be replaced with the actual hook name ($Global:, $HUD:, $On Frame:, etc).

  1. $Hook: scripting
    Specifies one line of LUA scripting. In addition, the return value of that line will be passed to the interpreter and used (if applicable). Specifying a variable or value on this line will result in it being returned, as well.
  2. $Hook: [scripting]
    A single set of brackets specifies a block of LUA scripting. Does not return a value unless explicitly specified. (Explicit specification is not possible at this time; but may be implemented in the future if/when more future hooks take a return value.)
  3. $Hook: [[scriptfilename.lua]]
    Double brackets specify a file. This file is read out of the data/scripts directory, and must include the file extension. You may specify a compiled lua file as a target as well; this may result in crossplatform issues for a mod, however.


+Override

Some hooks additionally take a +Override: field. This field uses the same bracket configurations as above. If a hook includes a +Override field, this hook always determines whether the default FS2_Open behavior that the hook is associated with will function, or will be disabled. This should be used in cases where scripting will replace the original FS2_Open behavior. For example:

$HUD: [
    gr.setColor(255, 255, 255, 255)
    gr.drawString("HUD Disabled", 50, 50)
 ]
    +Override: true

Additionally, override hooks are (by convention) executed before FS2_Open behavior. The HUD hook override, for example, is executed before any part of the HUD is actually drawn.

Asynchronous operations

FS2 Open, 3.6.14: This feature is not yet available in FSO builds and is still in code review.

As already laid out in the previous sections, Lua code is executed synchronously when the engine invokes a hook. That means that the engine will stop doing what it does, execute some Lua code and then continue on. However, if you want to have some behaviour happen over multiple frames (e.g. you want to smoothly move text from one side of the screen to the other) you have to break up that operation into multiple "On Frame" hooks calls and somehow restore the operation state in each hook call. This is bothersome and gets increasingly more complicated when scripts get more complex. For this reason, the scripting API supports the concept of "Promises" and asynchronous coroutines. Let's look at Promises first since they are important for the coroutines.

Promise

Promise is a JavaScript concept that exists in various other languages with various names but the basic idea is that it is a handle to a value that will be available in the future. For example, you could have a function that returns when a specific ship arrives in a mission and gives you a handle to that ship. Such a function could not be run synchronously since it would block the engine making it impossible for it to actually warp in that ship.

However, a function could return a promise that will resolve to the ship handle once that ship has arrived. Then a script could just register itself to be called when the promise has been resolved and not worry about keeping track of new arrivals via different hooks or something like that.

For this reason, promises has one important operation: "continueWith" (in JavaScript it would be "then" but that is a keywork in Lua). That function gets called with a function as its first parameter. This function will be called when the promise resolves with the resolve values as its parameters.

In addition to resolving, promises can also go into an "error" state. This means that whatever operation the promise represents has failed for some reason. In our example, ending the mission would also cause an error for the promise since it is impossible for a new ship to arrive in the mission we were in. This case can be handled with the "catch" function. Similar to "continueWith", you pass it a function but this will only be called when the promise ends in an error. The function returns another promise that will (successfully) resolve with the return value of the "catch" function so you can handle an error in a promise and still continue.

Asynchronous Coroutines

A normal function has a very strict life cycle. It gets called, does some stuff, and then returns a value. A coroutine expands that concept by allowing the function to "suspend" at certain points in its runtime. Then the calling function can "resume" the coroutine at a later point until the function is actually finished and reaches a "return" statement. This is a very common concept in several programming languages but Lua has built-in support for that. That covers the "Coroutine" part. Now let's look at the asynchronous part.

Consider again our promise from above where we want to wait for a ship to arrive. What we want to do is to apply some battle damage to the ship once it arrives. However, this ship isn't here yet. We could use the "continueWith" function from the returned promise but that is a bit bothersome expecially if you have some more complicated control flow. Instead, we can use our coroutines to start a function, maybe do some setup, and then "suspend" until the promise resolves:

async.run(function()
	mn.sendPlainMessage("Waiting for GTVA Collosus")

	local ship = async.await(mn.waitForShipAsync("GTVA Collosus"))

	applyBattleDamage(ship)
end)

There are two interesting functions here. async.run and async.await. async.run will execute the specified function as a Lua coroutine and return a promise that will resolve with the return value of the function or error out if one of the awaited promises returns an error.

async.await takes a promise as its parameter and uses the coroutine character of the function and suspends the function until the promise resolves or causes an error. It will then resume the function an return the resolve value of the promise. This way, we can write code that looks just like normal synchronous code but still do stuff across several frames.

Advanced promise features

These coroutines are a very powerful feature but without anything to produce promises it is not very helpful. The engine can maybe provide you with some functions that return coroutines but the point of Lua is to allow modders to do their own thing. For this reason, there is the basic building block async.promise. This function can create a promise for which the Lua code can determine when it resolves or errors. To do this, you pass async.promise a function that takes up to two parameters. Both parameters will be functions. Calling the first parameter resolve will resolve the original promise with the passed parameters. reject will similarly cause the promise to transition into the error state with the passed parameters as the error values.

This feature is very useful to "promisify" code that uses the "old" way of doing asynchronous operations since it allows to create the promise at one point and then store the resolve and reject functions so that they can be called at a later point.