Skip to content

Architecture

Repository structure

src/                    Source modules (pure Lua 5.1, one class per file)
  core/                 Foundations: class.lua, CTLD_objectRegistry, CTLD_modValidator,
                        CTLDParachuteEffect
  scenes/               Scene data files (auto-registered at load time)
  legacy/               Legacy v1 API wrappers (thin delegates, deprecated)
  CTLD_*.lua            Domain managers (config, i18n, utils, menu, zone, troop, crate,
                        vehicle, fob, aasystem, beacon, recon, jtac, player, core…)
  CTLD_bootstrap.lua    Engine bootstrap — ctld.initialize() + auto-start guard (merged last)
tools/
  build/                Build tooling: merge_CTLD.ps1, listToMerge.txt, generate_i18n_dicts.ps1
tests/
  ci/                   busted tests (no DCS required)
    helpers/            DCS stubs + module loader (init.lua, loader.lua)
    unit/               *_spec.lua unit tests
    functional/         *_spec.lua functional tests
  dcs/                  DCS integration-test scenarios (require a live mission)
docs/                   Published documentation (this site)
assets/                 Runtime audio (beacon.ogg)
missions/               Demo and test .miz files
migration/
  source/               Reference — original monolithic v1 CTLD.lua (read-only, immutable)
CTLD.lua                Generated deliverable — never hand-edit (rebuilt from src/)

Manager / singleton idiom

Classes are built with the minimal OOP micro-framework in src/core/class.lua:

MyClass = class()            -- create a class
Child   = class(MyClass)     -- subclass (single inheritance)

function MyClass:init(...) end   -- constructor, called by :new()
local obj = MyClass:new(...)     -- allocate instance + run init()

class(base) sets the class table as its own __index, so instance method lookups fall through to the class; passing a base chains __index to the parent.

Domain managers are singletons. They declare _instance and expose a getInstance() factory that bypasses :new() and calls init() on first access:

CTLDZoneManager = class()
CTLDZoneManager._instance = nil

function CTLDZoneManager.getInstance()
    if not CTLDZoneManager._instance then
        local o = setmetatable({}, CTLDZoneManager)
        o:init()
        CTLDZoneManager._instance = o
    end
    return CTLDZoneManager._instance
end

Configuration is read-only via ctld.gs("paramName") — never config:getSetting().

CTLDCoreManager init sequence

CTLDCoreManager:init() runs once at mission start and executes these phases in order:

Phase Method Description
INIT-B _initMMCrates() Scan coalition statics for MM-placed cargo objects
INIT-C _initMMJTACs() Scan coalition groups for MM-placed JTAC groups
INIT-D CTLDVehicleSpawner:scanMMVehicles() Scan coalition ground groups for MM-placed vehicles
INIT-E _initExtractableGroups() Register extractableGroups names + EXTR_-named groups into CTLDTroopManager._droppedGroups
INIT-A _initAITransports() Build AI team lists and start the auto-pickup/dropoff loop

