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.
Improvement: scripted HUD/UI drawing uses mod PNG pictures, the current game's sprite set or
one a script names, and selected bitmap or outline fonts (#590). drawing.Assets caches the mods'
pictures and fonts by script context and file name, and the game's sprite sets that scripts name, by
their name in resource.hog (ShapeStyle.sprites). The sets are read with the mods' copies and
pictures, as the front end reads its own, while drawing.View borrows the current game's fonts,
sprite set and rasterizer. A named set's shapes are drawn from it directly. The layer's own set is
looked up when the layer draws, since its screen can change the set between the scripts' frame and
the drawing. Custom outline fonts reuse hud.outline.Fit and its atlas path. Measurement uses
the same bitmap layout as drawing. When a new picture or font needs room, the pictures drawn
longest ago are taken out of the cache, but never one drawn in the last two frames, and freed at
the next draw, which hands their textures back to the device. Fonts, and the rest at shutdown,
are released before the rasterizer and the renderer; a reload makes new entries for the new
contexts without invalidating earlier frames.
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.
A HUD display's replaces and layout give each of the game's instruments a placement
(Registry.placements, hud.Placement), and its parts each of their parts one
(Registry.partPlacements, hud.parts.Placement), from the displays that are on, a later one
winning. Once
the scripts' frame has run, the driver keeps them for the frame
(Presentation.instrumentPlacements): the flight display draws each instrument through a device
of its own that moves, scales or hides it (hud.Placings), and the radar's backing in the scene
follows the radar. The driver also passes the flight display's state and the player's
(Presentation.Host.flight), which instruments.zig reads
for openreliant.hud: as the instruments last drew, since the scripts run first.
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.
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 in the tests, 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.
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.
deps/luau builds Luau 0.740 from source as two static libraries, split as Luau's Sources.cmake
splits it: luau-ast, the parser (Luau.Common and Luau.Ast), and luau on top of it, the
bytecode builder, the compiler and the VM (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. luau.zig wraps the C API. sltool links the parser
alone, to read mission source (Reading the text).
The other tools don't link Luau.
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 |
The split is the one a network game needs
(#55): global and object scripts will run
only where the game is hosted, load scripts on every machine, and the presentation on each player's.
The presentation reaches the game only through events to the global scripts, which wait until the
game next runs and then have their data copied into the game's state (Presentation.sendEvents,
data.transfer), and it can't change objects (objects.mayChange) or records. The rules mods keep
for it are in the scripting guide (In a network game).
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.
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.
Neither side has getfenv or setfenv (Runtime.withheld). A function's environment is the
global table of the script that made it, so with them a mod could read and change another mod's
globals through any function that mod's interface hands it. The metatables of handles and of
packages are locked (State.lockMetatable): getmetatable gives "The metatable is locked" in
their place, so a script can't call their metamethods by hand.
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 raises an error saying which.
| 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). Its clock moves on by the time the inner call took, so that time spent in the
handlers it ran, other mods' included, doesn't count against it (Runtime.end).
A call can be slow once for reasons that aren't its own, such as the system or a collection. So a
function the scripts register goes at once where a call raises an error, but where a call runs out
of time, only once three of its calls in a row have (runtime.Timeouts). That holds for a hook's
handler and its filter, an engine handler, a custom order, and a registered camera view, HUD
display or screen; event handlers and timers only log their errors. Many handlers that each keep to
the limit can still hold up a frame; a budget for each frame across all of them isn't kept yet
(#1077).
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. Every call runs with the same error handler,
which adds the traceback: a reference made as the state starts (Runtime.traceback), so that
nothing takes memory between the mod's limit taking hold and the protected call
(State.protectedCallWith). A value a call returns is kept once the call has ended
(Runtime.callResult). Opening a mod's context makes its thread and its tables in protected mode
as the engine's work (Runtime.protect), since a script can open one (object:add_script). An
error that no protected call catches is a bug of OpenReliant's: it's logged before Luau aborts
(State.setPanic).
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.zig holds copies of the stat tables
(Stat tables), of the strings in language.dll and the ITAC's
itaclang.dll, of the campaign's order (gameflow.Order), and of what the campaign makes of each
mission (gameflow.CampaignMission). After the load scripts run, the game reads its stats and text
from these copies, and the driver installs the order and the missions (gameflow.install,
gameflow.installMissions). The package holds the tables, and its metatable gives campaign:
__index pushes a new list each time, and __newindex takes a load script's list, since Luau calls
it for a key the read-only table doesn't hold. missions gives a proxy for each mission
(records/missions.zig), whose fields load scripts
change in place.
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.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 keys in [Scripts] that aren't a script kind.
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.
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, the calling context and what the script called), 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 takes theCalland reads what it's passed itself from the call's state, such as one that takes a script's function, with its Luau types written out for the reference.
A call is labelled with what the script called: a package's function or field by the package's
name and its own, such as hud.text, and a handle's by the handle's kind, such as
object:give_order for a method and object.throttle for a field. Each error the call raises
starts with the label, which comes from the declaration's name where it's pushed, so no message
types it by hand.
The call also gives the names of what mods register. Call.qualified makes the qualified name of
something the calling mod registers. Call.named turns a name a script gives for something
registered into the qualified name: the calling mod's own name gets the mod's prefix, and a
qualified name of any mod stays as it is. The functions that look up what any mod registered, such
as camera.set_view, go through Call.named, and the ones that reach only the calling mod's own,
such as postprocessing.set_enabled, through Call.qualified.
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).
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. - Missile scripts start and stop the same way with each missile's flight, through the engine events
missile_added, asmissile_launchandmissile_launch_turretfinish, andmissile_removed, asmissile_endbegins. A context runs on an object, a missile or a turret (RunsOn), whichself,nearby, the interfaces' scope and what a script may change follow. A missile's handle (missiles.zig) holds its record and the record's count of reuses (Linked.reuses, OpenReliant's own), as an object's holds its slot's. Each missile notes its launcher slot's count as it's launched (Missile.launcher_reuses), so that itslauncheris nil once that object has left, andmissile_endruns once even where a script sets the missile off as it hears of its end (Missile.ending). - Turret scripts start as an object is added, after its own scripts, on each of its guns that is a
turret (
turrets.isTurret: any but a fixed gun), each turret with the mod opened for it. They run in the object's list, so they stop with its scripts. A turret's handle (turrets.zig) holds its object's handle and the gun's place among the object's guns (create.Slot.guns), and stays valid while the object does, a turret destroyed with its base (guns.Turret.gone) included. - 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 (
Runner.closeUnused), as does a context none of whose scripts started. 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. - A script can stop its own scripts while it runs, such as a missile's script that sets off its
missile. A context closed while calls run keeps its thread and its tables until no call runs
(
Runtime.close), so that Luau doesn't collect a thread in the middle of a call. - An engine handler that fails, or runs out of time three times in a row, is logged and not called again.
The orders the scripts give run against the world the mission's start hands them (Scripts.begin),
then against the world as each frame has it (Scripts.frame), since some of the world's pointers,
such as the cockpit's model, change between frames.
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, then each
missile's, by record.
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.
A script that returns interface_name and interface offers a read-only copy of the table under
that name (interfaces.zig), taken as the script starts
(Context.offerOf), so no other script can change what it offers. 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 each game frame, once a tick (Game frames and drawn frames) |
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 from the state of the game's random number generator
when the mission begins (Random.fingerprint, which reads the state without drawing a number) and
the mission's number, so the game's own numbers don't change and every machine gets the same.
Before the first mission it starts from load scripts' fixed seed. As in the original, the mission's
start first seeds the game's generator from the clock, so both differ from one start to the next;
--seed fixes the seed for a run that comes out the same each time
(Configuration).
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.
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, the orders' context or the
mission's script, 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. An array of numbers, such as a command's arguments, is a read-only list that a handler
replaces. 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. Where a handler stops a function, its result is the result
type's stopped where the type declares one, and zero otherwise.
vm_command (0x0045BEA0) pops a command's arguments and stores its result as the original does,
and runs the command itself in Machine.runCommand, the hooked function, on a copy of the
arguments padded with zeros to the most any command takes (executor.Arguments). The command is a
MissionCommand, the catalogue's names in snake case, and its result a CommandResult, whose
stopped is run_on. A handler's hold, a value no command gives, has Machine.command put the
arguments back on the stack and step back over the command instruction, as the game's waiting
commands do (Call.againItself), so the thread runs the command again the next time it runs.
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, the proximity events as
the watches' scan (0x0045B170) finds a ship close by, with the distance before it's truncated, 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.
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, or, where it
ran out of time, once it has three times in a row (Limits).
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.
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 size, the camera, the sound system, the scene for 3D sounds during a mission (Host.scene), the view each drawing layer draws on, and the player's place in the rooms while they're on screen (Host.rooms). It reports a new window size (on_window_resized), a new view or place in the rooms (on_room_changed), a new piece of music (on_music_changed, comparing paths case-insensitively) and, in flight, the actions whose controls were just pressed (on_action, fromDevices.active, without consuming the press). Then it callson_frame. - Each loop of the rooms' driver says what it shows as it runs the scripts' frame
(
Driver.pump): the rooms, a place's screen, or a screen outside them, such as the in-game options.openreliant.roomsreads the last frame's (rooms.zig). The host holds only values, never a screen:rooms.set_cd_player_pageleaves a request that the CD player's loop takes after the scripts' frame (Presentation.takeCdPlayerPage), so a script called between frames can't reach a CD player that has closed. - 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).
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).
audio.zig plays script sounds on the game's voices
(Script sounds). audio.Sounds caches the mod sound files the
scripts play, by mod and name, decoded into WAVE files on first use (recordings.decode), up to
64 MiB. When the cache is full, it evicts the least recently played files that aren't playing. A
playing file can't be evicted, because the software mixer reads the samples straight from it.
audio.Sounds also tracks the sounds the scripts started, by ID (hog_snd.Starter) and script
family, and drops them once they end. It stops the player scripts' sounds when the game ends, and
the menu scripts' sounds when the menu scripts reload.
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.
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 file is written before
the save itself, and if it can't be written, the game isn't saved
(Saved games). 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.
- Loading reads a file of up to
snapshot.max_size, 256 MiB, andsnapshot.takekeeps within it. It gathers the state first (Gathered.gather), then measures each mod's share of the file: its game sections (Storage.measureGame), what its scripts saved and its timers. Where the file would pass the limit, the mod with the largest share is left out, then the next, until the rest fits (Gathered.fit), and each is logged. Their scripts geton_initwhen the game is loaded. - 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.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).
A mod's scripts may keep up to storage.max_storage, just under 15 MiB, in each place, counted as
the memory the mod's sections take: each section, each field, and what the values hold
(stored.Value.footprint), which their binary form never passes. A change that would take a mod
past it is refused, unless it makes the sections smaller. What the engine keeps for a mod, its
options and its game modes' progress, and what a file holds as it's read back, aren't counted. So a
file of global sections always fits in the 16 MiB that reading takes, and one that wouldn't is left
unwritten (Storage.writeGlobal).
A value is checked before it's copied out of Luau (stored.capture), counting what the copy would
take. A value that holds the same table twice is copied twice, so a few tables can stand for
millions: the check stops past 16 MiB (stored.max_kept), which bounds its time as well as the
copy, since it runs in Zig, where the time limit doesn't reach.
Together, the mods' game sections and what their scripts save can take far more than one mod may keep, so the file kept with a saved game has a limit of its own, past which the mods that keep the most are left out (Saved games).
options.zig is the openreliant.options package and the
registry of the mods' pages (options.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
options(options.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.optionChanged,Runner.callMod): the screen is the front end's, so no game scripts run then.
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.
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.
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 file is read up to what the mod's scripts have left of their memory (Runtime.memoryLeft), since
a copy goes into the script's string. The string is made in protected mode, so that the bytes read
are freed where it can't be made (vfs.pushBytes).
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. The Reliant's
rooms and the briefing have loops of their own, which run the console's pass too
(
openreliant/rooms.zig): F11 brings the console up once the screen is drawn, and until it's taken away, a loop of its own draws it over that screen, whose pass is left out. The room's clock and sounds go on meanwhile. The movies and the loading screens don't bring it up. 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.
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.
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.
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 setters do what the game's own code does for the same change:
position and orientation place the object as SnapToPoint does (objects.setPosition,
objects.setOrientation), the orientation's axes squared up first (math.orthonormalize);
armor works out its conditions again (main.armorConditions); and the flags set what the
mission's commands set, through the same routines where a command does more than set its flag
(cloak.set, ai.setTargetable, executor.setLights). Shields and armour go up to what
create.makeWhole gives a ship. 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.
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, through fields, lists, optionals and the variants of a union. 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, and a missile type's record is MissileRecord, so that it isn't
taken for the Missile handle). The build fails if two of these types, records and handles
included, would take the same name, or if the definitions name a type they don't declare.
The definitions file is written for current luau-lsp: the handles, the records and the hooks' e
are extern types (declare extern type), the other types are exported so that scripts can annotate
with them, and the descriptions are --- comments, which luau-lsp shows on hover. A test parses the
file with Luau's own parser, as luau-lsp reads a definitions file
(source_parser.zig).
hooks.add, hooks.after and an object's hook take the hook's name from a union of the names
they accept (HookName, FunctionHookName and ObjectHookName, from hooks.Names), and give the
handler an e of type any, which scripts annotate with the hook's type. Luau types a handler's
parameters only from a single signature without generics: one overload per hook, or a type
function from a hook's name to its type, leaves e untyped in both of Luau's type solvers
(luau#3045,
luau#2373). The new solver also refuses an
intersection of more than eight such overloads, and then ignores the whole file
(luau#1853). So the file loads in both solvers, and beside mission source's definitions in one folder
(Editors): a test checks that the names both files declare are
declared the same way in each.