F10 menu system¶
The F10 radio menu is CTLD's primary in-mission interface: every action a transport pilot
performs — loading crates, embarking troops, requesting equipment, toggling JTAC lasing — is
issued from it. This page documents the two-layer menu engine (ctld.Menu and
ctld.MenuManager), how CTLDPlayerManager assembles the per-player menu from sections
contributed by each manager, how the tree is refreshed against changing flight and cargo state,
and the full menu tree as it is rendered in game.
Source: src/CTLD_menu.lua (the engine), src/CTLD_player.lua (ownership and section
orchestration).
Two-layer architecture¶
The design separates what the menu contains from how DCS renders it. The DCS
missionCommands.* API is fragile — there is no way to query the current menu, no partial
update, and no automatic pagination — so CTLD keeps an authoritative in-memory tree and treats
every DCS call as a rendering side-effect of that tree.
| Layer | Class | Responsibility |
|---|---|---|
| Logical model | ctld.Menu |
In-memory tree for one group's menu. Unlimited depth; pagination is transparent to callers. One ctld.Menu per groupId. |
| DCS renderer | ctld.MenuManager |
Singleton owning every ctld.Menu. On refresh(), recreates only the CTLD entries that changed and parks every freed DCS id; sorts siblings by order; paginates. |
| Orchestrator | CTLDPlayerManager |
Owns the F10 menu lifecycle: builds it on player-enter, assembles it from registered sections, and drives refreshes on flight/cargo state changes. |
CTLDPlayerManager follows the standard manager idiom (CTLDPlayerManager = class() +
getInstance(); see Architecture). The menu engine does not use
class(): ctld.MenuManager is a hand-rolled singleton (ctld.MenuManager = ctld.MenuManager or {},
_instance, getInstance() calling self:_new()), and ctld.Menu instances are plain tables
with a metatable created by ctld.Menu:_new(groupId, manager).
CTLDPlayerManager ctld.MenuManager
_menuSections[] → menus{} (groupId → ctld.Menu)
registerMenuSection() refreshMenuForGroup() / deferredRefreshForGroup()
buildMenu(playerObj) _renderLevel() / _applyLevel() / teardownGroup()
refreshForUnit(unitName) _sortByOrder()
ctld.Menu
addSubMenu / addCommand / clearBranch
setBranchEnabled / removeMenuBranch
_getNode(pathTable) / refresh()
The menu model — ctld.Menu¶
A ctld.Menu is a tree of nodes. Callers manipulate this tree exclusively; they never call
missionCommands.* directly. There are two node types plus the root menu object itself.
-- Root menu (the ctld.Menu instance)
{
groupId = number, -- DCS group ID this menu belongs to
groupName = "string", -- resolved once at creation, for name-based lookup
children = { ... }, -- root-level nodes
_lookup = { ... }, -- "path.string" → node reference (fast access)
manager = reference, -- back-reference to ctld.MenuManager
nextItemId = number, -- counter for unique node ids
}
-- Submenu node
{
id = "sub_123",
name = "string",
type = "submenu",
children = { ... },
order = number|nil, -- position among siblings (see Order convention)
enabled = boolean, -- DCS visibility (see Enabled convention), default true
}
-- Command node
{
id = "cmd_456",
name = "string",
type = "command",
functionToCall = function,
anyArgument = table, -- passed as-is to the callback; defaults to {}
order = number|nil,
enabled = boolean, -- default true
}
Order convention¶
Every node may carry an optional order field (a number). Before rendering, siblings are sorted
by order ascending, independent of insertion order or manager initialisation sequence.
Because each manager registers its section independently, without explicit order the visual
position of a submenu would depend on the fragile manager init order. Explicit order makes
positions declarative and stable. Recommended spacing is multiples of 10 (10, 20, 30 …) to leave
room for future insertions. Nodes without an order value are appended last (math.huge); ties
preserve insertion order (stable sort).
Enabled convention¶
Every node carries an enabled field (boolean, default true). A disabled node
(enabled == false) is invisible in DCS but remains in the memory tree, preserving its order
slot — re-enabling it returns it to the exact same F-key position it would have occupied if
always visible. Toggle it with setBranchEnabled(path, bool) followed by refresh(). This is how
features are unlocked at runtime (e.g. a mission trigger enabling FOB building mid-mission)
without disturbing sibling slots.
_lookup table¶
The _lookup table maps a dotted path string to a node reference, giving O(1) path resolution
instead of O(depth × siblings) tree traversal:
_lookup = {
["CTLD"] = <ref>,
["CTLD.Troop Commands"] = <ref>,
["CTLD.Troop Commands.Infantry"] = <ref>,
}
It is maintained automatically by addSubMenu / addCommand and pruned by clearBranch,
removeMenuBranch, and (indirectly) buildMenu when it resets the model.
Rendering — ctld.MenuManager¶
Rendering by difference¶
Added by FIX-MENU-STABLE-ENTRIES — see ADR 0027, which supersedes ADR 0015.
DCS tracks each F10 entry by an internal id drawn from one pool for the whole server, and gives a removed entry's id to the next entry created, for any group, last freed first reused (measured in game, #257). A player's screen keeps the ids it was drawn with: a click on a screen left open runs whatever holds that id now. So a refresh must never recreate an entry that did not change, and must not let a freed id reach a real command.
refreshMenuForGroup therefore compares two trees and touches only the difference:
refreshMenuForGroup(groupId)
1. Validate a menu exists in memory for groupId
2. Build the rendered tree: enabled filter, `order` sort, pagination (submenus only),
each entry keyed on parent key + submenu/command + label (+ "#n" for the n-th twin)
3. For each level, against the mirror of what is live (menu._rendered):
keep the longest run of live entries already in the wanted order;
remove every other live entry (a submenu's children first), parking each freed id;
create the missing entries in order, then recurse into each submenu
4. Return { success = true, refreshedCount = N } -- N = top-level entries rendered
- Parking. Right after each
removeItemForGroup, the manager creates an inert command for group999999(no player slot holds it). It takes the freed id, so a stale click on the removed entry runs that command, which only logs at INFO. - Order. DCS appends a created entry after its siblings, so an insertion recreates the siblings
that follow it: the declared
orderholds and a re-enabled node returns to its slot. - One dispatcher. Every command is handed
ctld.MenuManager._dispatchwith{ groupId, key }; the click runs the node rendered under that key at the last refresh. A new callback or argument under an unchanged label needs no recreation. - Handles. The mirror keeps the handle each add returned:
removeItemForGrouphonours it and ignores a label path rebuilt by the caller. It removes only CTLD's own entries — it never passesnil, which would also destroy standard DCS entries such as Ground Crew and ATC. - Teardown. When a group's last crew member leaves,
teardownGroup(groupId)removes every live entry (each id parked), drops the menu and its mirror, and cancels any pending refresh: DCS reuses a numeric group id for the next occupant of a slot (#152).
A refresh that changes nothing makes no DCS call.
Debounced refresh¶
ctld.Menu:refresh() does not render immediately — it calls
ctld.MenuManager:deferredRefreshForGroup(groupId), which coalesces all refresh requests for a
group within a DEBOUNCE_S = 0.15 s window into a single refreshMenuForGroup (scheduled via
timer.scheduleFunction). The initial buildMenu on player-enter also routes through the
debounce. refreshMenuForGroup remains available for a direct, non-debounced refresh.
ADR 0015's ambient path (wipe at once, rebuild 4 s later) and its urgency detection (runUrgent,
{ urgent = true }) are gone: the wipe freed the ids and the rebuild handed them out again. The
pcall + log runUrgent gave its call sites stays, as ctld.utils.protectedCall.
Pagination¶
DCS gives each menu level ten programmer-controlled slots (F1–F10; F11 is the DCS "Previous Page",
F12 is "Quit"). _renderPage applies this rule to the pre-sorted, enabled-only children of every
submenu (the root level is not paginated):
| Visible children | Rendering |
|---|---|
| ≤ 10 | All rendered on one page (no pagination). |
| > 10 | F1–F9 render the first nine items; F10 = a → Next Page submenu; the remainder recurse inside it. |
The → Next Page node exists only at render time — it is never part of the memory model — and
its label is translated via ctld.tr("→ Next Page"). The last page always receives ≤ 10 items.
Because pagination is recomputed on every refresh, there is no cached page state to invalidate and
nesting depth is unlimited.
Example — a submenu of 15 vehicles:
F10 → Vehicles → (page 1):
F1 vehicle_1 … F9 vehicle_9 F10 → Next Page
F10 → Vehicles → → Next Page:
F1 vehicle_10 … F6 vehicle_15
(F11 Previous Page — provided by DCS)
Callback wrapping¶
When a command is rendered, its user callback is wrapped in a closure that isolates failures with
pcall, so one broken callback cannot crash the whole menu render:
local wrapped = function()
local ok, err = pcall(node.functionToCall, node.anyArgument or {})
if not ok then
ctld.logError("ctld.MenuManager: callback failed for '%s': %s", node.name, tostring(err))
end
end
missionCommands.addCommandForGroup(groupId, node.name, dcsPath, wrapped, {})
The callback receives exactly the anyArgument table supplied at addCommand time (defaulting to
{}); it is not augmented with the group id. Callers that need the group encode it in
anyArgument themselves.
ctld.Menu API¶
All mutating methods return a status table { success = bool, message = string, ... } rather than
raising errors (Lua 5.1 has no exceptions; explicit returns are more robust in the DCS
single-threaded environment).
| Method | Purpose |
|---|---|
addSubMenu(pathTable, menuName, opts) |
Add a submenu at pathTable ({} = root). opts.order / opts.enabled set position and initial visibility. Idempotent: if the submenu already exists it is returned unchanged (with order/enabled updated if supplied). Returns { success, message, subMenuId }. |
addCommand(pathTable, commandName, functionToCall, anyArgument, opts) |
Add an executable command. anyArgument must be a table (or nil → {}). opts.order sets position. Returns { success, message, commandId }. |
clearBranch(pathTable) |
Empty a submenu's children without removing the container, so its order slot does not shift. The standard idiom for dynamic proximity lists (nearby crates, loadable vehicles). |
setBranchEnabled(pathTable, enabled) |
Toggle a branch's DCS visibility while keeping it (and its order slot) in memory. Follow with refresh(). |
removeMenuBranch(pathTable) |
Permanently remove a branch and its descendants. Returns { success, message, removedCount }. Reserved for truly permanent removals — prefer clearBranch / setBranchEnabled. |
refresh() |
Convenience shortcut to manager:deferredRefreshForGroup(self.groupId) — debounced; only the entries that changed are recreated. |
Common failure messages: "Path not found: …" (create the parent submenu first),
"Cannot add submenu/command under a command node", "Invalid menu name",
"Invalid function reference", "Cannot remove root menu".
The dynamic-refresh idiom, used throughout the crate/vehicle managers:
local menu = ctld.MenuManager:getInstance():getMenuByGroupId(groupId)
menu:clearBranch({ "CTLD Commands", "Pack Vehicles" }) -- keep the container, drop its contents
for _, v in ipairs(nearbyVehicles) do
menu:addCommand({ "CTLD Commands", "Pack Vehicles" }, v.name, packFn, { unitName = v.name })
end
menu:refresh() -- debounced; unchanged entries keep their DCS id
ctld.MenuManager API¶
| Method | Purpose |
|---|---|
getInstance() |
Return (create on first call) the singleton. |
createMenuForGroup(groupId) |
Create an empty ctld.Menu for a numeric groupId. Idempotent: returns the existing menu if one is already registered. Returns the menu, or nil on an invalid id. |
refreshMenuForGroup(groupId) |
Immediate, non-debounced refresh by difference of the group's CTLD entries. Returns { success, message, refreshedCount }. |
teardownGroup(groupId) |
Remove every live CTLD entry of the group (each id parked), drop its menu and mirror, cancel any pending refresh. Called when the last crew member leaves. |
deferredRefreshForGroup(groupId) |
Schedule a debounced (0.15 s) refresh; coalesces bursts. |
getMenuByGroupId(groupId) |
Menu for a numeric group id, or nil. |
getMenuByGroupName(groupName) |
Menu whose group name matches, or nil. |
getMenuByUnitName(unitName) |
Menu of the group containing that unit (Unit.getByName → group), or nil. |
getMenuByUnitId(unitId) |
Menu of the group containing that unit (Unit.getByID → group), or nil. |
Group-name resolution (_getGroupName) iterates the three coalitions and matches on
group:getID(), because Group.getByID() does not exist in the DCS scripting API — only
Group.getByName() is available.
Section registration — CTLDPlayerManager¶
The root CTLD submenu and the Check Cargo command are added directly by CTLDPlayerManager.
Every other top-level section is contributed by the manager that owns it. Each manager registers
its section once, at its own getInstance() time:
-- e.g. from a crate/troop/jtac manager
CTLDPlayerManager.getInstance():registerMenuSection({
key = "crates", -- unique id; duplicate keys are ignored (idempotent)
manager = _cmInstance, -- the manager instance
method = "buildMenuSection", -- called as manager:method(playerObj, menu)
configKey = "enableCrates", -- ctld.gs(configKey) must be true; nil = always active
order = 40, -- lower = higher in the menu
refreshMethod = "refreshMenuSection", -- optional; called on land/takeoff (see below)
})
A sectionDef carries: key, manager, method, optional configKey, optional order, and an
optional refreshMethod. Registration is idempotent by key.
Pre-init registration. Scene files can be loaded before CTLDPlayerManager.getInstance() runs.
For that case, CTLDPlayerManager.deferMenuSection(sectionDef) queues a section at module
top-level; the queue is flushed into _menuSections during init(). The mine-field scene uses
this path.
buildMenu(playerObj)¶
Called on player-enter (and re-runnable). It:
- Gets/creates the group's
ctld.Menuviactld.MenuManager:getInstance(). - Resets the memory model (
children,_lookup,nextItemId) so sections do not accumulate on successive calls. - Adds the root submenu
ctld.tr("CTLD")atorder = 10. - Adds the
Check Cargocommand under the root — it tallies loaded crates, in-transit troops, and whole vehicles for the transport, thenoutTextForGroup. - Iterates the registered sections sorted by
order; for each, ifconfigKeyis nil orctld.gs(configKey) == true, callsmanager:method(playerObj, menu)to build that section's subtree. - Calls
menu:refresh()for a single debounced render.
Capability gating. Player capabilities are detected in _detectCapabilities from
ctld.gs("capabilitiesByType")[typeName]: a transport is any type with an entry;
canCarryVehicles is set when that entry's canTransportWholeVehicle == true. Managers use these
flags (and their own config/proximity checks) to decide which items to render. When
addPlayerAircraftByType == false, units whose name is not listed in transportPilotNames
are built with isTransport = false whatever their type: they keep a CTLD menu, holding only
the sections that do not require a transport (recon, smoke, List Beacons, JTAC Status,
List active FOBs).
refreshForUnit(unitName) and refreshAll() trigger a debounced refresh for one / every tracked
player.
Flight-state refresh¶
Items whose availability depends on being airborne vs on the ground (e.g. Release Slingload only
in the air, Load Crate only on the ground) are toggled via setBranchEnabled / section rebuilds
rather than by re-registering DCS commands (which would leak orphaned menu items). Two mechanisms
drive this:
- Flight-state poller (0.5 s cadence).
S_EVENT_TAKEOFF/S_EVENT_LANDfire with a 3–5 s delay for helicopters in DCS. A poller checksctld.utils.inAir(unit)every 0.5 s and, once a new state holds forDEBOUNCE_TICKS = 2consecutive polls (~1 s), commits it and runs the full refresh chain — so menus switch within ~1 s of the real transition. TheonTakeoff/onLandhandlers still run when the DCS events eventually fire, but by then the state already matches and the refresh is a fast no-op. - Event/handler refreshes. On land/takeoff (and on cargo events via the
event bus —
OnVehicleLoaded,OnVehicleUnloaded,OnCrateLoaded,OnFOBDeployed),CTLDPlayerManagercalls each relevant manager's refresh method (refreshMenuSection,refreshRequestEquipmentSection,refreshCrateFlightSection,refreshLoadSection,refreshUnloadSection,refreshParachuteVehicleSection,refreshJtacEquipmentSection, …). Sections that registered arefreshMethodare invoked generically throughmanager:refreshMethod(playerObj).
| Trigger | Managers/sections refreshed |
|---|---|
S_EVENT_LAND / poller → ground |
Troop, Crate (Request Equipment, Load Crate, Unpack, flight section), Vehicle (load/unload/parachute), JTAC, plus every section's refreshMethod |
S_EVENT_TAKEOFF / poller → air |
Same set — toggles SOL/AIR branch visibility |
| Vehicle spawn/despawn | Vehicle: load section + pack section |
| Crate load/unload | Crate: flight section; Unpack section on load |
| Troop embark/disembark | Troop: menu section |
| JTAC spawn/dead | JTAC: per-JTAC command branch, for all coalition players |
The F10 menu tree¶
The tree below is the full CTLD menu as rendered in game. Top-level order values determine F-key positions; per-section visibility depends on config gates, capability gates, and proximity/state.
CTLD (root, order=10)
├── Check Cargo (command — added by CTLDPlayerManager)
├── Troop Commands (submenu, order=20)
├── Request Equipment (submenu, order=25)
├── Vehicle Commands (submenu, order=30)
├── Crate Commands (submenu, order=40)
├── FOB (List active FOBs) (submenu, order=60) ⚠ shares order with Radio Beacons
├── Radio Beacons (submenu, order=60) ⚠ shares order with FOB
├── RECON (submenu, order=70)
├── Mine Field (submenu, order=75) — conditional, ground only
├── Smoke (submenu, order=80)
└── JTAC (submenu, order=90)
order=60conflict. FOB (CTLD_fob.lua) and Radio Beacons (CTLD_beacon.lua) both useorder=60, so the render order between the two depends on manager init sequence. This is a known gap — one should be renumbered (e.g. FOB →order=55).
Section registration table¶
| Section | Manager | Order | Config gate | Capability / state gate |
|---|---|---|---|---|
| Check Cargo | CTLDPlayerManager |
— | — | — |
| Troop Commands | CTLDTroopManager |
20 | — | troopsEnabled = true |
| Request Equipment | CTLDCrateManager |
25 | — | cratesEnabled = true |
| Vehicle Commands | CTLDVehicleSpawner |
30 | — | canCarryVehicles = true |
| Crate Commands | CTLDCrateManager |
40 | — | cratesEnabled = true |
| FOB (List FOBs) | CTLDFOBManager |
60 ⚠ | enabledFOBBuilding |
— |
| Radio Beacons | CTLDBeaconManager |
60 ⚠ | enabledRadioBeaconDrop |
isTransport = true |
| RECON | CTLDReconManager |
70 | reconF10Menu |
— |
| Mine Field | mineFieldScene |
75 | — | ground + mine fields nearby |
| Smoke | CTLDCrateManager |
80 | enableSmokeDrop |
isTransport = true |
| JTAC | CTLDJTACManager |
90 | JTAC_jtacStatusF10 |
— |
Troop Commands (order=20)¶
Manager CTLDTroopManager (src/CTLD_troop.lua). Sub-items are gated by whether the transport is
on the ground (SOL) or airborne (AIR), whether troops are carried, and per-template conditions
(a template is offered only if tmpl.total <= transportLimit, stock is available, and the side
matches). Embark / Extract paginates at 10 items/page.
Troop Commands
├── Disembark Troops [SOL — if hasTroops]
│ ├── (direct command if 1 group)
│ └── Disembark Troops (submenu if 2+ groups)
│ ├── Disembark All
│ ├── [1] TemplateNameA
│ └── [2] TemplateNameB
├── Embark / Extract Troops [SOL — if TRZ zones or field groups nearby]
│ ├── Load from <zone registry key>
│ │ ├── Load Template-Infantry
│ │ └── Load Template-Mixed
│ └── Extract from field [if dropped troops nearby]
│ ├── GroupName1 (50m)
│ └── GroupName2 (120m)
├── Check Cargo [SOL]
└── Parachute Troops [AIR — if canParachuteDrop + hasTroops]
├── (direct command if 1 group)
└── Parachute Troops (submenu if 2+ groups)
├── Parachute All
└── [1] TemplateNameA
Request Equipment (order=25)¶
Manager CTLDCrateManager (src/CTLD_crate.lua). Shown when the transport is landed and
inside a logistics zone (LGZ). One branch per nearby logistics zone; categories paginate at
10/page.
Request Equipment
├── [Logistics Zone "Zone-Alpha"]
│ ├── Infantry
│ │ ├── Rifle Squad
│ │ └── Rifle Squad x3 (singleTypeSet, if enableAllCrates)
│ ├── Support
│ │ └── Machine Gunner Team
│ └── [→ Next Page] (if > 10 categories)
└── [Logistics Zone "Zone-Beta"]
└── [...]
Item conditions: JTAC items require JTAC_dropEnabled = true; a descriptor.side must match the
coalition; whole-vehicle items (Feature Q) appear when canTransportWholeVehicle = true and the
type is in loadableVehiclesRED/loadableVehiclesBLUE, spawning a WAITING vehicle directly rather
than a crate. Spawn types: single crate, SingleTypeSet (N crates of one type), MixedSet
(multi-type, needs enableAllCrates), and vehicle (Feature Q).
Vehicle Commands (order=30)¶
Manager CTLDVehicleSpawner (src/CTLD_vehicle.lua). Shown when
capabilitiesByType[typeName].canCarryVehicles = true.
Vehicle Commands
├── Load / Extract Vehicles (submenu) [SOL — dynamic]
│ ├── M1A1 Abrams
│ ├── M113 APC
│ └── [... vehicles in range, coalition match, loadable type]
├── Unload Vehicles (submenu) [SOL — hidden if 0 loaded]
│ ├── Vehicle 1 (descriptor label)
│ └── Vehicle 2
└── Parachute Vehicle (command) [AIR — if canParachuteDrop + vehicle loaded]
Load filters: distance ≤ maximumDistancePackableUnitsSearch (~200 m), coalition match, type in
loadableVehiclesRED/loadableVehiclesBLUE, and state WAITING. Refreshed on landing, takeoff,
and vehicle spawn/despawn.
Crate Commands (order=40)¶
Manager CTLDCrateManager (src/CTLD_crate.lua).
Crate Commands
├── Load Crate (submenu, order=10) [SOL — if loadCrateFromMenu = true]
│ └── [dynamic: nearby crates < 300 m with assembly status]
├── Drop Crate(s) (command, order=15) [SOL]
├── Unpack Crate (submenu, order=20) [SOL]
│ ├── [Crate 1 - M1045 HMMWV TOW, 2500kg]
│ ├── [Crate 2 - Soldier]
│ └── [...]
├── List Nearby Crates (command) [SOL]
├── Pack Equipt (submenu, order=25) [SOL — dynamic, rebuilt on land]
│ ├── Pack FARP-AlphaModel [if enableFARPRepack + nearby FARP scenes]
│ ├── Pack FARP-BetaModel
│ ├── M1A1 Abrams [if enablePackingVehicles + packable vehicles nearby]
│ └── M113 APC
│ (submenu hidden entirely if no packable content found)
├── Parachute Crates (command) [AIR — if canParachuteDrop + crates onboard]
├── Release Slingload (command) [AIR — if canSlingload + slingload active]
└── Cut Slingload (command) [AIR — if canSlingload + slingload active]
Refresh patterns: refreshCrateFlightSection() toggles SOL/AIR visibility on land/takeoff;
refreshPackEquiptSection() rebuilds "Pack Equipt" on land; refreshRequestEquipmentSection()
rebuilds "Request Equipment" on zone entry/land.
Radio Beacons (order=60)¶
Manager CTLDBeaconManager (src/CTLD_beacon.lua). Config gate enabledRadioBeaconDrop = true.
Radio Beacons
├── Drop Beacon — spawns TACAN/ADF units + starts radio transmissions
├── Remove Closest Beacon — removes a beacon within 500 m
└── List Beacons — displays VHF/UHF/FM frequencies
RECON (order=70)¶
Manager CTLDReconManager (src/CTLD_recon.lua). Config gate reconF10Menu = true.
RECON
├── RECON [Start] / RECON [Stop] (toggle command)
├── Infantry [activate/deactivate (X)]
├── Air Defense (AA) [activate/deactivate (X)]
├── Ground Vehicles [activate/deactivate (X)]
├── Helicopters [activate/deactivate (X)]
├── Aircraft [activate/deactivate (X)]
├── Ships [activate/deactivate (X)]
└── FARP / FOB [activate/deactivate (X)]
Label logic: RECON active + layer enabled → [deactivate]; RECON active + layer disabled →
[activate]; RECON inactive + layer disabled → [activate] (X), where (X) marks that the scan
is not running. The seven layers are infantry, air_defense, ground_vehicles, helicopters,
aircraft, ships, farp_fob. Scan pipeline: altitude check (AGL ≥ reconMinAltitude, default
50 m) → line-of-sight per enemy unit matched against enabled layers → draw an F10-map mark per
target (shape by layer, colour by coalition) → auto-refresh timer (reconRefreshInterval, default
10 s) detecting new/moved/lost targets.
Mine Field (order=75)¶
Manager mineFieldScene (src/scenes/CTLD_mineFieldScene.lua), registered via
CTLDPlayerManager.deferMenuSection(). Shown only when the player is on the ground and mine fields
are within demineRadius (default 150 m); hidden entirely otherwise. Refreshed on land/takeoff and
after each clearing operation.
Mine Field
├── Clear Mine Field (N mines, ~Xm) [one entry per nearby mine set]
└── [...]
Smoke (order=80)¶
Manager CTLDCrateManager (src/CTLD_crate.lua). Config gate enableSmokeDrop = true.
Smoke
├── Drop Red Smoke
├── Drop Blue Smoke
├── Drop Orange Smoke
├── Drop Green Smoke
└── Smoke Auto-Resume [activate] / [deactivate] (toggle command)
With Smoke Auto-Resume activated, smoke markers are re-dropped automatically after burning out.
JTAC (order=90)¶
Manager CTLDJTACManager (src/CTLD_jtac.lua). Config gate JTAC_jtacStatusF10 = true.
JTAC
├── Request JTAC Equipment (submenu) [SOL — if JTAC_dropEnabled + in LGZ]
│ ├── Air JTAC Type1 (drone, spawnAs = "AIRPLANE")
│ ├── Ground JTAC Type2 (vehicle)
│ └── [...]
├── JTAC Status (command)
├── [JTAC Name 1] (submenu — per active JTAC, same coalition)
│ ├── Lasing [activate] / [deactivate] [if JTAC_allowStandbyMode]
│ ├── Spot Corrections [activate/deactivate] [if JTAC_laseSpotCorrections ≠ nil]
│ ├── Request Smoke on Target [if JTAC_allowSmokeRequest]
│ └── Request 9-Line [if JTAC_allow9Line]
└── [JTAC Name 2]
└── [...]
A per-JTAC submenu is present in every state except DEAD (SPAWNED, IDLE, LASING, ORBITING,
IN_TRANSIT all show it; DEAD removes it). toggleStandby() / toggleSpotCorrections() call
_rebuildJTACCommandBranch(), wiping and rebuilding the per-JTAC submenu for all coalition
players. Menu-affecting config flags:
| Flag | Effect on menu |
|---|---|
JTAC_dropEnabled |
Shows/hides "Request JTAC Equipment" |
JTAC_allowStandbyMode |
Shows/hides "Lasing [toggle]" |
JTAC_laseSpotCorrections |
Shows/hides "Spot Corrections [toggle]" |
JTAC_allowSmokeRequest |
Shows/hides "Request Smoke on Target" |
JTAC_allow9Line |
Shows/hides "Request 9-Line" |
Adding a menu section to a new module¶
- In your manager's
getInstance()(or, for a scene loaded early, at module top-level viaCTLDPlayerManager.deferMenuSection), callCTLDPlayerManager.getInstance():registerMenuSection(sectionDef)with a uniquekey, yourmanagerinstance, themethodname, and anorder. Add aconfigKeyif the section is config-gated, and arefreshMethodif it needs land/takeoff refreshing. - Implement
Manager:buildMenuSection(playerObj, menu)— build your subtree withmenu:addSubMenu/menu:addCommand, assigningorderto control position. - Implement
Manager:refreshMenuSection(playerObj)(or your registeredrefreshMethod) — update dynamic content viaclearBranch+ re-add, or toggle visibility withsetBranchEnabled. Do not re-register DCS commands directly. - Trigger
CTLDPlayerManager.getInstance():refreshForUnit(unitName)on any state change that affects your section's visibility.
DCS API constraints and limits¶
- No menu listing API — DCS cannot report existing menu items; the in-memory tree is the sole source of truth, validated locally before any DCS call.
- No insert-at, recycled ids — a created entry goes after its siblings, and a removed entry's id goes to the next entry created, server-wide; see Rendering by difference.
- Manual pagination — DCS does not auto-paginate;
_renderPagehandles it. - No callback introspection — Lua function signatures cannot be validated at runtime; the
expected signature is documented and failures are isolated with
pcall. Group.getByID()is unavailable — group ids are resolved to names by iterating coalitions.
| Limit | Value |
|---|---|
| Programmer slots per menu level | 10 (F1–F10; F11 = Previous Page, F12 = Quit, both DCS) |
| Visible children before pagination | 10 |
| Menu depth | Unlimited (keep < 5 levels for usability) |
| Simultaneous menus | Unlimited (one per group) |
| Refresh frequency | Debounced at 0.15 s; keep well under 1 refresh/s per group |
Design decisions¶
- In-memory tree before DCS apply — keeps the model authoritative; a mirror of the live entries lets each refresh apply only the difference.
- Pagination recomputed on every refresh — no cached page state to invalidate; dynamic updates stay simple.
- Explicit
order+enabled— positions are declarative and stable regardless of init order, and features can be hidden without losing their slot. - Debounced refresh — absorbs bursts so players are not ejected from the menu mid-navigation.
- Status tables, not exceptions — Lua 5.1 has no exception handling;
{ success, message }returns are more robust in a single-threaded engine.
Differences from v1¶
v1 (old/CTLD_menus.lua) |
v2 |
|---|---|
One monolithic addTransportF10MenuOptions() |
Each manager registers its own section via registerMenuSection |
Global functions (ctld.checkTroopStatus, ctld.loadTroopsFromZone, …) |
OOP methods on manager instances |
| Static menu built once on player enter | Dynamic sections rebuilt on land/takeoff and cargo events |
| No order control | Explicit order field per node |
| PKZ / EXZ zone naming | TRZ zone naming (see the troop-zone documentation) |
| No whole-vehicle menu integration | Feature Q: whole vehicles offered in Request Equipment |
Related subsystems¶
- Player tracking — the
CTLDPlayerentity andCTLDPlayerManagerlifecycle that owns this menu. - Events — the cargo/FOB events that drive menu refreshes.
- Architecture — the manager/singleton idiom used by
CTLDPlayerManager.