Script Pipeline
Processing script atachments and detachments is all done by the script pipeline.
In versions prior to 16.0 the pipeline was represented by load unload commands, which atomically processed scripts. Since then the pipeline has been maassively overhauled to allow:
- Customizing callbacks during the process
- Budgeting the work done by the pipeline each frame
- Allowing async steps in the process
The new pipeline works like this:
- A set of systems in
PreUpdate“filter” incomingScriptLifecycleMessage’s (attachments and detachments, processed in the order they were written) as well asScriptAssetModifiedMessage’s (some generated from asset events, others from component hooks). The filtered events are wrapped inForPlugin<P>(inner)events based on the language of the underlying event’s. - A set of other systems then converts those into active
ScriptMachine’s - Then inside the
ScriptProcessingSchedule<P>, which is only run inPreUpdateif there are active machines, we tick each machine until it either errors or reaches its final state.
Script Machines
State Machine
Script loading is handled by the following state machines:
graph TD;
A[Loading Initialized]
B[Reloading Initialized
Enter: OnReloaded callback]
A --[ new context ]--> C
B --[ existing context]--> C
C[Context Assigned
Enter: OnReloaded + OnLoaded callbacks]
C --> D
D[Loading Completed]
E[Unloading Initialized
Enter: OnUnloaded callback]
F[Context Removed]
G[Resident Removed]
E --[Last resident]--> F
E --[More residents]--> G
H[Unlading Completed]
F --> H
G --> H
Hooks, such as on_script_loaded are implemented using bevy observers. This allows you to hook into various parts of the loading process and add your own callbacks, i.e.:
fn main(&mut app: App) {
let machines = app.add_observer(|On<LoadingCompleted<LuaScriptingPlugin>>| todo!("do stuff"));
}
Timing & Order
By default all machines are ticked to completion in the frame they start. Setting ScriptLoadingPipeline::tick_budget limits the amount of machine ticks (steps in the state machine above) per frame, spreading the loading of many scripts over multiple frames. The budget counts steps, not time, so a step running a slow callback can still take up the frame.
You can override this and many other settings (including disabling the core callbacks) by changing the ScriptLoadingPipeline<P> settings via your scripting plugin’s set_pipeline_settings.
The script loading/unloading order will look as follows:
- the order in which components are attached/detached, will determing what order scripts will be processed
- an attachment whose script asset is still loading holds back the attachments and detachments after it, until it finishes loading
- scripts are processed one-by-one, i.e. each machine is ticked to completion before the next one is started
- meaning for example if two scripts are loaded, their
on_script_loadedhooks will not run at the same “lockstep”.
- meaning for example if two scripts are loaded, their
- loading/unloading might happen over multiple frames, depending on the pipeline’s settings.
Determinism
Given the same inputs (the same order of attachments, detachments and app updates), scripts load in the same order and callbacks reach scripts in the same order, on every run and machine: script contexts are kept in insertion ordered maps, and the pipeline budget counts steps rather than time.
What BMS cannot control:
- which frame a script asset finishes loading in, as asset IO is asynchronous. Wait for scripts to load (see below) before relying on frame timing.
- language internals, for example Lua iterates tables with string keys (
pairs) in a different order on each run. BMS seeds Lua’smath.randomwith0when creating a context, callmath.randomseedto change it.
Waiting for scripts to load
In order to check on the pipeline and figure out when everything is ready, you can use the ScriptPipelineState<P> system parameter in a system as shown in the script_loading example.