Vehicle system¶
The vehicle system moves whole ground units by air: a transport picks up a drivable vehicle at a logistic zone, carries it, and sets it back down in the field — without ever breaking it into crates. It is the counterpart to the crate pipeline (see crates), and it also underpins ground-JTAC spawning (see Troop + JTAC lifecycle).
Source: src/CTLD_vehicle.lua
Entity: CTLDVehicle
Singleton: CTLDVehicleSpawner
CTLDVehicleSpawner follows the standard manager idiom (X = class() + getInstance() — see
Architecture): a single _instance, allocated lazily on first
CTLDVehicleSpawner.getInstance(). CTLDVehicle is a plain entity created with :new(data).
CTLDVehicle = class()
CTLDVehicleSpawner = class()
CTLDVehicleSpawner._instance = nil
function CTLDVehicleSpawner.getInstance()
if not CTLDVehicleSpawner._instance then
local o = setmetatable({}, CTLDVehicleSpawner)
o:init()
CTLDVehicleSpawner._instance = o
end
return CTLDVehicleSpawner._instance
end
At init() the singleton registers its F10 menu section (order = 30), subscribes to the DCS
S_EVENT_DEAD bridge, subscribes to several bus events, and starts three periodic timers
(native-load detection, packing-landing refresh, hover hint).
State machine¶
CTLDVehicle.STATE declares four states:
| State | Meaning |
|---|---|
WAITING |
On the ground, tracked, ready to be loaded |
LOADED |
Carried by a transport (DCS unit destroyed for menu_ctld, or physically linked for dcs_native) |
FALLING |
Released in flight by the DCS native cargo system: the unit stays alive and falls under its DCS parachute; back to WAITING once landed |
DELIVERED |
Declared but not currently assigned — reserved for a future dedicated delivered state |
The transitions actually performed by the current code are:
spawn / register loadVehicle
(none) ───────────────► WAITING ──────────────► LOADED
▲ │
│ unloadVehicle │
└────────────────────────┘
│ parachuteVehicle
└─── (drop, then respawn) ─► WAITING
Both unloadVehicle and parachuteVehicle return the vehicle to WAITING (not DELIVERED)
so it can be re-loaded once it is back on the ground. setState(newState) performs no
validation — callers own correctness.
Load and unload methods¶
Load and unload each carry a method string:
| Direction | Method | Trigger |
|---|---|---|
| Load | menu_ctld |
Player picks a vehicle from the Load / Extract Vehicles F10 submenu; the DCS unit is destroyed on load |
| Load | dcs_native |
The vehicle's CRG:<unit name> companion entry appears on a player aircraft's on-board cargo list (unit:getCargosOnBoard()); DCS keeps the unit physically linked |
| Unload | menu_ctld |
Player picks a vehicle from Unload Vehicles; the unit is respawned near the transport |
| Unload | dcs_native |
The entry leaves the list with the transport on the ground; DCS has already placed the unit, CTLD recovers the live reference (never respawned) |
| Unload | parachute |
Airborne drop: CTLD's virtual Parachute Vehicle (the unit is respawned at its landing position), or a native-carry vehicle whose entry leaves the list in flight (the unit is not respawned; state FALLING until it lands) |
The rule behind the two carry modes is that a cargo item is unloaded the way it was loaded: the F10
unload and parachute entries (findVirtualCarryVehicles) list virtual-carry vehicles only, and a native-carry
vehicle is released by DCS.
Where a WAITING vehicle comes from¶
A loadable CTLDVehicle in WAITING state is registered from one of these paths:
| Source | Mechanism |
|---|---|
| MM pre-placed vehicle | INIT-D scanMMVehicles() scans both coalitions' Group.Category.GROUND groups and calls _registerMMVehicleUnit(unit) for each live unit whose type has a CTLD crate descriptor (findDescriptorByUnitType) |
| Late activation | S_EVENT_BIRTH → onBirth defers one frame → _onBirthDeferred → _registerMMVehicleUnit (or re-attaches a live unit reference to an existing vehicle after unload) |
| Programmatic spawn | spawnVehicleForTransport(vehicleType, spawner, logisticZone) spawns a fresh unit near a transport and registers it WAITING |
| Crate unpack | spawnVehicleAt(spawnData, position) / registerJTACVehicle(...) register a unit produced by the crate/JTAC unpack flow |
_registerMMVehicleUnit is idempotent: it skips a unit already present in the
_unitToVehicle reverse-lookup map.
spawnVehicleForTransport¶
Spawns a drivable unit in the transport's front sector via ctld.utils.dynAdd, creates the
CTLDVehicle in WAITING, and publishes OnVehicleSpawnedForTransport. Group and unit names are
assigned as CTLD_VEH_<type>_<id> (with whitespace and slashes replaced by _), and preserved in
spawnData so the unit re-appears under the same name after any later unload. The JTAC ground path
(spawnJTACFromDescriptor) calls this, then CTLDJTACManager:startLase(...).
The Feature-Q "Request Equipment spawns a whole vehicle" branch exists in
CTLDCrateManager:refreshRequestEquipmentSectionbut is currently inert: the menu builder setsspawnAsVehicle = falsefor every entry, so Request Equipment (order 25) only ever spawns crates. Whole-vehicle loading is driven by the dedicated Vehicle Commands menu against vehicles that are alreadyWAITING.
Load pipeline — loadVehicle(vehicle, transport, player, method)¶
- Reject unless the vehicle is
WAITING. menu_ctldcapacity guard: readcaps.maxWholeVehiclesOnboard(default1) from the transport'scapabilitiesByTypeentry, countfindLoadedVehicles(transport), and refuse with a player message if the hold is full. (dcs_nativecapacity is left to DCS.)- If the vehicle is a registered JTAC, suspend lasing (
setJTACInTransit). menu_ctld: destroy the DCS unit, publishOnGroundUnitRemoved(reason = "loaded"), and clear the reverse lookup.dcs_native: keep the unit alive but drop it from the reverse lookup so a staleS_EVENT_DEADcannot misfire.- Record
loadMethod,loadTransportName,loadTime; set stateLOADED. menu_ctldonly: recompute the transport's internal cargo weight (_updateVehicleCargo) and send a confirmation message.- Publish
OnVehicleLoaded.
The maximum pick-up distance is not enforced in loadVehicle; it is applied upstream in
findLoadableVehicles (≤ maximumDistancePackableUnitsSearch, default 200 m).
Unload pipeline — unloadVehicle(vehicle, transport, player, method, rearSector)¶
- Reject unless the vehicle is
LOADED. - Compute a spawn position with
_computeSpawnPosition(transport, rearSector). - A virtual-carry vehicle (
loadMethod ~= "dcs_native") is respawned near the transport withctld.utils.dynAdd, reusingspawnData.groupName/spawnData.unitName, whatevermethodis published. A native-carry vehicle (loadMethod == "dcs_native") is never respawned: the live unit DCS kept is recovered viaGroup.getByName(spawnData.groupName). The mechanism follows how the vehicle was loaded, not the published reason. - Set state back to
WAITING(FALLINGfor a native-carry vehicle released withmethod = "parachute"); re-register the reverse lookup. - Virtual-carry: recompute cargo weight (a native-carry weight is left to DCS). Resume JTAC lasing if applicable (a falling vehicle resumes on landing).
menu_ctldonly: send a confirmation message.- Publish
OnVehicleUnloaded; for a vehicle released in flight, start the landing poll (_scheduleNativeLandingPoll).
The dev-guide draft described unload as re-spawning through
CTLDObjectRegistry.spawnObject; the current code respawns throughctld.utils.dynAddand does not use the object registry here.
Spawn positioning¶
_computeSpawnPosition(transport, rearSector) (public wrapper computeSafeDropPos) places the
unit in a ±45° sector of the transport heading — the front sector by default, or the rear
sector (hdg + π) when rearSector is true, which keeps the drop clear of an AI helicopter's
takeoff path. The radial offset comes from _secureOffset, the bounding-box diagonal
sqrt(halfLen² + halfWid²) × 2 + 10 m, falling back to 60 m when getDesc().box is unavailable.
DCS-native whole-vehicle loading and release — _checkNativeLoading (1 s tick)¶
A native-carry vehicle is read from the list DCS reports on board the aircraft, not from geometry (ADR 0022). Each tick:
- Collect
WAITINGvehicles with live units, andLOADEDvehicles whoseloadMethod == "dcs_native". With neither, the tick returns before reading any list. - Candidates are the player units (
CTLDPlayerManager._players; an AI-flown aircraft is never scanned) of any category whosecapabilitiesByTypeentry setsuseNativeDcsCargoSystemandcanTransportWholeVehicle. Their list is read withctld.utils.getOnBoardCargo(unit). If it cannot be read, one warning is logged for the type and the type is no longer watched (_unreadableCargoTypes); there is no geometric fallback. - Entry: a whole vehicle shows up as a companion entry named
CRG:<unit name>. When it matches aWAITINGvehicle, callloadVehicle(veh, transport, nil, "dcs_native"). No position, ground, speed or coalition test applies: DCS decides what it accepts. An entry that is not a waiting vehicle (cargo created with the loadmaster tablet, an unknown editor crate) is ignored with one debug trace (_ignoredCargoNames). - Release: a native-carry vehicle whose entry has left its transport's list is released through
unloadVehicle:method = "dcs_native"with the transport on the ground (WAITINGagain at once), ormethod = "parachute"whenctld.utils.inAir(transport)(stateFALLING)._scheduleNativeLandingPollthen follows the unit (alive all the way down) untilctld.utils.hasLandedsays it is on the ground (the criterion the parachuted crates use), and resumes its JTAC. A unit that is gone is dropped like any lost vehicle (_dropLostVehicle: JTAC deregistered,OnVehicleDead). - Transport gone (slot change, despawn: no death event): a native-carry vehicle whose recorded transport no
longer exists gets the same
_dropLostVehiclehandling as a destroyed transport, once. A transport whose list was not read this tick releases nothing. A vehicle alreadyFALLINGis not affected.
A released vehicle cannot be reloaded by CTLD: it is loaded again only by reappearing on the list.
Loadable filtering — findLoadableVehicles / _isTypeLoadable¶
findLoadableVehicles(transport) returns the WAITING vehicles a given transport may load,
applying three filters on top of the capability gate:
- The transport's
capabilitiesByTypeentry must setcanTransportWholeVehicle, else{}. - Coalition (GAP-Q1):
veh.spawnData.coalitionIdmust equal the transport coalition. - Type (GAP-Q2):
_isTypeLoadablerequiresveh.vehicleTypeto appear in the transport'sloadableVehiclesRED(coalition 1) orloadableVehiclesBLUE(coalition 2) list. - Distance: ≤
maximumDistancePackableUnitsSearch.
Vehicle unit references are resolved lazily inside the loop (Group.getByName can lag one frame
behind coalition.addGroup).
Transport capabilities¶
Whole-vehicle behaviour is driven by the transport's capabilitiesByType entry
(ctld.gs("capabilitiesByType")), plus the per-player canCarryVehicles flag:
| Field | Effect |
|---|---|
canTransportWholeVehicle |
Enables whole-vehicle load/unload for this type |
maxWholeVehiclesOnboard |
Simultaneous whole-vehicle capacity (0 = none) |
loadableVehiclesRED / loadableVehiclesBLUE |
DCS type names this aircraft may load, per coalition |
canParachuteDrop |
Enables the Parachute Vehicle entry |
useNativeDcsCargoSystem |
Marks the type as a native-cargo carrier |
Packing a vehicle back into crates — packVehicle¶
findPackableVehicles(transport) lists CTLD-managed WAITING vehicles within
maximumDistancePackableUnitsSearch (only tracked vehicles — never arbitrary scene props).
packVehicle(transportUnitName, packableUnitName, playerObj):
- Resolve the descriptor for the unit type (
findDescriptorByUnitType); refuse if none. - Silently deregister any JTAC on the unit, then
destroy()it and publishOnGroundUnitRemoved(reason = "packed"). - Spawn
descriptor.cratesRequiredcrates viaCTLDCrateManager:spawnCratesAligned(...)withCTLDCrate.SPAWN_METHOD.VEHICLE_PACK, in the front sector (helicopter) or rear sector (native-cargo aircraft). - Publish
OnVehiclePacked, then rebuild the player menu one frame later (to dodge the one-framecoalition.getGroups()lag afterdestroy()).
The landing-triggered pack-menu refresh (_checkPackingLanding, 3 s tick) is gated on
ctld.gs("enablePackingVehicles").
Parachute drop — parachuteVehicle¶
Requires caps.canParachuteDrop. Checks AGL against parachuteMinAltitudeVehicles (default 30 m)
and refuses if too low. Resolves the vehicle (explicit id, else the first vehicle loaded on that
transport), computes the landing point with ctld.utils.calcDropPosition using
parachuteDescentRateVehicles (default 8 m/s), sets the vehicle back to WAITING, publishes
OnVehicleParachuting, and after the descent delay respawns it at the landing position and
publishes OnVehicleParachuteLanded (resuming JTAC lasing if applicable).
Cargo weight¶
getLoadedVehicleWeight(transportUnitName) sums ctld.gs("groundVehicleWeights") for LOADED,
menu_ctld vehicles only (default 2500 kg per unknown type); dcs_native weight is left to DCS.
_updateVehicleCargo delegates to ctld.utils.updateTransportWeight, which aggregates troops,
crates, and vehicles into a single setUnitInternalCargo call.
F10 menu — buildMenuSection¶
Added only when the player unit has canCarryVehicles. It creates Vehicle Commands
(order = 30) with three dynamic branches:
- Load / Extract Vehicles — rebuilt by
refreshLoadSection; disabled while airborne; lists the result offindLoadableVehicles. Selecting an entry callsloadVehicle(..., "menu_ctld"). - Unload Vehicles — rebuilt by
refreshUnloadSection; hidden when nothing is loaded, shows "Land to unload vehicles" while airborne, otherwise lists loaded vehicles. - Parachute Vehicle — present only when
caps.canParachuteDrop; enabled only when airborne with a vehicle loaded.
Refreshes are driven by the landing detector (_checkPackingLanding), the ground-unit
spawn/removal bus events (OnGroundUnitSpawned / OnGroundUnitRemoved), and the load/unload
events themselves. A hover-hint timer (_checkVehicleHoverHint, 5 s tick, 30 s per-player
cooldown) nudges a hovering carrier to land over a nearby WAITING vehicle.
DCS event handling¶
onDead(S_EVENT_DEAD) — if the dead unit is a tracked vehicle, remove it and publishOnVehicleDead+OnGroundUnitRemoved. Otherwise, if it is a transport that was carryingLOADEDvehicles, each carried vehicle is considered lost: its JTAC is deregistered andOnVehicleDeadis published for it.onBirth(S_EVENT_BIRTH) — see the late-activation source above.
Events published¶
| Event | When |
|---|---|
OnVehicleSpawnedForTransport |
spawnVehicleForTransport created a WAITING vehicle |
OnVehicleLoaded |
Vehicle loaded into a transport |
OnVehicleUnloaded |
Vehicle unloaded / dropped from a transport |
OnVehicleDead |
Tracked vehicle destroyed, or lost with its transport |
OnVehiclePacked |
Vehicle packed back into crates |
OnVehicleParachuting |
Parachute drop started |
OnVehicleParachuteLanded |
Parachuted vehicle reached the ground |
OnGroundUnitSpawned / OnGroundUnitRemoved |
A tracked ground unit appeared / disappeared (drives menu refresh) |
See Events for the full bus catalogue and payload shapes.