Scene engine¶
Source: src/CTLD_sceneManager.lua, src/scenes/*.lua
Classes: CtldScene (one running instance), CTLDSceneManager (singleton registry + engine)
CTLDSceneManager executes time-sequenced deployments of DCS statics and ground groups from a
declarative model. It is the backend for every FARP, FOB and minefield deployment: a scene
model lists ordered steps, and the engine spawns each step's objects relative to a position
snapshot, waiting a configurable delay between them.
Scene models live one per file under src/scenes/ and self-register at load time via
CTLDSceneManager.getInstance():registerSceneModel(model). CTLDSceneManager:_registerBuiltins()
is intentionally empty — there is no hard-coded model list.
Internal data model¶
CtldScene (one instance per active deployment)
├── _name : "<modelName>#<counter>" (unique per deployment)
├── _modelName : original model name (used for registry / pack lookups)
├── _unit : trigger DCS Unit (or a mock unit for playSceneAtPos)
├── _steps : model.steps (shared reference)
├── _stepIndex : current step pointer (0 before the first step)
├── _timeMarker : absolute timer.getTime() target for the next step
├── _spawnedObjs : { DCSObject, … } (every object spawned so far)
├── _params : runtime bag forwarded to step funcs (e.g. farpName, repackData)
├── _onComplete : callback fired after the last step (or model.onComplete)
├── _aborted : true once abort() is called
├── _coalitionId / _countryId : snapshot at init
└── _refX / _refZ / _refAlt / _refHdgRad / _magDecDeg : position + heading snapshot
CTLDSceneManager (singleton, via getInstance())
├── _models[modelName] : registered model tables
└── _active[sceneName] : CtldScene instances currently deployed
_models is keyed by model name; _active is keyed by the per-deployment _name
("<modelName>#<counter>"), so several instances of the same model can be active at once. Both
tables are in-memory only — _active does not survive a mission restart or a full CTLD
re-injection during development.
The reference position and heading are captured once in CtldScene:init() from the trigger
unit (unit:getPoint() and ctld.utils.getHeadingInRadians). Every subsequent step is positioned
relative to that snapshot, so the scene deploys coherently even if the unit moves or leaves. A
step's func may overwrite _refX / _refZ / _refAlt on ctx.scene before later spawn steps
run.
Step execution¶
The step machine is driven by timer.scheduleFunction. CtldScene:_execute() schedules the first
step after steps[1].delayAfterPreviousStep seconds; _runNextStep() then executes the current
step and schedules the next. Each step is one of three shapes:
| Step type | Key fields | Behaviour |
|---|---|---|
| polar | polar = { distance, angle }, relativeHeadingInDegrees, relativeAltitudeInMeters, registryKey |
Deterministic world position derived from the snapshot via ctld.utils.getRelativeCoords. |
| axis | axis = { count, safeDistance, spacing }, registryKey |
A random axis around the unit; count objects distributed along it via ctld.utils.getSpawnObjectPositions. |
| func | func = function(ctx) … end |
No spawn — runs the callback only (post-spawn hook / custom placement). |
Every step also carries delayAfterPreviousStep (seconds): after step N runs, the engine waits
that many seconds before step N+1. The same field on step 1 is the initial delay from the
trigger.
For a spawning step (registryKey present, spawn not skipped), _runNextStep():
- Runs the optional
preFunc(ctx)withctx = { unit, step, scene }. Returningfalseskips this step's spawn (the scene continues); callingctx.scene:abort(reason)stops the scene entirely. - Looks up the descriptor with
CTLDObjectRegistry.get(step.registryKey)and auto-injectscircleRadiuswhen the descriptor uses acircleformation. - Spawns via
CTLDObjectRegistry.spawnObject(registryKey, coalitionId, countryId, x, z, hdg, overrides)— the engine never callscoalition.addStaticObject/coalition.addGroupdirectly (afunc-only step may, as the countryside FARP does for its boundary tyres). - Appends every spawned object to
_spawnedObjs. - If
step.criticalis set and nothing spawned, callsabort()rather than continuing with a broken partial scene. - Runs the optional
func(ctx)withctx = { unit, spawnedObj, step, scene }, wherespawnedObjis the last object spawned this step (nilfor a skipped orfunc-only step).
Both hooks are wrapped in pcall; an error is logged (ERROR) and the scene proceeds. When the
last step finishes, _onComplete(scene) fires (also under pcall). A model may set
model.onComplete; a caller-supplied callback to playScene overrides it.
Entry points¶
| Method | Use |
|---|---|
playScene(unit, modelName, params, onComplete) |
Start a scene from a live trigger unit. Rejects a nil/dead unit, an unknown model, or a _disabled model. |
playSceneAtPos(modelName, pos, coalitionId, countryId, params) |
Start a scene with no live unit (e.g. parachute auto-unpack). Builds a minimal mock unit at pos facing north and delegates to playScene. |
Both register the new CtldScene in _active before executing.
FARP pack flow¶
Packing tears a deployed FARP scene back down into crates while preserving its warehouse state.
The flow is split across the scene manager and CTLDCrateManager:
Player selects "Pack Equipt → Pack <scene>"
└── CTLDCrateManager:refreshPackEquiptSection(playerObj)
└── CTLDSceneManager:findNearbyRepackableScenes(transport:getPoint(), 300)
└── returns _active scenes within radius whose model defines onRepack
└── on click, per scene:
1. sc = CTLDSceneManager._active[sceneName]
2. repackData = CTLDSceneManager:packScene(sc)
├── model.onRepack(sc, repackData) ← snapshot warehouse into repackData
├── destroy every obj in sc._spawnedObjs
└── _active[sc._name] = nil
3. spawn cratesRequired crates via CTLDCrateManager:spawnCrate(...)
└── crate.metadata.warehouseSnapshot = repackData.warehouseSnapshot
On crate unpack at a new site (auto-unpack path in CTLDCrateManager):
└── sm:getModel(desc.unit) resolves the scene model
└── CTLDSceneManager:playSceneAtPos(desc.unit, centroid, coa, cId, { repackData = … })
└── warehouse step reads ctx.scene._params.repackData.warehouseSnapshot to restore fuel/inventory
packScene(scene) takes only the scene instance; onRepack(scene, repackData) is where a model
writes its repackData.warehouseSnapshot. The snapshot travels on the crate through
crate.metadata.warehouseSnapshot and is threaded back into the new scene's _params.repackData
so the warehouse-stocking step can restore liquids and inventory instead of zeroing them.
The relevant identifiers (onRepack, findNearbyRepackableScenes, packScene, repackData,
warehouseSnapshot) keep their spelling in code; the F10 label is ctld.tr("Pack %1", …).
Adding a new scene (dev checklist)¶
- Create
src/scenes/CTLD_myScene.lua: a local model table withnameandsteps, ending withCTLDSceneManager.getInstance():registerSceneModel(myScene). - Declare the crate on the model itself —
myScene.crate = { weight, i18nKey, deployKey, cratesRequired, side, … }. It is auto-injected into the Request Equipment menu byCTLDCrateManager:_processSpawnableCrates/_injectSceneCrate; there is nounit = "…"entry to add inCTLD_userConfig.lua. - Register any DCS objects the steps reference with
CTLDObjectRegistry.registerIfAbsent(key, descriptor)and point each step'sregistryKeyat them. - Add the file to
tools/build/listToMerge.txt(scenes are listed after all managers so the crate auto-injection resolves) and add the matchingdofileline totests/helpers/loader.lua. - If the scene deploys a mod-based helipad FARP with a warehouse, add a
func-only final step that stocks it viaw:setLiquidAmount(fuelType, qty)(fuel types0–3: jet fuel, aviation gasoline, MW50, diesel). Read levels withw:getLiquidAmount(fuelType), nevergetLiquid. Invisible FARP airbases returnnilfromgetWarehouse(), so guard for it. - For pack support, implement
myScene.onRepack(scene, repackData)that readsw:getLiquidAmount(...)andw:getInventory()intorepackData.warehouseSnapshot. - To make the scene deployable as a FOB, set
fobCompatible = trueinside thecratetable (myScene.crate.fobCompatible). A FOB scene typically also supplies a customcrate.unpack = function(unit, unitName, sceneName) … enddelegating toCTLDFOBManager. - If the scene needs a mod that cannot be probed (heliport types with
probeSkip = true), setmyScene.requiresMod = "<registryKey>"so_auditAfterModValidatoremits a startup WARN.
src/scenes/CTLD_countrysideFarpScene.lua is the reference implementation (Invisible FARP +
warehouse stocking + onRepack); src/scenes/CTLD_fobScene.lua shows the FOB variant.
Mod validation¶
After CTLDModValidator runs, CTLDCoreManager:init() calls
CTLDSceneManager:_auditAfterModValidator(). It walks every registered model's steps, resolves
each registryKey in CTLDObjectRegistry, and — skipping entries flagged probeSkip — checks the
DCS type against the validator (isStaticInvalid / isGroundInvalid). A model with any missing
type is marked _disabled, reported via trigger.action.outText, and purged from the Request
Equipment menu (CTLDCrateManager:_purgeDisabledScenes). Models declaring requiresMod (which
cannot be auto-validated) instead emit a WARN reminding mission makers that all clients need the
mod. isSceneEnabled(name) reflects the result. See Architecture for the
probeSkip rationale and the object registry.