Crate spawn pipeline¶
Crates are the physical currency of CTLD logistics: a transport requests equipment as a
crate (or a set of crates), flies it to a destination, drops or slingloads it, and unpacks
it into a vehicle, a JTAC, or a scene. The whole domain lives in src/CTLD_crate.lua, which
defines the CTLDCrate entity and the CTLDCrateManager singleton.
This page follows a crate from configuration to deployed contents. For the manager's public method signatures see the API reference; for the events it publishes see Events; for the scenes and vehicles it feeds, see Scenes and Vehicles.
The CTLDCrate entity¶
CTLDCrate = class() models one cargo static and its lifecycle. A crate is created with
CTLDCrate:new(data) (never CTLDCrateManager internals directly) and carries its descriptor,
position, coalition, spawn method, and the DCS StaticObject handle.
The lifecycle is a small state machine held in crate.state, drawn from CTLDCrate.STATE:
| State | Meaning |
|---|---|
spawned |
On the ground, freshly created, never moved |
loaded |
Inside / attached to a transport |
falling |
In the air, descending (drop or parachute) |
landed |
On the ground after a transport cycle |
unpacked |
Contents deployed — terminal state |
Transitions are driven by entity methods, each of which sets state and the relevant fields:
load(transport), unload(position), drop(position), startParachute(altitude),
land(position), and unpack(). destroy() removes the backing DCS static.
Two predicates gate menu actions. isOnGround() returns true only in spawned or landed
state and verifies dcsStatic:isExist() when a static handle is present — this guards
against crates destroyed by combat before an S_EVENT_DEAD could unregister them.
canUnpack() combines isOnGround() with the canBeUnpacked flag. isLoaded() is true whenever the
crate is loaded, whatever the loading mode. isLoadedByCTLD() is true only for a crate in virtual
carry — loaded through the CTLD menu, or a DCS UI load handed over to CTLD (convertNativeLoadToCTLD) —
never for a crate in native carry (loadedByDCSNative, still inside the aircraft through DCS's cargo
system). CTLD's Drop Crate(s) and Parachute Crates actions and the weight CTLD adds to the aircraft use
isLoadedByCTLD(): a native-carry crate is unloaded and parachuted through the DCS cargo UI, and DCS accounts
for its weight itself, as it does for whole vehicles. CTLDCrate:load() clears loadedByDCSNative; the
native entry path sets it right after.
Each crate records its origin in crate.spawnMethod, one of CTLDCrate.SPAWN_METHOD:
| Value | Origin |
|---|---|
crate_spawn |
Spawned from the F10 Request Equipment menu |
vehicle_pack |
Result of packing a vehicle |
mission_maker |
Pre-placed by the mission maker (detected via INIT-B) |
menu_ctld |
Spawned via a CTLD menu action |
Configuration pre-processing: _processSpawnableCrates¶
CTLDCrateManager.getInstance() calls _processSpawnableCrates() once at first access. It reads
the raw ctld.gs("spawnableCrates") config and transforms it into two internal structures the menu
builder and every descriptor lookup rely on:
self._processedCrates[category] = { singleCrates = { {singleCrate, singleTypeSet?}, … }, mixedSets = { … } }self._weightIndex[weight] = descriptor— an O(1) index used by allfindDescriptorBy*methods (single crates only; mixed sets have noweightfield).
The AA system part/repair crate entries are plain catalogue entries in CTLD_config.yaml (see
AA system assembly), so they flow through the same passes as any other crate — nothing
injects them at runtime. The transform runs in three passes per category:
- Separation. Each entry with a
weightfield becomes a single crate (indexed into a per-categorycatWeightIdxand intoself._weightIndex); each entry with amixedSetfield becomes a mixed set. Entries with neither are logged as a warning and skipped. singleTypeSetgeneration. For each single crate whosecratesRequired > 1, whenenableAllCratesis notfalseand the crate'sshowSetsis notfalse, an auto-generatedsingleTypeSetis stored next to it:multipleis the weight repeatedcratesRequiredtimes, anddescis the crate description plus an i18n-aware" - " .. ctld.tr("All crates")suffix. SettingshowSets = falseon a crate suppresses this entry even whenenableAllCratesis on.- Mixed-set validation. Every weight in a
mixedSetis checked against the per-category index. Any unresolved weight invalidates the entire set (excluded from the menu) and queues a mission-maker warning shown viatrigger.action.outTextat startup. Valid sets are copied withmixedSetaliased tomultiplefor spawn compatibility.
Scene crates registered before init are merged in afterward via _injectSceneCrate; scenes
registered later are injected incrementally by CTLDSceneManager:registerSceneModel. Weight
collisions during injection are resolved by walking to the next free slot in the same 1001.xx
range and logging a WARN. Scenes disabled by the mod audit are purged by _purgeDisabledScenes.
From menu to ground: spawnCrate¶
A spawn request funnels through spawnCrate(descriptor, position, coalitionId, spawnedBy,
spawnMethod, countryId, modelKey). It delegates static creation to _spawnStatic, which:
- resolves the crate model from
ctld.gs("spawnableCratesModels")bymodelKey, falling back to"load"; - derives the country from the coalition when
countryIdis nil; - builds a sanitised unique name (
CTLD_<label>_<uid>orCTLD_Crate_<uid>); and - calls
ctld.utils.dynAddStatic(nevercoalition.addStaticObjectdirectly) inside apcall, returning the name and theStaticObjecthandle.
spawnCrate then wraps the static in a CTLDCrate, registers it, publishes OnCrateSpawned, and
schedules a near-immediate _refreshNearbyPlayers so nearby transports see the new crate in their
Load Crate submenu. spawnCratesAligned is the batch entry point: it picks a random spawn axis
(front sector for standard transports, rear sector for native-cargo-capable ones), computes
non-overlapping positions avoiding other dynamic-cargo aircraft bounding boxes, and calls
spawnCrate for each descriptor. The model key is chosen by _crateModelKey: "sling" when
slingLoad is set, "dynamic" for native-cargo-capable transports, otherwise "load".
When the transport's type declares a crate spawn plan (getCrateSpawnPlan: crateSpawnSector +
crateSpawnDistance in capabilitiesByType, see
ADR 0024),
spawnCratesAligned calls _spawnCratesInRow instead of the radial rule. It builds a row perpendicular to
the sector axis with ctld.utils.getCrateRowPositions: crates size + crateSpawnGap apart (getCrateSize
reads the model's size), centred on the axis, as many per row as fit along the
aircraft (UserBox length for side, width for rear/front), the next row one step further out. A side
plan picks the side at random; ctld.utils.positionsInsideAnyBBox flips it when the row would stand inside
another aircraft's volume, and the first side is kept when both are taken. The single crate of Request
Equipment goes through the same path. A type without a plan keeps the radial rule above.
A crate model without a size keeps it too (getCrateSize returns nil, so getCrateRowPlan gives no plan): no size is invented, and with the default catalogue every crate of a slingLoad mission takes the radial rule.
Packing a scene (the "Pack <scene>" command of refreshPackEquiptSection, a FARP) goes through spawnCratesAligned too; its third return value, the list of CTLDCrate it created, lets the callback attach the FARP's warehouse snapshot to each crate.
The placement is two routines shared with Drop Crate(s): _planCratePositions (the row, with the other-side
anti-collision) and _radialCratePositions (the radial rule, with its anti-collision). The Drop callback asks
getCrateDropPositions(transport, crates) for the positions of the crates it drops, one size per crate
(crate.modelKey; one crate without a size sends the whole wave to the radial rule), so a dropped crate stands where a requested one would and can be loaded again through the DCS
cargo UI. For a type with a plan the row is crateDropExtraDistance (default 2 m) farther than a requested one, so
the aircraft can taxi away or lift off without touching the crates it has just dropped.
Lifecycle transitions on the manager¶
The manager mirrors the entity transitions and owns event publication:
| Method | Transition | Publishes |
|---|---|---|
loadCrate(crateName, transport) |
spawned/landed → loaded (destroys the static) |
OnCrateLoaded, OnCrateCleared |
unloadCrate(crateName, position, method) |
loaded → landed (respawns the static) |
OnCrateUnloaded, OnCrateSpawned |
unpackCrate(crateName, unpacker) |
spawned/landed → unpacked (destroys + unregisters) |
OnCrateUnpacked, OnCrateCleared |
destroyCrate(crateName) |
destroy + unregister | OnCrateCleared |
Loading and unloading both call ctld.utils.updateTransportWeight so the transport's mass reflects
its cargo. Because the DCS static is destroyed on load, unloadCrate recreates it via
_respawnStatic, which mints a fresh unique name and re-indexes self.crates under it.
DCS-native cargo — _checkNativeDCSCargo (1 s tick)¶
Crates carried through the DCS cargo system are read from the on-board list, like native vehicles
(ADR 0022). A tracked crate is in native carry exactly while it is on the
unit:getCargosOnBoard() list (read with ctld.utils.getOnBoardCargo) of a player aircraft whose type sets
useNativeDcsCargoSystem; there is no bounding-box test, speed guard or drift reference. The tick returns at
once when no crate is tracked.
- Entry: a tracked crate on the ground whose
crateNameappears on a list becomes native carry (OnCrateLoaded,method = "dcs_native",loadedByDCSNative = true). Entries CTLD does not track are ignored with one debug trace; a vehicle'sCRG:companion entry belongs to the vehicle scan. An unreadable list logs one warning for the type and the type is no longer watched. - Conversion: for a type with
convertNativeLoadToCTLD(UH-1H and CH-47Fbl1 by default) the appearance is the trigger of the hand-over:UnloadCargoasks DCS to release the load and, 0.5 s later, CTLD checks that the cargo has left the on-board list beforeloadCrateloads it as a CTLD crate (destroying the static). The crate is still listed during that delay, so_convertingCratesmarks it as being converted and it is handled once. DCS releases a cargo only with the cargo-bay doors open and the aircraft on the ground (measured on the UH-1H: with the doors closed, or in flight even in a hover,UnloadCargoreturns success and does nothing), so CTLD never destroys a crate DCS still holds — that left DCS with an entry for a cargo that no longer existed (a "ghost" in its cargo window, the weight above the maximum, the slot taken). If the cargo is still on board (or the list cannot be read) the crate stays in DCS-native carry, flagged_awaitingHandOver, and the pilot is told how to fit a parachute. The F10 Fit parachute action (fitParachute, offered byrefreshCrateFlightSectionwhilecratesAwaitingHandOveris not empty and the aircraft is on the ground) asks DCS for the release, marks the crate in_convertingCratesso the release is not handled as a native unload, and completes the hand-over (_completeHandOver) once the cargo has left the list; with the doors closed the pilot is asked to open them and may use the action again. Opening the doors alone does nothing, so a crate can still be unloaded from the DCS cargo UI (ADR 0026). - Release: a native-carry crate that has left its transport's list is released (
OnCrateUnloaded,method = "dcs_native"):LANDEDwith the transport on the ground,FALLINGwithfromParachutewhenctld.utils.inAir(transport), then followed to the ground by_scheduleParachuteLandingPoll(ctld.utils.hasLanded). A native-carry crate whose transport no longer exists is reset to the ground state.
The spawn-time check that keeps a fresh crate out of a neighbouring aircraft's volume
(_getDynamicBBoxes) is unrelated to detection and unchanged.
The unpack pipeline: _spawnUnpacked¶
Every unpack outcome — ground vehicle, air JTAC, or static — is deployed through a single
three-step pipeline in _spawnUnpacked(desc, pos, coa, cId, playerName):
_spawnUnpacked(desc, pos, coa, cId, playerName)
├── spawnAs ≠ "GROUND"/"STATIC" (air) + JTAC_dropEnabled == false → skip
├── desc.isJTAC → CTLDJTACManager:_consumeJTACSlot(coa) ← quota gate (definitive)
│ └── limit reached → notify player + return (no spawn)
├── ctld.utils.buildGroupUnitDef(desc, pos, gname, gid, uid)
│ ├── spawnAs == "GROUND" → minimal {name, task, units[{x,y,heading}]}
│ └── spawnAs == "AIRPLANE" → full {groupId, units[{alt,speed}], route[orbit+EPLRS]}
│ (orbit + EPLRS embedded only when isJTAC = true)
├── ctld.utils.spawnFromDescriptor(desc, cId, unitDef)
│ ├── spawnAs == "STATIC" → coalition.addStaticObject
│ └── otherwise → coalition.addGroup(Group.Category[spawnAs])
└── _dispatchPostSpawn(desc, gname)
└── isJTAC = true → CTLDJTACManager:startLase(gname)
Ground spawns additionally publish OnGroundUnitSpawned. On success, when a playerName is
supplied, the vehicle spawner's load and pack submenus are refreshed.
Key rules:
coalition.addGroupandcoalition.addStaticObjectmust only be reached viactld.utils.spawnFromDescriptor— never called directly.ctld.utils.buildGroupUnitDefis the single builder for GROUND and AIR unit definitions. STATIC objects use a separate schema and go straight toaddStaticObject.- Post-spawn role activation belongs exclusively in
_dispatchPostSpawn. That method starts JTAC lasing forisJTACdescriptors and registers both JTAC and plain ground vehicles withCTLDVehicleSpawnerso the Load/Unload menu can track them. Do not add role logic elsewhere in the unpack path. - The JTAC quota (
JTAC_LIMIT_RED/JTAC_LIMIT_BLUE) is consumed before spawn, both here and in the Request Equipment path (spawnJTACFromDescriptor). The quota is definitive — it is never refilled when a JTAC is killed. Mission-maker JTACs and infantry JTAC soldiers do not consume it.
Descriptor fields driving the pipeline¶
| Field | Effect on pipeline |
|---|---|
spawnAs (string, default "GROUND") |
Selects the addGroup category or routes to addStaticObject |
isJTAC (boolean) |
Source of truth for the JTAC role. Adds the orbit route to an air unit definition and triggers startLase post-spawn. The unit type name (unit) is never used for JTAC detection in the OOP stack. |
specificParams (table) |
Ignored on a crate. Drone JTAC orbit geometry comes from the JTAC_droneAltitude / JTAC_droneRadiusNoLase / JTAC_droneRadiusOnLase / JTAC_droneSpeed settings. A crate still carrying the field produces a startup NOTICE naming the crates concerned. (Unrelated to specificParams.task on loadableGroups templates, which is live — see Troop + JTAC lifecycle.) |
cratesRequired (number) |
Guards unpack — the count must be met before the pipeline runs |
showSets (boolean, default true) |
When false, suppresses the auto-generated "All crates" singleTypeSet entry even if enableAllCrates is on |
JTAC detection rules (do not invert)¶
| Context | Rule |
|---|---|
| Request Equipment menu population | getJTACDescriptors(coalition) iterates _processedCrates and returns single-crate entries with isJTAC = true for the player's coalition (or side = nil) |
| Request Equipment spawn | CTLDVehicleSpawner:spawnJTACFromDescriptor(desc, spawner, zone) — quota check → ground: spawnVehicleForTransport + startLase; air: deployAirJTAC |
| Post-unpack activation | _dispatchPostSpawn: if desc.isJTAC → CTLDJTACManager:startLase() |
| Pre-placed MM group detection | Group name contains "jtac" (case-insensitive) — unit type not used |
| Troop deploy with JTAC soldier | tmpl.hasJtac == true (computed from jtac > 0) → startLase after deploy |
Do not add new JTAC detection paths. If a new unit type needs JTAC behaviour, set
isJTAC = trueon its crate descriptor — never add it to a type-name list.JTAC_unitTypeNameshas been removed: the crate catalogue is the single source of truth for both the crate menu and the Request Equipment menu.
F10 menu integration¶
buildMenuSection(playerObj, menu) builds the Request Equipment submenu (order = 25, between
Troop and Vehicle commands) and the Crate Commands submenu (order = 40), gated on the
player's capabilitiesByType[typeName].cratesEnabled. The dynamic submenus are rebuilt on demand:
refreshRequestEquipmentSectionrenders each category'ssingleCrates(each immediately followed by itssingleTypeSetwhen visible) then itsmixedSets, with per-player coalition/JTAC filtering and a stable order counter.refreshLoadCrateSectiongroups crates within 50 m by descriptor type with a count; loading picks the nearest matching crate and enforces the transport'smaxCratesOnboard.refreshUnpackSectionlists assembleable sets (count ≥cratesRequired) within 300 m, delegating AA sets toCTLDCrateAssemblyManager:tryUnpackOrRepairand scene crates toCTLDSceneManager:playScene(or a model's owncrate.unpack), and standard vehicles to_spawnUnpacked.refreshPackEquiptSectionshows the unified Pack Equipt submenu (FARP scenes and packable vehicles) only on the ground, whenenableFARPRepackorenablePackingVehiclesis set.
A ground-position poller (10 s) refreshes Request Equipment only when the player's logistic-zone set changes, avoiding a fixed-cadence rebuild that would eject players from open sub-menus. Crate spawn and clear events refresh the Load Crate and Unpack submenus for every transport within range.