Scripting¶
OpenReliant runs scripts from mods, written in Luau. The design follows OpenMW's Lua scripting and is described in full in #498. This version supports load scripts, which change the game's records (stats and text) at startup, global scripts, which hook the game's functions and events as it plays, object scripts, which run on the mission's objects, and player and menu scripts, which draw over the flight display and the menus and hear the keys. Scripts send each other events and offer each other interfaces. The scripting guide explains how to write them; this page explains how they run.
Improvement: the original has no scripting apart from its mission scripts.
Drawing resources¶
Improvement: scripted HUD/UI drawing uses mod PNG pictures, the current game's sprite set,
and selected bitmap or outline fonts (#590). drawing.Assets owns cached mod resources by script
context and filename, while drawing.View borrows the current game's fonts, sprite set and
rasterizer. Custom outline fonts reuse hud.outline.Fit and its atlas path. Measurement uses the
same bitmap layout as drawing. Cached resources are released before the rasterizer and renderer
at shutdown; reload creates a new context's entries without invalidating preceding frames.
Registries and built-in interfaces¶
Improvement: registries.zig owns presentation registrations by context and qualified name
(#558). It calls handlers through the protected runtime and validates camera return values in
a protected boundary. Camera selection uses unnamed view values after the original table; an
original view change releases the custom selection. Camera subjects use reuse-counted object
handles. HUD displays and selected screens record commands through the existing drawing layer.
Context shutdown and failed loads invalidate registrations and release callback references.
builtin_interfaces.zig groups existing API declarations. interfaces.zig supplies these tables
as fallback bases, while keeping normal scope/override behavior. Generated definitions follow
the same declarations rather than a second list of function signatures.
Post effects¶
postprocessing.zig is the openreliant.postprocessing
package and the registry of the effects (Runtime.post_effects), on the presentation side.
registerreads the shader withMod.readFileand hands it to the effect host (postprocessing.EffectHost), which the driver gives the presentation side (Presentation.setEffectHost). The host compiles it, or reads it from the shader cache (platform.shader_cache), and adds it to the GPU (platform.gpu.Gpu.addEffect). A compile error is raised in the script. Without a host, as with the software device, an effect registers and draws nothing.- Each entry keeps its script's context, its qualified name, its stage, its order, its four
parameters, whether it is on, and the host's effect.
Runtime.closeand a failed load (Runtime.run) remove a context's effects, and the host removes them from the GPU. - The driver's
mod_shaders.zigis the host. It is the GPU's effect source (Gpu.effect_source): as each frame is finished, it gives the GPU the passes of the enabled effects (Registry.passes), sorted by stage, then order, then registration. The driver removes the host before the GPU is destroyed.
Surface and lighting functions¶
shaders.zig is the openreliant.shaders package and the
registry of the functions (Runtime.mod_shaders), on the presentation side, set up as the post
effects' is.
register_surfaceandregister_lightingread the function's file and hand it to the shader host (shaders.ShaderHost,Presentation.setShaderHost), which compiles it on its own in a variant of the device shader to find its mistakes, and keeps it. A compile error is raised in the script.- After each change (a registration,
set_enabled,set_parameters, a removal), the registry gives the host every function it has, in the order they were registered (ShaderHost.update). The driver's host (mod_shaders.zig) compiles the variants the lighting function that draws needs, gives the textures named their surface function bysrtexture.Table.find, and sets the surface function of every lit surface. object:set_surfacesets the surface function and parameters of each part of the object's model (srapiext.MeshObject.surface). An object whose function is removed draws without it.Runtime.closeand a failed load remove a context's functions, as they remove its effects.
Luau implementation¶
deps/luau builds Luau 0.740 from source as a static library. It includes the VM and the
compiler, using the source files listed in Luau's Sources.cmake for Luau.Common, Luau.Ast,
Luau.Bytecode, Luau.Compiler and Luau.VM. The build matches Luau's LUAU_EXTERN_C option: the
C API is extern "C", and Luau errors use longjmp instead of C++ exceptions, because exceptions
can't unwind through Zig code. Luau is always built optimized, like FreeType.
Because errors use longjmp, a Zig function called from Luau must free anything it allocated before
it calls something that can raise an error. Zig defers don't run when a longjmp skips the frame.
The scripting code is a separate module, src/scripting.zig, linked into
the game only. sltool and the other tools don't link Luau.
luau.zig wraps the C API.
The Luau state¶
runtime.zig sets up a Luau state for scripts. There are two
kinds of state:
| State | Scripts | Differences |
|---|---|---|
| Game | Load, global and object scripts, which affect what happens in the game | No os library. math.random uses a seeded generator so that every machine gets the same numbers, and math.randomseed is removed. |
| Presentation | Player and menu scripts, which affect what the player sees and hears | Luau's standard libraries |
Load scripts run in their own game state, which is created at startup and closed once they finish. Global and object scripts run in another, which lasts for a game (The game's scripts). Player and menu scripts run in a presentation state, which lasts from startup until OpenReliant quits (The presentation side). If no mod has a script of the kind, no state is created for it.
Sandbox¶
The state opens Luau's standard libraries, adds OpenReliant's require and print, and then calls
luaL_sandbox, which makes the globals and libraries read-only. Luau's sandbox has no io,
package, dofile, loadfile or string.dump, and doesn't load precompiled bytecode. print
writes to the log, prefixed with the mod's name.
Loading a mod's scripts¶
The first time a mod is opened (Runtime.open), every .luau file in it is compiled, and the
bytecode is kept for the rest of the state's life (Code), or until the scripts are reloaded
(Runtime.recompileFolders, Reloading). If a script fails to compile, the error
is logged then, and again if a script requires it. Each opening of the mod is a context
(Context): one for its global scripts, one for each mission it has scripts for, and one for each
object its object scripts run on. A context loads its own copy of a script as it requires it, with
its own global table (luaL_sandboxthread), so scripts can't see each other's globals, and the
scripts on one object can't see another object's. Each context's calls run on a thread of its own
that records the context, so require and OpenReliant's functions know which mod, and which
object, is calling. Coroutines created by a script inherit this.
require(name) runs a script from the same mod once for its context and returns its result, or
true if it returns nothing. Requiring it again returns the same value. The name is the file name,
with or without the .luau extension, in any case. Circular requires are an error. Names starting
with openreliant. load a package (script.Package), and
openreliant.self gives the handle of the context's object. Requiring a package that isn't
available to that kind of script, or isn't implemented yet, raises an error saying which.
Limits¶
| Limit | Load scripts | Other scripts | How it works |
|---|---|---|---|
| Time | 1 second per call | 100 milliseconds per call | Luau's interrupt callback checks the clock every 64 safe points and raises an error when the limit is passed |
| Memory | 64 MiB per mod | 64 MiB per mod | Each mod's allocations are counted in their own memory category (lua_setmemcat), and the allocator refuses any allocation that would take the mod over its limit |
After any failed call, the error is logged and a full garbage collection runs, so a script that hit the memory limit doesn't leave its garbage to block the next one. Mods past the 255th share the last memory category.
A call can run inside another: a hook's handler can run the game's function (e:original()), whose
own hooks call other handlers. Each call starts its own time limit and counts its allocations
against its own mod, and the call it interrupted gets its limit and its mod back when it returns
(Runtime.begin).
Runtime.call only pushes arguments that take no memory: references, numbers and booleans. What
does take memory, such as e or the table on_mission_start gets, is made beforehand in protected
mode (Runtime.make), where running out of memory is an error the engine handles, rather than one
Luau can't recover from. These count against no mod.
Binding¶
bind.zig exposes Zig values to scripts by reflecting over their
declarations at compile time:
- Numbers are numbers,
boolis a boolean, and enums are their tag names. An enum value without a name, or whose tag starts with an underscore (one not understood yet), is shown as its number (values.zigconverts every value of this kind). - Byte arrays are strings, such as a record's name.
- Structs and other arrays are proxies: userdata that read and write the value in place. Struct fields use their Zig names, except fields starting with an underscore, which are hidden (they hold unknown or unused data). Array elements are indexed from 1, as usual in Lua.
- Writes are checked: a number for a number field, a finite number for floats, an integer in range for integer fields, a tag name or valid number for enums. Unknown field names are errors, so typos are reported instead of ignored.
- Two proxies of the same value compare equal, and a proxy can be iterated over its fields or elements in order.
Field names are part of the scripting API, so the definitions file pins every name the records expose (The reference). Renaming a field breaks the mods that use it.
Records¶
records.zig holds copies of the stat tables
(Stat tables) and of the strings in language.dll and the ITAC's
itaclang.dll. After the load scripts run, the game reads its stats and text from these copies.
Each table exposes as many records as the game reads from its file. Before each load script and each
on_records_loaded handler, the records are saved, and they are restored if it fails, so a failed
script leaves no changes behind.
Text is stored in the game's code page (Windows-1252) and converted to UTF-8 for scripts. Text from
a script is converted back, with characters the code page doesn't have replaced by ?.
Load scripts¶
load.zig runs the load scripts at startup, before the window
opens. Mods run in load order, and each mod's scripts in the order its manifest lists them. After
all of them, each script's on_records_loaded handler runs, in the same order. The table a script
returns is checked: a load script may only return engine_handlers, and the only engine handler it
may give is on_records_loaded. The log reports scripts of a kind this version doesn't run yet
(Missile and Turret) and keys that aren't a script kind.
Script definitions¶
script.zig defines the whole scripting API, including the parts
that don't run yet, so that scripts written now keep working as later versions fill them in:
- The script kinds that
[Scripts]accepts:Load,Global,Player,Menu, each object class (create.ShipCombat.Class),Missile,Turret, and an object type asType.followed by its name or number. The build fails if an object class has no matching kind. - The script families, which decide what a script may do: load, global, object, player and menu.
- The keys of the table a script returns:
engine_handlers,event_handlers,interface_nameandinterface. - The engine handlers, which families may use each one, what the engine passes each
(
Handler.Arguments), and what it takes from what each returns (Handler.Result). - The packages, which families may require each one, and which are implemented in this version
(
Package.ready): all of them. Kind.runssays which kinds of script run: all butMissileandTurret, whose objects aren't objects in the mission's slots (#587).
Declarations¶
What scripts see of a package or a handle is declared once, in Zig, and the bindings and the
reference are made from it at compile time (api.zig):
Fielddeclares a field: its type, a sentence for the reference, a getter, and a setter for a field scripts can change.Functiondeclares a function: a sentence and its parameters' names. Its Zig parameters give their types: the first is theCall(the state and the calling context), and the rest are read withvalues.read. Its result is pushed withvalues.push. The build checks that every parameter has a name.Nativedeclares a function that reads what it's passed itself, such as one that takes a script's function, with its Luau types written out for the reference.
A struct is a table of its fields (values.zig). One a function
returns is read-only; one a script passes must name each field that has no default, and may leave
out the rest. A list (values.List), such as the objects world.objects gives, is a table of its
values in order. The reference shows a table as one scripts give, with its defaults, where a
declared function takes it.
A package made this way is a namespace of these declarations
(packages.zig says which namespace declares which package),
pushed as a read-only table of its functions, whose metatable reads its fields. A handle's fields
and methods are declared the same way (objects.fields, objects.methods).
The game's scripts¶
game.zig runs the scripts that decide what happens in the game,
while a game runs. The driver starts them as the front end starts a campaign, loads a saved game or
flies a mission on its own, or as --mission starts one, and stops them as the front end's main
menu comes back, or the game quits. Each game gets a new Luau state.
- A mod's
Globalscripts start with the game, in load order, each mod's in the order its manifest lists them. Each runs asrequireruns it, the table it returns is checked (Context.offerOf), and itson_initis called. [Missions]matches a mission's file name (winmain.missionFileName) to its keys, in any case. As the mission begins, each mod with scripts for it is opened again (Runtime.open), so that every attempt starts them afresh, and the scripts start as the global ones do. As the mission ends, they stop and the mod is closed (Runtime.close).- Object scripts start as an object is added (
object_added): each mod whose manifest names the object's class or type is opened for the object, and each script it lists for them starts there, withon_initand thenon_added. A global script starts one on an object withobject:add_script. As the object leaves the mission (object_removed), its scripts geton_removedand stop; as the mission ends or the next begins, the objects' scripts stop without it. - As a script stops, its handlers, hooks and interfaces go, and so does its context once none of
the mod's scripts on it runs. While engine handlers are being called, a stopped script only gets
marked, and leaves its list once they're done (
Game.sweep), so no list changes under a call. - An engine handler that fails is logged and not called again.
The engine handlers get their arguments as Handler.Arguments declares them: numbers as they are,
and handles and tables made beforehand in protected mode (Runtime.make). They're called in the
order the scripts started: the global and mission scripts', then each object's, by slot.
Events¶
core.send_global_event and object:send_event copy their value as plain data
(data.zig): nil, booleans, numbers, strings, vectors, handles, and
tables of these, at most 32 deep. The copy and the event's name wait in a queue
(events.zig) until the next update, which delivers them before
any on_update, in the order they were sent. Each script with a handler for the event's name in its
event_handlers gets it, newest mod first, and within a mod in the order the scripts started; a
handler that returns false stops the rest. Events sent while the queue is delivered wait for the
next update.
Interfaces¶
A script that returns interface_name and interface offers the table under that name
(interfaces.zig). An interface is seen within its scope:
the global and mission scripts', or one object's scripts'. openreliant.interfaces is one
userdata for every script, whose __index looks up the latest interface of the name in the calling
script's scope. A script that offers an interface of a name already offered in its scope gets the
earlier one in on_interface_override. As a script stops, its interfaces go, and the earlier ones
are seen again.
The engine calls the game side through engine.hooks.Scripts, which create.Objects.scripts holds
while a game runs, null otherwise:
| Call | Where | Handlers |
|---|---|---|
begin |
main.startMission, before the script's start part makes the mission's ships |
The objects' and the last mission's scripts stop, the mission's scripts start, math.random starts again, and the mission's order context is kept for the functions that give orders |
started |
The end of main.startMission |
on_mission_start, then the hook mission_started |
update |
main.missionFrame, after the orders (aigeneric.ordersUpdate), in a frame whose frame_duration isn't 0 |
The events waiting, then on_update, with frame_duration in seconds |
step |
The end of gameobj.simulationStep |
on_step |
ended |
main.endMission, as the driver lets a mission go |
on_mission_end, then the hook mission_ended; the objects' and the mission's scripts stop |
call |
The hooks (Hooks) | For object_added, the object's scripts start, then on_object_added; for object_removed, on_object_removed, then the object's scripts get on_removed and stop. Then the hooks' handlers |
math.random starts again from a seed made of the C runtime's rand seed as the mission begins
(libcmt.Rand, which it doesn't draw from) and the mission's number, so the game's own numbers stay
as they are and every machine draws the same. Before the first mission it starts from load
scripts' fixed seed. The original seeds rand with the time as each mission starts, which
OpenReliant doesn't port yet (#582).
Hooks¶
engine/hooks.zig declares what scripts can hook, and the engine
calls the hooks where they happen. scripting/hooks.zig runs the
handlers mods add. The scripting reference lists every hook.
Declarations¶
Hook is an enum built at compile time from these lists:
- Functions (
functions): each declared with its address, its fields (Fields), its result, the field that holds the object it concerns (subject, which filters test) and a sentence for the reference. A test inghidragenchecks each name againstghidra/names/LANCER.EXE.tsv. - The order table's routines (
routine_hooks), named asghidragennames them (ai.routines):order_and the order's name, with_initor_exit, or the names table's own name where it has one (player_controls,order_first_step_init). Their fields areRoutineFields. - Events: the mission's (
mission_events), which must each be adte.Condition, and the engine's (engine_events).
A function's fields are its parameters after the first, the world or the orders' context, in order,
with a slot as an Object and an order's or a missile's target as a Target, which gives back the
game's own target unchanged where the scripts leave it alone (Target.aimed). A field whose name starts with _ passes its parameter through without
scripts seeing it. The build checks that the fields follow the function's parameters and that the
result is the function's. So OpenReliant's code can change around a hook, but what scripts see of
it only changes where its declaration does.
Calling them¶
A hooked function starts with one line:
if (hooks.enter(.object_damage, damage, .{ world, index, struck, value, factor, attacker, kind })) |done| return done;
enter returns null at once without scripts, or while no handler hooks the function
(Scripts.hooked). Otherwise it copies the arguments into the fields and calls the handlers through
Scripts. To run the function itself, as the handlers have it (Call.original), the arguments are
read back from the fields, and the function is called again with Scripts.passing set to the hook,
which the first line takes as the sign to run the body. The order routines are hooked where
aigeneric runs them (runInit, runUpdate, runExit, through enterRoutine), which finds the
routine's hook from the order. Events are told with hooks.tell, where the engine posts them:
the mission's in mission/events.zig, as each posting routine takes them, object_added in
create_object, object_removed in object_reset and create.retire, order_started and
order_ended where an order's init and exit run, and trigger_fired in vm.triggers.match.
Running the handlers¶
Each hook's handlers are kept in the order they run: newest mod first, then in the order added. A
run of a hook (Dispatch):
- Calls the handlers added with
hooks.add, each that its filter lets through, until one returnsfalse. A filter's object, types, classes and sides are tested in Zig before calling into Luau. - Runs the function, unless a handler stopped it or already ran it.
e:original()continues the same run from the next handler, then runs the function, and returns its result. - Calls the handlers added with
hooks.after, with the result ine.result, until one returnsfalse.
e is userdata that points to the run, made for the first handler that runs. Its fields are read
and written through each hook's Access, which is generated at compile time, and values are
checked as they're written (values.zig). Once the run is over, e no longer points anywhere, so a
script that kept it gets an error. Before each handler, the fields and the result are saved; if it
fails, they're put back, unless the function ran inside it, and the handler is removed.
While any hook runs, handlers that are added wait, and removed ones are only marked, so that no
list changes under a run. Once the outermost run ends, the lists are brought up to date, and so is
Scripts.hooked.
The presentation side¶
presentation.zig runs player and menu scripts in their
own state, created at startup where a mod has either. The scripts of both sides share the code that
starts, calls and stops them (running.zig).
- Menu scripts start at once and run until OpenReliant quits. Player scripts start as a game
starts and stop as it ends, with the global scripts (
GameScriptsin the driver). - Each pass of the driver's loop, before anything is drawn,
Presentation.framegets the seconds since the last pass, the devices, the window's size, the camera and the sound, and what each drawing layer is drawn on. It tells the scripts of a new window size (on_viewport_resized) and, in flight, of the actions whose controls have just been used (on_action, fromDevices.activewithout taking the press), then callson_frame. - The window's key events reach
Presentation.key, which tellson_key_pressandon_key_releaseas a key's state changes, so a key the window repeats is told once. - The driver tells both kinds as each mission starts and ends (
Play.start,Play.end), with the same mission and outcome the game's scripts get (main.scriptMission,main.scriptOutcome).
Drawing¶
What the scripts draw in a frame is recorded in a layer
(drawing.zig): hud over the flight display, and ui over
the front end's screens or the pause menu. The layers
are cleared as each frame starts, and drawn after the game's own display or menu: the flight
display's and the pause menu's in Display.drawOverlay, the front end's in FrontEndDisplay.draw.
At most 4096 things and 64 KiB of text can be drawn on a layer in a frame. Text is converted to the
game's code page and drawn with hud.drawText, in the menus' small font over the front end, and
over the flight display and the pause menu in a ramped copy of the display's font, so that it takes
the colour the script gives. The debug's lines and text are placed in the world and projected
where the camera sees them (hud.Sight).
Events to the game¶
core.send_global_event from a player or menu script copies its plain data a second time, from
the presentation state into the game's (data.transfer), as an event would be sent to another
machine. Handles cross as handles of the same object, and a handle that's no longer valid stays so.
The rooms, the movies and the loading screens have loops of their own, which run the player and
menu scripts too (src/openreliant/script_frames.zig): each hands them the keys its window reads,
runs their frame before it draws, with their user interface layer in the menus' small font at the
front end's scale, and draws that layer last over the screen.
Saved games¶
snapshot.zig keeps the scripts' state with a saved game, in a
file beside it, saves\<call sign>GAME<slot>.scripts (save.companionName). The saves folder
tells the driver as a game is saved, loaded or removed (save.Extra, which GameScripts in the
driver implements), and the driver writes, reads or removes the file. The game is saved between
missions, so the file holds what lasts a whole game: the storage's game sections, each global and
player script that runs with what its on_save returned, and those scripts' timers. Mission and
object scripts don't run then, and menu scripts run across games.
The form, little-endian:
| Part | What it holds |
|---|---|
| Magic | ORSV, then the form's version as a u16, 1 |
| Game sections | Their count as a u32, then for each its mod's name, its name, the count of its fields as a u32, and each field's name and value (Storage.encodeGame) |
| Scripts | Their count as a u32, then for each its mod's name, its family as a byte (script.Family), its file's name, and what its on_save returned, nil where it has none |
| Timers | Their count as a u32, then for each its mod's name, its family, the name of its function, the seconds left as an f64, and its data |
Names and values are written in stored.zig's form: each value
starts with its kind as a byte (stored.Kind); a number is an f64, a string its length as a u32
and its bytes, a vector three f32s, a handle the object's slot as a u16 and its count of reuses
as a u32, and a table its count of pairs as a u32, then each key and value. A handle comes back
as one that isn't valid, since the game is saved between missions.
- As a game is loaded,
GameScriptsreads the file and starts the scripts withloadingset, so that they don't geton_init(Game.start,Presentation.startGame), thensnapshot.restoreputs the state back. The game sections come back first; then each script the file holds getson_loadwith what it saved, matched by its mod, its family and its file's name; then the timers start again. The scripts the file doesn't hold, such as a new mod's, geton_init. A load from the front end starts the scripts as the game goes into the rooms; a load in the rooms starts them again at once. A saved game without the file starts them as a new game. - A file of another version, or a damaged one, is logged, and what's left of it goes unread.
- The restart point keeps the same state in memory (
Saving.restartPoint), and a replay or the pause menu's RESTART starts the scripts again from it. RESTART ends the mission first, so that the scripts don't hear the end of a mission they never saw start. - With no scripts at all, nothing is written, and a file left from an earlier save in the slot is removed.
Storage¶
storage.zig holds the mods' storage: sections of plain data,
by mod and by name, each a game section or a global section (storage.Scope). The driver makes one
Storage as OpenReliant starts, and both Luau states reach it through runtime.Shared, so a
mod's scripts see the same sections on either side. A section's handle is a userdata whose
__index copies a field's value out as Luau values (stored.push), whose __newindex copies a
value in (stored.capture) and whose __iter goes through a copy of the fields made as the loop
starts, so that the loop can change the section. Load, player and menu scripts can't change game
sections.
- Game sections are kept with the saved game and the restart point. As a game starts, they're
emptied rather than freed (
Storage.clearGame), since scripts may still hold their handles. - Global sections are kept in the game folder, one file per mod:
storage\<mod>.data, where the mod is named as its archive or folder is. The file holdsORST, the form's version as au16, 1, and the mod's global sections as the scripts' state file holds game sections. They're read as OpenReliant starts (Storage.readGlobal), and the files of the mods whose sections changed are written at most every 2 seconds and as OpenReliant quits (Storage.flush).
Options¶
settings.zig is the openreliant.settings package and the
registry of the mods' pages (settings.Registry), which the driver makes with the storage and
reaches both Luau states through runtime.Shared.
register_pagereads a table of lists and values:values.readtakes avalues.Listas a table of values in order, at most its capacity, and a union of a boolean, a number and a string as whichever the value is (mod_options.Value). The page is checked (mod_options.Option.problemand the keys' being unique) and copied into the registry's arena, as the mod's page (mod_options.Page). Load and menu scripts can register, until the driver closes the registry once the menu scripts have started (Registry.close).- The values are the mod's global storage section
settings(settings.section_name), written byStorage.put. A value that is the option's default takes the field out. Reading goes throughOption.fit, which gives the default for a value that doesn't suit the option, and a choice's own copy of its value, so that what the screen holds lasts as long as the page. - The mods screen reaches the pages and values through
mod_options.Pages, whose three functionsRegistry.pagesfills in, as the settings screen reaches OpenReliant's options throughsettings.Own. A change is also noted in the registry (Registry.takeChange). The driver takes the changes after each pass of the front end and tells the mod's menu scripts (Presentation.settingChanged,Runner.callMod): the screen is the front end's, so no game scripts run then.
Game modes and the menu flow¶
game_modes.zig is the registry of the mods' game modes
(game_modes.Registry), which the driver makes with the storage and reaches both Luau states
through runtime.Shared. front_end.zig holds the menu
scripts' side of the front end.
core.register_game_modereads its table into agame_modes.Definition, checks it, and copies it into the registry's arena, with the list the game modes screen shows (Registry.shown). Load and menu scripts can register until the driver closes the registry once the menu scripts have started (Registry.close), which also reads each campaign's progress.- The driver starts the mode the screen chose (
Registry.start) before the game's scripts start, so that they read it (core.game_mode,core.game_mode_mission). As each mission ends,Registry.goesOngives the next mission from how it ended and how its script rated it, or none where the mode is over. A campaign's progress is the mod's global storage sectioncampaigns(game_modes.progress_section), keyed by the mode's own name. - Between a mode's missions the driver shows the front end's
mode_briefingwith the game's scripts still running (Flow.toBriefing), and stops them as the mode ends. front_end.scriptedgives the front end itsinterf.Scripted: whether a mod's screen stands in for a screen (the onesui.replace_screenset, and formode_briefingthe running mode's briefing), selecting it as the front end shows it, and the request it made since the last pass (front_end.Standing). The front end takes one request a pass.ui.play_moviekeeps the movie's name until the driver takes it after the front end's pass (Presentation.takeMovie) and plays it on a cleared screen.
Timers¶
async.zig runs a mod's functions after a while.
async.register_timer keeps a function in a table of its context (Context.callbacks) under a
name, and async.after adds a timer that names it (Runner.addTimer, at most 4096 waiting). A
timer names its function rather than holding it, so that it can be written to a file.
Runner.advance counts the timers down and runs those whose time is up, the most overdue first. A
timer added while timers run waits for the next round, so a timer that starts itself again with no
delay doesn't run for ever. The game side advances them in update, after the events and
before on_update, with game time; the presentation side in Presentation.frame, before
on_frame, with real time. A timer whose function isn't registered is logged and dropped. As a
context closes, its timers go.
Files¶
vfs.zig reads files for scripts, which can't write files.
vfs.read_mod reads the calling mod's own file (Mod.readFile). vfs.read looks a name up in three
places (vfs.find):
- A mod's copy, found by the last part of the name.
- The game folder's loose file, found by its whole path in any case (
Mods.readLoose, with the folder inruntime.Shared.game). A folder is not a file. - The member of
resource.hog(runtime.Shared.files).
A loose file is read up to half the mod's memory limit, since a copy goes into the script's string.
The console¶
console.zig holds the console: its output, the line typed and
the lines typed before, and what a line runs as. The driver (openreliant/console.zig)
creates it in the developer mode (DeveloperMode, --developer-mode) where a mod has a .luau
file, brings it up and takes it away with F11, and draws it last over the frame.
- The output keeps the latest 512 lines, of at most 256 bytes each, and is locked while it
changes, since the log writes to it from any thread. The driver's log function
(
std_options.logFn) writes each message of thescriptsscope to it as well as to the terminal: errors red, warnings gold, the rest blue. - A line runs as one of the console's commands (
console.Command), or, in Luau mode, as Luau in the context of a mod's global, player or menu scripts (console.Mode), looked up again for each line, since contexts close and open as games start and scripts reload.Runtime.evaluatecompiles the line asreturnand the line, and failing that as the line, and runs it on the context's thread within the time limit, with a thread of its own for globals (Context.console), which the context keeps for the next line. Each value it returns is shown astostringgives it, in protected mode. Any other line goes toon_console_commandwhere a player or menu script has it. helpwrites from the same declarations as the reference (reference.writeHelp): a package's fields and functions, an engine handler, or a hook asopenreliant hookslists it.- While it's up in flight, the mission is paused as the pause menu pauses it, and the console
stands in the pause menu's place; in the front end, the screen's pass is left out. Its pass
(
console/screen.zig) takes the characters typed and the keys, and the player and menu scripts hear no key pressed meanwhile, nor F11. Not yet: the console in the Reliant's rooms and the briefing, which have loops of their own (#589).
The screen is laid out on the front end's screen as the settings screen is
(console/screen.zig): its title on the row of the
settings screen's tabs, the output in a frame from x 45, 520 wide, with the controls list's arrows
right of its top, the line typed in a frame below it with the saved games' blinking cursor, and
RUN, CLOSE, CLEAR and RELOAD in the places of the settings screen's buttons, with their shapes. The
text is in the front end's small font, the title in its large font. Over the front end, what's
behind it is darkened enough that the menus' labels don't show through; over the paused mission,
only as much as the pause menu darkens it behind the settings screen (hudoptions.shade), so the
mission shows through.
Reloading¶
The scripts reload as the console asks, and in the developer mode as a folder mod's script is
saved: the driver looks at when each folder mod's scripts last changed once a second
(console.Watch). GameScripts.reload
in the driver:
- takes the scripts' state where a game runs, as a save takes it (
snapshot.take); - reads the folder mods' scripts again in the presentation state and starts the menu scripts again
(
Presentation.reload); - starts the game's scripts and the player scripts again from that state, in a new game state,
which reads the scripts again as it opens each mod (
GameScripts.startFrom); - partway through a mission, starts its mission scripts and the scripts of each object in it
(
Game.resumeMission), as the mission's start would, withouton_mission_startoron_object_added.
Load scripts and the manifests are read only as OpenReliant starts.
Object handles¶
objects.zig gives scripts objects as handles: a slot, and the
slot's count of reuses when the handle was made (create.Objects.reuses). The count goes up as
create_object fills a slot, as object_reset and create.retire remove its object, and for
every slot as a mission starts. A handle is valid while its count matches. The state keeps one
handle per slot in a table with weak values, so the same object always gives the same handle while
a script holds it.
Every script can read a handle's fields. A field with a setter can be changed by a global script on
any object, and by an object's own scripts on their object (objects.mayChange); the setter checks
the value's range, such as the throttle's (motion.reverse_throttle to
motion.afterburner_throttle). The same goes for give_order, which gives an order as the
mission's SetAI does (aigeneric.give), and for hook, which adds a handler whose filter names
the object. add_script and remove_script are for global scripts only, and act on the calling
mod's scripts.
The reference¶
reference.zig generates, from the declarations above, the
definitions file for luau-lsp (openreliant.d.luau), the
scripting reference, and what openreliant hooks prints. The first two are
committed, and tests check that they match what the code generates. Everything scripts see is in
them: the engine handlers, the packages, the fields and methods of objects, the hooks and their
fields, the records, and the names of enum values. So a change that would change the scripting API
fails the tests, and the API only changes on purpose; make definitions then writes both files
again.
The enums and the tables the reference lists are found by following every type scripts can reach
from the declarations. Each takes its type's own name, or the name its script_name gives where
that isn't clear enough on its own (gameobj.Type is ShipType); the build fails if two types
would take the same name.