INIT-E detail: reads ctld.gs("extractableGroups"), calls Group.getByName() for each entry, and inserts the group name into CTLDTroopManager._droppedGroups[coalition]. Groups not found are logged as WARN and skipped. Then scans coalition.getGroups(side) on RED/BLUE/NEUTRAL for a group whose name matches the anchored prefix EXTR_ — a Mission Editor naming convention needing no config entry — skipping any name already registered by the list above (deduplicated union). Both passes are init-only: there is no late activation (iso-legacy for the list; deliberate for EXTR_, unlike _initMMJTACs' markPendingJTAC/onBirth mechanism) and no _droppedTemplates entry — embarkFromField uses a 130 kg/unit fallback.

Adding a new module

  1. Create src/CTLD_mymodule.lua using the class/singleton idiom above (CTLDMyManager = class(), _instance, getInstance()).
  2. Add the filename to tools/build/listToMerge.txt in dependency order (foundations first, then domain managers, then scenes, then CTLD_core.lua, then legacy/, CTLD_bootstrap.lua last).
  3. Add the same dofile entry to tests/ci/helpers/loader.lua.
  4. Write busted specs in tests/ci/unit/mymodule_spec.lua (test-first — see Building & testing).

The merge order in listToMerge.txt is authoritative: scenes are listed after all managers so that model.crate auto-injection resolves, and CTLD_core.lua (the orchestrator) comes after the scenes it instantiates.

Internal libraries

core/class.lua — OOP base

The single-inheritance class system used throughout CTLD (see the idiom above). class() creates a class whose __index is itself; class(base) chains to a parent.

core/CTLD_objectRegistry.lua — spawn descriptor store

CTLDObjectRegistry is a static registry mapping template keys to DCS group/unit spawn descriptors. It manages descriptors, not instances.

CTLDObjectRegistry.register(key, descriptor)    -- add a template
CTLDObjectRegistry.spawnObject(key, coa, country, x, z, hdg, opts)
    -- → DCS Group object | nil

Scenes register their component descriptors at dofile time; troop and vehicle templates are registered by their managers at init.

Asset validation — design-time, not a runtime probe

CTLD does not probe-spawn objects to check DCS type presence. Type names are validated at dev/CI time against a vendored datamine stock-type set (tests/data/dcs_types.lua):

  • CTLDTypeCollector (core/CTLD_typeCollector.lua) is the single source of truth for which DCS types a mission configures — registry STATIC (desc.type) and GROUND (desc.units[].unitType(coalitionId)), spawnableCrates, AA templates, loadableGroups — and which are declared non-stock (mod) types: scene model.modTypes ∪ the modTypes config setting.
  • The busted gates (scene_asset_gate_spec — hard fail; config_types_lint_spec — lenient report) consume it to catch types that are neither stock nor declared.
  • The optional asset-check companion (tools/companion/, built to dist/CTLD_asset_check.lua) is the dev-time runtime equivalent: a mission maker loads it after CTLD during development and it WARNs on unknown configured types — a pure table lookup, no spawning, no events.

A scene or mission declares its mod types so validation stays strict on everything else:

someScene.modTypes = { "Some_Mod_Type" }   -- on a scene model
advanced:
  modTypes:                                # for a mission's own crate/troop/AA config
  - Some_Mod_Type

Historical note (ADR 0007): earlier versions probe-spawned a static/group per type at mission start (CTLD_modValidator) and could not probe heliport mods at all — getDesc().life == 0 whether the mod was installed or not. That probe was removed: it wasted resources and fired spurious S_EVENT_BIRTH/destroy events that custom mission handlers observed.

core/CTLDParachuteEffect.lua

Visual parachute effect helper used when dropping crates/troops from altitude.

CTLD_utils.lua — utility functions

Key functions available as ctld.utils.*:

Function Purpose
log(level, fmt, ...) Write to CTLD.log (levels: DEBUG, INFO, WARN, ERROR)
getDistance(caller, p1, p2) 2D ground distance between two {x,z} points
getHeadingInRadians(caller, unit, magnetic) Unit heading in radians
inAir(unit) True if unit is airborne (AGL + velocity guard)
getNextMarkId() Allocate the next unique DCS mark ID from ctld._markIdCounter
getNextUniqId() Allocate the next unique entity ID from ctld.utils.UniqIdCounter
drawQuad(coalitionId, pts, msg) Draw a 4-point polygon on the F10 map
buildWP(caller, pt, type, speed) Build a DCS waypoint table
getSecureDistanceFromUnit(unitName) Minimum spawn clearance radius from a unit's bbox
dynAddStatic(coalitionId, data) coalition.addStaticObject wrapper with country resolution

ctld.scheduler — the only way to schedule a timer

Every timer CTLD schedules goes through ctld.scheduler.schedule(fn, arg, t), a one-for-one replacement of timer.scheduleFunction that records the id of each pending timer. Never call timer.scheduleFunction directly in src/: a busted guard spec (scheduler_guard_spec.lua) fails if you do, because a timer the registry does not know about survives a shutdown and, waking up through getInstance(), runs on top of the next instance (a hover poll at twice its rate, doubled smoke, recon and JTAC lasing). The exit is guarded the same way: cancel a timer by id with ctld.scheduler.remove(id), never timer.removeFunction, or its id stays pending for the rest of the mission and cancelAll() keeps cancelling it.

Function Purpose
schedule(fn, arg, t) Schedule fn like DCS does; returns the function id. A numeric return value reschedules the same id (it stays pending), anything else ends the chain
remove(id) Cancel one timer by id and forget it; a nil id, or one DCS no longer knows, is ignored
register(name, id) Name a loop; registering the same name again cancels the previous id (double-start guard)
cancel(name) Cancel one named loop
cancelAll() Cancel every pending timer, named or not, and log how many (inject tests/dcs/util/shutdown_ctld.lua)

cancelAll() stops every CTLD loop of the live mission: reload the mission (Shift+R) afterwards.