veaf-mission-mcp — LLM-assisted mission-editing MCP server¶
Audience: developers evolving the mission-editing MCP server, or wiring an MCP client (Claude Code, an agent) to it.
🎯 Mission-Maker side (plain-language catalogue):
mission-maker/AI_ASSISTANT_CATALOG.en.md.
Why this server¶
First phase of NL-MISSION-GEN (see ROADMAP.md §4): let an LLM edit a DCS mission on a
Mission Maker's behalf — and, eventually, generate one end-to-end from a detailed prompt. See
ADR 0014 for the architecture decision,
and CONTEXT.md (section "LLM-assisted mission editing") for the vocabulary.
Two action families, deliberately kept apart:
- Editor-parity action — mutates the mission's raw
.mizLua tables directly, exactly the way a Mission Maker would by hand in the DCS Mission Editor (add a group, a trigger, a zone). Never goes throughmission.yaml. This is the entire scope of this server in v1. - VMCT action — goes through the existing declarative
mission.yamlpipeline (inject_presets,aircraft_groups...). As of wave 4 the server exposes a first brick of this family: editing the sourcemission.yaml(see below), alongside the usual CLI/config path.
Running the server locally¶
poetry install
poetry run veaf-mission-mcp # in dev
# or, from the shipped binary (what `veaf-tools ai-setup` declares in the AI clients):
veaf-tools mcp
Starts an MCP server over stdio (the mcp SDK's default transport). No configuration: every
action receives the .miz path to edit as a parameter. The veaf-tools mcp subcommand embeds the
server in the already-shipped veaf-tools binary (no separate binary to build) — this is what
veaf-tools ai-setup declares in Claude Desktop, Claude Code, Codex, Antigravity, Gemini CLI and OpenCode.
Started from the shipped binary, the server also updates itself: in a background thread, at most once every
4 hours, only when check_updates is on and no pre-release is pinned (VEAF_MCP_UPDATER_TAG), it compares its version with the published-latest release and,
when that is newer, starts veaf-tools-updater in its own folder, in a hidden console
(veaf_mission_mcp/self_update.py). The updater sets the running veaf-tools.exe aside rather than overwriting
it, which Windows refuses; the new version is used from the client's next start.
Action catalog (v1)¶
The mission is mission_path, everywhere (FIX-OPEN-TRAINING-PROMPT-FINDINGS, ticket 05)
42 of the 47 actions took the mission under five names: miz_path (17), target (9),
folder_path (6), mission_path (4) and mission_yaml_path (6). The catalog now publishes
mission_path (a mission folder or a .miz) for all of them and translates it back to the
key the handler reads: the three former names are still accepted as aliases, and the examples
below that use them stay valid. mission_yaml_path is kept, since it names another file; a
mission_path given in its place (a folder) is read as <folder>/mission.yaml. A catalog test
fails when a new action introduces a sixth name.
miz_path also takes a mission folder (FIX-MCP-AUTHORING-GAPS lot, ticket 03)
Every editing action — edit_route, set_group_properties, set_unit_properties, edit_zone,
add_trigger_zone, add_map_drawing, edit_map_drawing — takes either a .miz or a mission
folder, exactly like add_group's target. The trade-off is the same: a folder edit is
durable (it goes into src/mission/, so it survives the next veaf-tools build), a .miz
edit is transient (the next build overwrites it). Each action now returns durable to say which.
Timestamped backup either way.
They used to take only a .miz, so a group could be created durably but not edited
durably, and pointing edit_route at the folder failed with [Errno 13] Permission denied —
reading a directory as a zip archive. A directory that is not a mission folder is now refused with
a message that says so.
The server does not expose one MCP tool per business action. It exposes a fixed discovery
surface, mirroring the dcs-bridge MCP tool (a bridge to a running mission):
| MCP tool | Role |
|---|---|
capabilities() |
Server identity (name, version). |
list_catalog(full=False) |
List registered actions as {name, summary} — the description's first sentence, about 10 kB for all of them; full=true gives every description and parameters_schema (some 70 kB, which a client saves to a file rather than shows). describe_action gives one in full. |
describe_action(name) |
Detail one action's parameter JSON Schema. |
run_action(name, params) |
Run a registered action. |
Concrete actions are registered by veaf_mission_mcp.actions.register_default_actions
(src/python/veaf-tools/veaf_mission_mcp/actions.py).
describe_mission¶
Read-only. Lists the groups (name, coalition, country, category) and trigger zones (name,
position, radius) already present in the .miz — so the caller can check current state before
writing, the same way a human would check the editor's outliner before adding something. Reuses
the existing pure-Python parser (mission_tools.miz_tools.read_miz) — no new parsing.
{"miz_path": "path/to/mission.miz"}
describe_units (FEAT-MCP-MUTATION-ACTIONS lot)¶
Read-only. The level of detail describe_mission does not give: each group's units (type,
skill, livery, callsign, side number, position, heading, altitude, fuel, counters/gun), their
loadout, and the group's route with the tasks at each waypoint.
Three shape decisions, each for a reason measured on a real mission (Foothold Caucasus 4.4.1, 357 armed units):
pylonsis keyed by pylon number, never positional. DCS numbers stations and the numbers are not contiguous: a real FA-18C carries pylons 1, 4, 5, 6 and 9. In that mission 170 of 357 units have a gapped layout, and the Lua parser hands those back as adictwhile it flattens the contiguous ones into alist. A reader treating pylons as an ordered list would therefore be right about half the time and silently wrong the rest — which is how a future setter comes to hang a weapon on the wrong station.- The editor's automatic tasks are flagged and stripped. A waypoint task is a
ComboTaskmixing the task the author added with the options the editor writes by itself (ROE, radar usage, formation), all markedauto = true: 1093 automatic entries against 189 authored ones in that mission. Both are reported — hiding them would misrepresent the mission — but only authored tasks carry theirparams. - A cap the caller is told about. The whole mission is 1.9 MB of JSON and a single 62-waypoint
group is 18 KB. Hence the filters (
group_nameby fragment,coalition,category), the default limit of 50 groups withtruncated/matchedin the answer, andinclude_route: false, which omits the key rather than returning an empty list ("not asked for" is not "this group has no route").
Booleans come back as booleans: DCS omits a key that is false, and a caller reading null cannot
tell "off" from "the reader did not look".
{"miz_path": "path/to/mission.miz", "group_name": "Colt", "include_route": false}
set_unit_properties (FEAT-MCP-MUTATION-ACTIONS lot)¶
Since
FIX-SCRATCH-MISSION-FINDINGSticket 19 it also renames (new_name, refused when another unit already has the name: DCS wants them unique across the mission) and moves (position) a single unit; the group's anchor and route stay put. For an aircraft, whose place is tied to the route, a warning says so.
Write. The first action that changes an object the mission already contains: every set_*
shipped before it acts on configuration (modules, security, logging, an airbase's coalition).
Timestamped backup before the write, like its siblings.
It addresses the unit by exact group name and exact unit name — not by fragment, unlike
describe_units: a fragment makes the edit land on whichever group matched first, which is not
recoverable. A name that misses lists what exists, so a caller can retry without re-reading the
whole mission.
Three shapes were measured on real missions rather than inferred, and two contradict the ticket that asked for them:
skillhas seven values, not four.Average,Good,High,ExcellentandRandomare AI levels;ClientandPlayerare human slots. Crossing that line one way adds a place to the multiplayer list, the other way removes one — the bugFIX-TEMPLATE-SLOTS-VISIBLEwas opened for. Both directions are therefore refused naming the reason, instead of being honoured as a skill setting.- An aircraft's callsign is not a plain field. It is a table
{1: family, 2: flight, 3: number, name: "Colt11"}wherenameis the family's word followed by the two indices ({1:1, 2:1, 3:2}readsEnfield12). Writingnamealone desynchronises what DCS says on the radio from what the editor shows, so the action edits the indices and rebuildsnamefrom the prefix already there. Changing the family requires DCS's family→word table, which this repository does not ship: that is refused unless the caller supplies the resultingname. headingis radians while a mission maker speaks degrees — the trapresolve_coordinateshides elsewhere. The parameter is namedheading_degso the unit cannot be mistaken, and the value is normalised onto one turn (−90 is 270). On an airborne aircraft (plane/helicopter category, a route of 2+ waypoints, an in-air first waypoint) the action warns: DCS recomputes the heading from the route's first leg on save, so a set heading has a lifetime of one save (FIX-MCP-EDITOR-ROUNDTRIP, measured 2026-08-15). To point an airborne aircraft, set the route, not the heading. The heading is still written — the warning informs, it does not refuse; a parked aircraft or a ground unit does not trigger it.
What the action does not validate, for want of the data to do it: a weapon's CLSID against the
airframe carrying it, and a livery against the skins installed. DCS silently drops an impossible
weapon and silently shows the default skin, so both limits are returned as warnings rather than
implied by their absence.
pylons is keyed by station number, never positional, for the reason measured in
describe_units. No pylons means "leave the loadout alone"; {} in replace mode means "carry
nothing"; in merge mode an empty CLSID empties that station.
{
"miz_path": "path/to/mission.miz",
"group_name": "Colt 1-1",
"unit_name": "Colt 1-1-1",
"skill": "Excellent",
"heading_deg": 270,
"pylons": {"4": ""},
"pylons_mode": "merge"
}
The result carries changed, giving each touched field its previous value and the new one: a
caller that cannot say what it replaced cannot undo it.
set_group_properties (FEAT-MCP-MUTATION-ACTIONS lot)¶
Write. Acts on the whole group: move, rename, frequency, modulation, and the three booleans
(lateActivation, hidden, uncontrolled). Timestamped backup before the write.
The move carries the whole design of this module, and it is not "write x and y". A group is units
in a formation plus possibly a route. The translation therefore applies to every unit,
every waypoint and the group's own x/y anchor, by a single vector: otherwise the formation
shears, or the route detaches from the units it belongs to — and neither shows before somebody flies
the mission. The shear test (move the units, leave the waypoints) is written to fail on any
implementation that forgets it, and that was verified by deliberately breaking the translation.
The vector comes from the geodesic offset of FEAT-GEO-PLACEMENT
(ADR 0015), not from adding metres to x: a DCS theatre is the
real world projected, so "5 km east" is a lat/lon question. A theatre with no projection makes the
bearing + distance form refuse, pointing at move_to instead.
frequency_mhz is checked against the airframe's HumanRadio, reusing the presets injector's
validator rather than re-deriving it: FIX-PRIMARY-FREQ-HUMANRADIO established that the DCS editor
refuses to save a mission whose primary frequency falls outside that range. Every unit type in
the group is checked, not just the first — a mixed group would otherwise pass on its first member and
be refused by the editor because of another.
The rename runs the reserved-convention check (validate_group_name) and refuses by default: a
group renamed onto a combat zone's trigger-zone name is despawned at start, silently. Renaming
into a convention is a legitimate intent, hence acknowledge_conventions — what matters is that it
be deliberate. Unit names never follow: they carry markers of their own (#command=,
#veafInterpreter[...]) that a cascade would rewrite blind.
What the action cannot do, measured rather than overlooked: check the surface at the destination.
There is no terrain data on the Python side — land.getSurfaceType is a runtime API and only its
schema ships here — which is exactly why FEAT-SCENERY-AWARE-SPAWN solved the problem at runtime. So
a move warns that it could not look, instead of validating and lying.
{
"miz_path": "path/to/mission.miz",
"group_name": "Red SAM Battery",
"move_bearing": 90,
"move_distance_m": 5000,
"late_activation": true
}
edit_route (FEAT-MCP-MUTATION-ACTIONS lot)¶
Write. Two layers: the route (add, insert, remove, reorder, set) is mostly a list
operation on route.points; a waypoint's tasks (add_task, clear_tasks) are what makes a flight
do something.
On or off the road. On a ground group, road: true writes a Turning Point's On Road action (the vehicles follow the roads), road: false writes Off Road; for add, insert and set.
Refused on any other group and any other waypoint type.
Every point used to be written Turning Point, and the demo mission's convoy drove across fields (FIX-DEMO-MISSION-FINDINGS 07).
A convoy's route at creation. A ground group made by add_group or create_combat_zone with two points or more starts on the road (On Road, like its legs) and runs at 16.4 m/s (59 km/h); a point may give its own speed, in m/s. Measured 2026-10-13 over the 126 vehicle groups of 14 hand-built missions whose route has an On Road leg: first point On Road in 85, Off Road in 37; the legs' speed has a median of 16.4 m/s, 5.6 m/s in only 11 % (FIX-OPEN-TRAINING-FALKLANDS-FINDINGS ticket 04). A single point stays Off Road, a ship keeps its Turning Point and its speed. That the start stands on a road is checked by nothing outside the game (ticket 06).
The invariant that makes this surgery rather than list editing. FIX-WAYPOINTS-ETA-LOCKED
established that DCS refuses to save a mission whose route has no waypoint with a locked time
("Route has no waypoints with locked time!"), and that its own repair is to lock the first. Removing or
reordering can therefore produce a mission the editor rejects, far from the edit that caused it. Every
operation restores the invariant and says so when it had to.
Units. The mission table holds metres and metres per second; a mission maker speaks feet and knots.
As with set_unit_properties' heading_deg, the parameters carry their unit in their name
(altitude_ft, speed_kt) and the result reports both, so a caller never converts back.
Tasks are a named set with validated signatures, not a free-form table — a deliberate choice from the ticket: a generic "write this task table" action is a foot-gun, because an agent produces a plausible table, DCS ignores it silently, and the mission maker finds out an hour later. The escape hatch starts closed.
Every signature was read out of a real mission, and three are traps:
SetFrequencytakes hertz (31000000for 31 MHz) while a group's frequency —set_group_properties— is in MHz. Two units for the same notion, in the same file. The action takes MHz and converts.EngageTargetsInZoneduplicates its target list into a serialisedvaluestring ("Air;Cruise missiles;") beside thetargetTypesarray; writing only the array leaves the mission carrying two versions of the same decision.SetFrequencyandSwitchWaypointare not tasks but actions, carried inside aWrappedActionenvelope. Written as a bare task, DCS ignores it.
Two measured details the ticket did not mention: a waypoint's type and action are a pair
("Land" goes with "Landing"), and an added waypoint inherits its neighbour's altitude and speed —
otherwise it is written at altitude 0 and the flight dives into the ground to reach it — unless
altitude_ft/speed_kt are given to add/insert, which are then written (FIX-MCP-EDITOR-
ROUNDTRIP: they were accepted and then silently dropped, the inheritance overwriting the asked-for
value).
Attack tasks must carry the full field set the editor keeps. FIX-MCP-EDITOR-ROUNDTRIP measured
(2026-08-15) that a Bombing written without weaponType is discarded by the editor on save — a
strike package that drops nothing. Bombing and AttackGroup therefore now carry weaponType
(measured "Auto" default: 2032 for Bombing, 9659482112 for AttackGroup, overridable via weapon_type),
the altitude/altitudeEnabled and direction/directionEnabled pairs present but disabled by
default (enabled when the caller passes altitude_ft/direction_deg), and the
expend/attackQty/groupAttack set. EngageTargetsInZone also carries noTargetTypes (its
exclusion list, empty by default).
Task order matters. DCS runs tasks by number, and a task placed after an orbit that never ends
is never reached. add_task appends by default; task_position (1-based) inserts it at a given place
and renumbers the others — to put an engagement before the orbit (FIX-SCRATCH-MISSION-FINDINGS
ticket 17).
{
"miz_path": "path/to/mission.miz",
"group_name": "Colt 1-1",
"operation": "add_task",
"index": 2,
"task": "orbit",
"task_params": {"pattern": "Race-Track", "altitude_ft": 20000, "speed_kt": 300}
}
edit_zone (FEAT-MCP-MUTATION-ACTIONS lot)¶
Write. add_trigger_zone only creates circular zones and nothing edited one afterwards, so
adjusting a VEAF combat zone — which is a trigger zone — meant deleting it and building it again.
Two measurements before any code, as the ticket required:
- A polygon zone's real shape, read out of
veaf-demo-mission.miz(czBatumi):type: 2plus averticieslist — DCS's own spelling, kept verbatim because correcting the typo would write a field DCS ignores — whilex,yandradiusstay present. A polygon is therefore not a circle with extra fields. - What the VEAF runtime handles. At the time,
veafCombatZone.luabranched on exactly two types:0→mist.getUnitsInZones,2→mist.getUnitsInPolygon(triggerZone.verticies). There was noelse, so a zone of any other type would contain no units, in silence — worse than not offering the shape. The action therefore writes only 0 and 2. SinceDROP-MIST, the branch lives inveaf.getUnitsInTriggerZoneand calls VEAF's ownveaf.getUnitsInCircularZone/veaf.getUnitsInPolygon; it still accepts only 0 and 2, but an unexpected type now logs an error and returnsnilinstead of an empty list, so the silence is gone. The action's rule is unchanged.
David's call on the vertex count (2026-08-12): accept three or more, since "follow the ridge line"
is the real use case and mist handles an arbitrary polygon — but warn whenever the count is not
four, the DCS editor having no tool to draw or reshape a non-quad zone. The open question of whether it
preserves one was settled in game on 2026-08-15 (FIX-MCP-EDITOR-ROUNDTRIP): a 6-vertex zone came
back unchanged through a save, so the action does not refuse above four; the warning states a known
limitation (you cannot edit the shape by hand there), not an unknown risk.
Two refusals the ticket left open, decided here: a link to a unit that does not exist is refused
rather than warned (a zone linked to nothing simply never follows anything, silently), and a name
collision is refused (zones are referenced by name from mission.yaml).
{
"miz_path": "path/to/mission.miz",
"zone_name": "czBatumi",
"vertices": [
{"x": -359753.0, "y": 614918.0},
{"x": -355602.0, "y": 622688.0},
{"x": -352849.0, "y": 617192.0},
{"x": -358731.0, "y": 614282.0}
]
}
add_map_drawing / edit_map_drawing (FEAT-MCP-MUTATION-ACTIONS lot)¶
Write. Nothing in VMCT touched F10 map drawings, so a briefing line, an ingress corridor or a no-fly box was drawn by hand in the editor — and vanished the moment the mission was rebuilt from its folder. That is the whole argument: a drawing an agent places is part of the recipe, a hand-drawn one is not.
The measurement that governs the design, read out of this repository's fixtures:
pointsare relative to the drawing'smapX/mapYanchor, the first one being{0, 0}.
A drawing written in absolute coordinates lands hundreds of kilometres away and nothing errors —
the same class of silent failure as confusing the mission table's {x=north, y=east} with a runtime
vec3 (see docs/agents/dcs-coordinates.md). So the actions take the absolute coordinates a caller
actually has and do the anchoring themselves. The payoff shows in edit_map_drawing: moving a drawing
is moving its anchor, and the shape follows for free.
Six shapes ship because six shapes were measured: Line (with lineMode segment or segments,
and closed for a shape that joins up), Polygon in rect mode (width/height/angle, no
points), TextBox (text/font/fontSize; the font is taken from a real drawing — one absent from DCS
renders as nothing), and — added 2026-08-15 from bridge-Syria-editeur.miz (ticket 10) — Polygon
in circle mode (radius, no points or angle), oval (r1/r2/angle), and free (points
relative to the anchor like a Line, a free-form filled area, three or more points).
arrow and icon were measured but stay refused, with a reason rather than a guess: an arrow
stores a computed 8-point outline beside its length/angle, so writing the parameters alone
needs an in-game round-trip to learn whether DCS recomputes the outline (its own ticket); an icon
needs a file from the editor's icon set (e.g. P91000007.png), which nothing here enumerates, and an
unvalidated name renders as nothing. chevron was removed — it is not a DCS editor tool.
Line style (style, FIX-OPEN-TRAINING-CAUCASUS-FINDINGS): one of DCS's sixteen styles — solid (default), solid2, dot, dot2, dotdash, dash, cross, square, strongpoint, triangle, wirefence, boundry1 to boundry5, DCS's spelling included.
It applies to every shape but textbox, which refuses it; a name outside the list is refused with the list.
Measured in the editor's me_draw_panel.lua: the mission stores the style's name alone, and the editor derives the texture from it on load — an unknown name would make that fail.
The layer is a first-class parameter, never a default: a drawing on the wrong layer is invisible to the pilots who need it and visible to the ones who should not see it.
{
"miz_path": "path/to/mission.miz",
"layer": "Blue",
"shape": "line",
"name": "FSCL",
"points": [{"x": -300000.0, "y": 600000.0}, {"x": -290000.0, "y": 610000.0}]
}
add_group¶
Write. Inserts a ground/vehicle group into the source .miz, in place, with a systematic
timestamped backup before the write (mission_tools.miz_backup.backup_before_write, e.g.
mission.20260712-143012.miz). A same-second collision is disambiguated (-2, -3, ...), never
silently overwritten.
{
"miz_path": "path/to/mission.miz",
"coalition": "red",
"country_id": 0,
"country_name": "Russia",
"category": "vehicle",
"name": "Red Armor Section",
"position": {"x": 1000.0, "y": 2000.0},
"units": [{"type": "T-72B", "count": 2}],
"route": [{"x": 1000.0, "y": 2000.0}, {"x": 1200.0, "y": 2000.0}],
"patrol": true
}
units— the server does no unit-catalog curation: concrete DCS types (T-72B,BTR-80...) are the calling LLM's decision, not this action's. Each unit may carry an explicitname(else auto-named). That is where a combat-zone marker goes (the runtime reads them off the unit name):#command,#spawngroup,#spawnradius,#spawncount,#spawnchance,#spawndelay,#alarm. The classic idiom is a "fake-unit" group whose name is#command="-armor ..."(alist_shortcutsalias): on zone activation it spawns the described group. E.g.units: [{"type": "Soldier M4", "name": "#command=\"-armor, spawnRadius 300\""}].route— optional; defaults to a single stationary point atposition. Withpatrol: true(and at least 2 points), the last point loops back to the first via aGoToWaypointtask — a classic DCS ground-unit patrol.- A name already taken is refused, group name and unit name alike, naming the group that holds it.
DCS does not refuse two objects of one name:
Group.getByNamereturns either one and says nothing, and everything that names a group (a QRA, a combat zone,remove_group) works on the wrong one. Applies to every action that creates a group (add_air_group,add_player_slot,create_qra…), which share the same insertion. groupId/unitIds are always fresh (mission_tools.group_insertion.max_ids), even on a mission with gaps in its existing id ranges.
Naming intents (wave 6). The caller expresses intent and add_group produces a
convention-correct name itself (veaf_mission_mcp.group_naming.resolve_group_name):
for_combat_zone: <zone>— prefix the name with the trigger-zone name (combat-zone membership rule), idempotent and case-insensitive;late_activation: true— set the DCSlateActivationflag (QRA interceptors, CAP templates);as_spawn_template: true—veafSpawn-prefix (spawnable-aircraft template).
add_group also returns a warnings field (see validate_group_name): it still writes, but
flags any convention collision for the caller to relay.
Clear ground (FEAT-CLEAR-GROUND-AT-AUTHORING lot). When the tool chooses the position, a
stationary vehicle group (no route) is put on ground measured clear of trees and buildings, at
most 1 km from the asked position, translating the group as one body. The measurement comes from the
theatre's clear-ground catalogue, swept once in DCS with veaf-tools dcs clear-ground-sweep
(Caucasus ships; otherwise the one swept on this workstation). The room needed is the extent of the
units written; for a #command marker, it is the worst case of the group the runtime will draw
(computed from veaf-units.yaml, spawn radius included: about 214 m for -sa10). A warnings entry
always says what happened: moved by N m, nothing large enough within reach, a place the catalogue
does not cover (with the command that sweeps it), or a group whose size is only known when it spawns
(-armor, -infantry…), left to the runtime. The group is never refused: failing better, it stays
where it was asked to go.
keep_position: true— the position is the one the user gave: the group is never moved. Same parameter on the groups ofcreate_combat_zone.
add_player_slot (FIX-SCRATCH-MISSION-PLAYABLE lot)¶
Write. Creates a player slot — a flyable aircraft group — which add_group (ground) cannot, and
without which a from-scratch mission is not flyable at all. Timestamped backup before writing. Targets
a folder (durable) or a .miz (transient).
{
"target": "path/to/mission-folder",
"coalition": "blue",
"country_id": 2,
"country_name": "USA",
"name": "Player Viper",
"unit_type": "F-16C_50",
"position": {"x": 1000.0, "y": 2000.0},
"start": "ground-cold",
"parking": "43",
"parking_id": "16",
"airdrome_id": 24
}
skill: Client— the multiplayer-slot skill, playable in single-player too. This action does not change an existing unit's skill:set_unit_propertiesrefusesClient/Playerand this is not a back door to it.dynSpawnTemplateis set tofalse. That flag marks a dynamic-spawn template, which needs an airfield configured for it; left on (as on a copy of a template) the slot sits in the file but does not appear in the slot list — the defect found in game on 2026-08-14.start—"air"(position +altitude_ft+speed_kt+heading_deg, no runtime data; DCS orients an air start toward its second waypoint, so the slot gets one 10 km ahead onheading_deg— with a single point it flew north whatever the heading, FIX-AIR-SLOT-HEADING),"ground-cold"or"ground-hot". A ground start requiresparking,parking_idandairdrome_id; without them it is refused (the message names the data captured byFEAT-MCP-MUTATION-ACTIONSticket 09), never guessed. The first waypoint'stype/actionpair is written per mode.frequency_mhzis written (group radio on) rather than inherited from acommunication: false.- Full internal fuel by default.
fuel(kg) andfuel_fraction(]0, 1]) let you choose the load; without them the aircraft gets its type's internal capacity, read from the shipped units database (dcsUnits.yaml, thefuel_capacityfield sourced from the datamine'sM_fuel_max). Both actions wrotefuel = 0until 6.15.1, which means no fuel at all: measured in game on 2026-08-18, a KC-135 and its two F-15C escorts created at 20 000 ft pitched straight into the ground on appearing, engines out. A parking start hid the defect, DCS fuelling a parked aircraft from the airfield's stock. A type the database does not know — a third-party mod — is created with nofuelkey (leaving DCS its own default) and the caller is warned, rather than handed an invented number. - Assigns the country to its side in
coalitions(seeadd_group), so the mission stays loadable.
add_air_group (FEAT-MCP-MUTATION-ACTIONS lot, ticket 09)¶
Write. Puts a flight (one or more aircraft) on the ramp, resolving the stands itself from an
airfield name — the "a two-ship of F-16s at Incirlik" case that add_player_slot (one aircraft,
caller supplies the spot) does not cover. Timestamped backup before writing. Targets a folder (durable)
or a .miz (transient).
{
"target": "path/to/mission-folder",
"coalition": "blue", "country_id": 2, "country_name": "USA",
"name": "Viper", "unit_type": "F-16C_50", "count": 2,
"start": "parking-cold", "airfield": "Kobuleti"
}
- Stand resolution. The airfield name is resolved to an id (
veaf_libs.dcs_airdromes), then to free stands via the slimmed bundled capture (veaf_libs.dcs_parking, generated byveaf-build update-dcs-data --parking). It takescountfree stands, nearest to the runway first, and seats each aircraft at the stand's exact position. parking_id=parking. Settled in game 2026-08-15:parkingis the capture'sTerm_Index, the aircraft seats from the exact position, and the editor's ownparking_id— absent from the capture — is not load-bearing, so it is written equal toparking.- Only terminal types 68, 72 and 104 are offered as parking — DCS's own
FighterAircraftmask (244).100(SmallSizeFighter) is excluded on purpose: DCS documents it as a tight spot for small airframes, and it unlocks no airfield the other three do not already cover. An airfield with none of the three is refused rather than seating an aircraft on a runway threshold or a helipad. The measurement behind the set, and the seven airfields it unlocked, are recorded besideAIRCRAFT_STAND_TYPESinveaf_libs/dcs_parking.py. - Collision refused. A stand already occupied in the mission (an aircraft group whose first waypoint targets this airdrome and one of whose units declares that stand) is refused naming the group that holds it; auto-selection skips occupied stands.
- Starts.
parking-cold/parking-hot(needairfield),runway(needsairfield, anchored on the field, no stand consumed),air(needsposition). The first waypoint'stype/actionpair and itsETA_lockedare written for you. skilldefaults to an AI level (High) — a ramp flight is AI unless you ask forClient/Player.parkingaccepts an explicit stand list that overrides selection.- Full internal fuel by default.
fuel(kg) andfuel_fraction(]0, 1]) let you choose the load; without them the aircraft gets its type's internal capacity, read from the shipped units database (dcsUnits.yaml, thefuel_capacityfield sourced from the datamine'sM_fuel_max). Both actions wrotefuel = 0until 6.15.1, which means no fuel at all: measured in game on 2026-08-18, a KC-135 and its two F-15C escorts created at 20 000 ft pitched straight into the ground on appearing, engines out. A parking start hid the defect, DCS fuelling a parked aircraft from the airfield's stock. A type the database does not know — a third-party mod — is created with nofuelkey (leaving DCS its own default) and the caller is warned, rather than handed an invented number. - An uncaptured theatre, an unknown airfield, or too few free stands are refused naming the cause.
Assigns the country to its side in
coalitions.
remove_group (FIX-MCP-AUTHORING-GAPS lot, ticket 02)¶
Write. Removes a group from the mission — the one group-level edit the catalogue was missing,
while edit_zone and edit_map_drawing both have a remove: true. Targets a folder (durable) or a
.miz (transient), timestamped backup before writing.
{"target": "path/to/mission-folder", "group_name": "Texaco"}
- Renumbers what it leaves behind. That is the whole point: hand-deleting a Lua block leaves the
enclosing list numbered
1,3,4, which Lua loads without complaint and the build dies on (AttributeError: 'int' object has no attribute 'get') at a line that does not point at the edit. Three corrupted builds on 2026-08-18 came from exactly that — and the stopgap of the day, a renumbering regex keyed on indentation alone, also renumberedunitsandroute.points. The survivors keep their order and are re-keyed from 1. - The last group of a category takes the
groupkey with it, rather than leaving an empty container — the shape a downstream reader mistakes for a list (FIX-GROUP-CONTAINER-SHAPE). - Exact name required. A fragment is refused, as
set_group_propertiesrefuses one: a removal landing on whichever group matched first is not recoverable. An unknown name lists what exists, and nothing is written. - A name several groups share is refused, listing their
groupIds:group_idsays which one goes, the other stays. It used to remove all of them and report one. - Names what it breaks without refusing — the mission maker may well mean it: a combat zone
capturing the group by name prefix, an
Escorttask pointing at itsgroupId(nested inside aComboTask, which is how DCS actually writes it), and amission.yamlmodules.ASSETS.assetsentry naming it. That last one is only checkable on a folder target: a.mizcarries nomission.yaml.
validate_group_name (wave 6)¶
Read-only. Checks a proposed name against the reserved patterns (veafSpawn-/OnDemand-/
VEAF-placeholder- prefixes, #veafInterpreter[...]/#command= markers, QRA deploy syntax, fixed
CAS names) and, with miz_path, the combat-zone capture trap (name starting with an existing
trigger-zone). expected_combat_zone suppresses the warning for the intended zone. Shares the
veaf_mission_mcp.group_naming module with add_group.
{"name": "combatZone_North-tanks", "miz_path": "path/to/mission.miz"}
add_trigger_zone (wave 2)¶
Write. Inserts a named circular trigger zone into mission.triggers.zones, with a fresh
zoneId, in place and backed up first. This is the zone a VEAF combat zone references: combined
with add_group, it lets you lay down a full combat zone (the trigger zone group_validation
requires + the groups inside it). No deduplication.
{
"miz_path": "path/to/mission.miz",
"name": "combatZone_North",
"position": {"x": 1000.0, "y": 2000.0},
"radius": 3000,
"hidden": false
}
add_startup_script_trigger (wave 2)¶
Write. Adds a "mission start" trigger that runs a script — for outfitting a vanilla or
CTLD mission with scripting without the DCS editor's Triggers tab. Generalizes
inject_dcs_bridge_trigger and the VEAF static/dynamic loading mechanism
(ADR 0004). Unlike that helper (which inserts at index 1
and renumbers everything), this appends at the next free index — no existing trigger is
renumbered. Three modes:
inline— run supplied Lua (inline_lua) viaa_do_script.file_static— embed a.luafile (source_path) into the.miz(l10n/DEFAULT/<name>.lua+mapResourceentry) and load it viaa_do_script_file.file_dynamic— load a.luafrom a runtime disk path (runtime_path) vialoadfile, nothing embedded.
{
"miz_path": "path/to/mission.miz",
"mode": "file_static",
"comment": "load my script",
"source_path": "C:/scripts/myscript.lua"
}
Timestamped backup before the write; no deduplication.
Editing embedded Lua files (wave 3)¶
Third action family: edit the text of the .lua files embedded in the .miz
(l10n/DEFAULT/**/*.lua), without a rebuild — neither the raw mission.lua tables
(editor-parity) nor the mission.yaml pipeline (VMCT action). Shared brick:
mission_tools.rewrite_miz_members copies the archive through verbatim and swaps only the
targeted members (no Lua-table re-serialization). Timestamped backup before each write.
replace_in_mission_files — generic search-replace¶
Text or regex replacement, restricted to l10n/DEFAULT/**/*.lua (never mission/
options or binaries). files is a glob matched against each .lua's path relative to
l10n/DEFAULT/.
{
"miz_path": "path/to/mission.miz",
"search": "debug",
"replace": "info",
"files": "veaf-*.lua",
"regex": false
}
Returns {files_changed, total_replacements}.
VMCT settings (veaf-config.lua)¶
Semantic actions editing l10n/DEFAULT/veaf-config.lua (the build-generated VEAF config).
Each replaces the line if present, else inserts it at the top (before the modules
initialise):
set_log_level(level)→veaf.ForcedLogLevel = "<level>"(error/warning/info/debug/trace).set_module_enabled(module_id, enabled)→veaf.setConfig("<MOD>", "enable", <bool>).set_security_disabled(disabled)→veaf.SecurityDisabled = <bool>.set_veaf_config(key, value)→veaf.config.<key> = <Lua scalar>.
Airfield coalition¶
set_airbase_coalition(folder_path, name, coalition, dynamic_spawn=True)— durably assign a DCS airfield to a coalition, in a mission folder;dynamic_spawn=Falsekeeps its dynamic slots closed (an enemy base).
⚠️ An airfield's coalition lives in
warehouses.airports[<id>].coalition, not inmission.coalition. Placing a unit near a base therefore never turns the base itself — this action is what does. It resolves the airfield name to an id through the mission's theatre, sets the coalition, and turns on the base's Dynamic Spawn slots (the build then stocks them) unlessdynamic_spawnis false. Backed up first, like the other editing actions.
dynamic_spawn: falsealso records the base under<side>.exclude_airportsinsrc/warehouses.yaml, andtrueremoves it: the build opened every base of a declared side with noairports:list,dynamicSpawn = falseor not (FIX-SCRATCH-MISSION-FINDINGSticket 15). Nothing is written when the file is absent (the step does not run) or the side is not declared in it (declaring it would open every one of its bases).
Multi-mission campaign¶
campaign_status(campaign_folder)— where the campaign stands: missions applied, each zone's owner, garrison strength, kind and neighbours, reserves, objectives met or not, the last mission's changes. Read-only.campaign_apply(campaign_folder, state_file)— merges a flown mission's state file, plays the turn between missions, judges the objectives, writes and returns the FR/EN debriefing; refuses a file already applied, from another campaign or skipping a mission, writing nothing.campaign_next(campaign_folder, players?)— creates (a copy oftemplate/) or refreshesmissions/mission-NN/mission: airfields to their owner throughset_airbase_coalition,src/campaign-data.yaml, theCAMPAIGNmodule inmission.yaml, and, on a folder it creates, the strategic situation as the briefing and the date, time and weather fixed in the mission (one mission, no variant; the day after the last mission, a solar time computed on the campaign's ground, a weather drawn with the ground visible); writes the strategic briefing deck next to it, and the mission briefing when the mission is built; returns the folder,conditions, the decks and the factual part of the strategic briefing in FR and EN. Its description asks Claude to set the date and time from the campaign's progress and to keep the ground visible, and to ask how many players are expected tonight:players(a count, or a range"5-7", beatingcampaign.yaml'splayers) writes the mission'sopposition:block.campaign_briefing(campaign_folder)— writesmissions/mission-NN/briefing-campagne.pptxand its strategic map from the campaign (facts) andbriefing.yaml(prose); returns the paths, the page count, whether the prose was there, whether the map had to do without tiles, the sectionsbriefing.yamlmay hold, andmission_deck: the mission briefing (briefing-mission.pptx, tactical maps), read from the built mission —Noneand a warning saying how to build while there is no.miz. Its description carries the writing rules: a military situation brief, no game mechanics outside the annex, the enemy kept to uneven intelligence.
Each one is the matching veaf-tools campaign command (campaign_manager.CampaignWorker), returning data instead of printing it. See Multi-mission campaign.
FARP¶
add_farp(target, name, position, coalition, country_id, country_name, farp_type="FARP", frequency_mhz=127.5, modulation="AM", callsign_id=1, ammo_dump=True, keep_position=False)— a complete FARP: the heliport static (category = "Heliports", the type'sshape_name), its radio and callsign, and thewarehouses.warehouses[<unitId>]entry that lets helicopters refuel there.add_groupinstaticplaced the object alone (ticket 19). Shape measured on the 372 heliports of the missions underD:\dev\_VEAF.warehouses.yaml'sfarps:then stocks it at build, like a base. By default, its ammunition dump (FARP Ammo Dump Coating,<name> - Ammo, 120 m east,Fortifications/SetkaKPlike the 68 of the OT missions) that CTLD takes as a loading point;ammo_dump=Falseskips it (FIX-OPEN-TRAINING-SYRIA-FINDINGSticket 05). A pad in the sea (ground 0 m on the elevation grid) is warned about (ticket 04). The pad is checked (FIX-FARP-PLACEMENTticket 01): where the clear-ground catalogue covers the place, a pad in trees or buildings is moved with its dump onto the nearest clear ground, within 1 km (keep_position=Truerefuses the move and only warns); where the elevation grid covers it, more than 3 m of height difference between the centre and a 40 m ring is warned as a slope. Where neither covers the place, the warning says what was not checked and gives theveaf-tools dcs farp-spotcommand, which finds a flat, clear spot in DCS;positionin the result says where the pad went. Neither the grid nor the catalogue sees a lake or a river (the grid knows the sea only): the result always carriessurface_check, which says so and repeats that command (FIX-OPEN-TRAINING-FALKLANDS-FINDINGSticket 03). A frequency that is an airfield tower's of the theatre, in the same band (AM:vhfanduhf;FM:fm), is warned with the airfield's name. Clearing the trees around the pad by trigger (ticket 02) was tried and taken out: DCS's « SCENERY REMOVE OBJECTS ZONE » removed no tree on MarianaIslands or Caucasus (known limitationscenery-remove-objects-zone-leaves-the-forest).
Carrier group, deck slots, sounds (FIX-OPEN-TRAINING-PROMPT-FINDINGS)¶
add_carrier_group(mission_path, coalition, country_id, country_name, name, position, heading_deg, speed_kt, carrier_type="Stennis", carrier_name, escorts, tower_mhz, tacan_channel, tacan_callsign, icls_channel, link4_mhz, recovery_tanker, tanker_*, rescue_helicopter)— the carrier (and its escorts) under way, its radio on the unit (hertz),ActivateBeacon(TACAN,system3),ActivateICLS, and on an arrested-landing deckActivateLink4+ActivateACLS; the<unit> S3B-Tanker(Tankertask + TACAN Y) and<unit> PedrogroupsveafCarrierOperationslooks for by name; thewarehouses.warehouses[<unitId>]entry. The ATC tasks are written on the first route point and in the group'stasks: of 218 groups measured, 120 keep them on the group, 55 on the route, 43 in both, andveafCarrierOperationsreads only the group; which of the two DCS runs has not been measured.add_air_group(..., start="deck-cold"|"deck-hot", carrier=<ship unit>)— first point linked to the ship (linkUnit=helipadId= itsunitId), deck spots numbered on; refused when the aircraft cannot both take off from and land on that deck (TakeOffRWCategories/LandRWCategories, captured intodcsUnits.yaml).add_sound(mission_path, sound_path, resource_name)— copies a.ogg/.wavintol10n/DEFAULTand declares it inmapResourceasMCP_Sound_<name>;edit_routetransmit_messageplays it (a wrappedTransmitMessage,file= the key,loop,duration,subtitlewritten to the dictionary).set_briefing_picture(mission_path, source_path, side, resource_name)— copies a.png/.jpgintol10n/DEFAULT, declares it inmapResourceasMCP_Picture_<name>and appends the key to the side'spictureFileNameB/R/N, keeping the pictures it had (FIX-DEMO-MISSION-FINDINGS 07).build_mission_briefing(mission_path, title, strategic_situation, context, mission_bullets, objectives, threats, air_opposition, air_defences, other, deployment, waypoint_notes, flights, code_words, recon_folder, diversions, kneeboard, pilots, loadouts, basemap, side)— an objective mission's VEAF briefing with no script (FEAT-MCP-MISSION-BRIEFING):docs/briefing.pptx, its PDFdocs/briefing.pdf(drawn byfpdf2with the bundled DejaVu font: the same pages and words, a layout of its own, no Office needed;pdf_pagesin the result),docs/maps/and the DCS briefing pictures, in the folder and the built.miz, first inpictureFileNameB/NunderMCP_Briefing_*keys replaced on every call. The assistant passes the words; the rest is read from the built.miz(mission_briefing/), the injected presets (Radio[n].channelsNames) included for the frequency plan.basemap: topotakes OpenTopoMap tiles, with their credit and their own cache folder. FEAT-OBJECTIVE-BRIEFING-QUALITY:strategic_situationanddeploymentmake a page each, left out when empty; a string is prose, its paragraphs separated by a blank line (mission_bulletsas a string loses its dashes);flightsnames a dynamic-slot package's flights, listed under their base instead of the types it offers;waypoint_notes(by waypoint name) goes on the flight plan's step-by-step sheets. The overview is framed byMapCanvas(width=…, rotation=…): 2000 px at least, symbols in proportion, turned bybest_rotationwhen the 2:1 frame gains 15 %, a north arrow, its key drawn inside; the zooms carry an inset (Inset). The targets and the steps areCardSections (mission_briefing/briefing_cards.py), drawn in the deck and the PDF; the deck and the PDF embed the maps as JPEG. FEAT-OBJECTIVE-MISSION-V2: a "combat area" map (render_overview_map(combat_area=True),docs/maps/combat-area.png, a DCS briefing picture too) follows the overview; the kneeboard maps are turned attack axis up (attack_axis_rotation, true bearing from the base to the objectives), a north arrow on each;code_words(word,meaning,who) makes a "Code words" page after the deployment, and a kneeboard page. FIX-BRIEFING-PARITY-MARTEAU: the overview frames the theatre (bases, tankers, AWACS, the QRA zones' centres, the lines the players' side drew on the map), the QRA zones dashed in orange, a SAM's reach outlined on a faint tint; an objective carries the name of the flight-plan point on it (objective_waypoint), the helicopters' route is green and dashed; the combat area adds the friendly ground units, the JTAC (a group namedJTAC…), the routed groups' axes and each threat's range on its label (ground_picture). A target is a unit when its units carry names the author gave, otherwise its type or its VEAF spawn command's family, with its role (DCS attributes) and its bullseye;objectives[].flightsgives an objective's "For:". With an operation inmission.yaml, its tasks are the main objectives, the others secondary, a chained zone in neither. Withflights, the frequency plan and the kneeboard flight card follow the types the flights fly, one block per radio plan. The PDF is left-aligned; the ATO, the deployment (<when> — <who>: <what>) and the code words are tables.waypoint_notestakes{"plane": {…}, "helicopter": {…}};kneeboard_replaceremoves the kneeboard pages that are not the tool's (folder →.veaf-backups/kneeboard-<date>/, and every.miz). FIX-BRIEFING-PARITY-MARTEAU-2: the overview draws and frames the departure bases (with dynamic slots, those of theflights, positions fromairdrome-positions.yaml) and the enemy's airfields, the tankers' flight levels, and leaves out a drawn line that only retraces a route; each route leaves from its flights' base. The combat area names the objectives as the flight plan does and has no QRA arcs; a key taller than 40 % of the map takes two columns, in the corner the labels leave free, above the scale and the credit. A frequency block says which flights carry it on which radio.objectives[].status(main,secondary,prerequisite) wins over the operation. The kneeboard flight card is a table with the altitude. The ATO gives the tankers' level;support(callsign,role,frequency,details) adds a JTAC, the fat cow, a GCI. FIX-BRIEFING-MAGNETIC-HEADINGS: every heading and bullseye bearing the briefing prints is magnetic,°M, as the cockpits read them (the F-16C manual gives the bullseye "in degrees magnetic"; the F/A-18C shows magnetic unless TRUE is set on the HSI): the true bearing on the WGS84 ellipsoid less the declination of the World Magnetic Model at the mission's month and year, the model DCS uses (veaf_libs/magnetic.py). They used to be taken on the DCS grid, which is neither: 6.9 degrees off on the Golan. A carrier's course and a recon photo's heading are on the grid in the mission and the sidecar, as the Mission Editor and the DCS camera hold them: they are turned to magnetic where they stand, in the captions and on the photo's banner (whose rose points to true north, as the maps). The ranges are measured on the WGS84 ellipsoid, no longer on the grid, which overstated them by its scale (0.1 % on the Golan, 1 % at Tbilisi). Recon photos (mission_briefing/briefing_recon.py) — the interface the in-game lot fills: a folder (recon_folder, relative to the mission folder or absolute;docs/recon/by default, ignored when absent) of pairs<stem>.jpg(or.jpeg) +<stem>.json, UTF-8:build_mission_promotion(mission_path, title, event, tagline, pitch, program, practical, aircraft, closing, calendar_url, briefing_url, recon_folder, side)— a built mission's announcement (FEAT-OBJECTIVE-MISSION-V2):docs/poster.jpg(1920 × 1080),docs/calendar-banner.jpg(2400 × 432, the VEAF calendar's 5.55:1) anddocs/discord-post.md, refused beyond Discord's 2,000 characters (mission_briefing/mission_promotion.py). Background: the first recon photo (byorder) graded, or the map; thebuild_mission_briefingcombat-area map and the photo pinned on it. The post's practical lines come from the built.miz(theatre, time of day, sky, dynamic-slot bases or flights).event(local date and hour) and the links are parameters, never invented: missing,warningssays so. The result gives the text back (discord_post,discord_chars).
| key | required | meaning |
|---|---|---|
zone |
yes | the objective's combat zone, as objectives[].zone (case-insensitive) |
title |
yes | the caption, in the mission's language |
heading |
no | camera heading, degrees on the mission's grid as the DCS camera holds it (what veaf-tools dcs recon-shoot writes): the photo's axis; printed magnetic, °M |
pitch |
no | camera pitch, degrees, negative looking down |
x, y |
no | camera position, DCS map coordinates (x north, y east); printed as DMS |
altitude_m |
no | camera altitude, metres; printed in feet |
order |
no | sorting within the zone (then file name), default 0 |
After each objective's zoom, its photos two to a slide (deck and PDF) and two to a kneeboard page, captioned "title — heading 095°, pitch -12°, from recon.json index veaf-tools dcs recon-render writes beside them is ignored. A photo with no sidecar, a sidecar with no photo, no zone or title, or a zone that is no objective: skipped, in warnings.
Two photos per objective at most (one by default): the next ones in order (order, then the name) are skipped, in warnings — Marteau du Golan had six for one objective.
The result lists the photos used in recon.
Syria Open Training (FIX-OPEN-TRAINING-SYRIA-FINDINGS)¶
- Callsign from the name: a western flight named like its callsign (
Texaco 2,Magic 1) gets that callsign (Texaco21) when the family fits the task and the flight is free; otherwise the former rule (first free family) and a warning saying why. An AI flight with a fighting task (Escort,CAP,CAS,SEAD…) and nopylonsis warned about (ticket 01). set_unit_properties: acallsign.namewithout digits is completed (Texaco→Texaco21);pylonstakes{station: CLSID}as well as{station: {CLSID: …}}and refuses any other value, naming the station (ticket 02).create_qrano longer writessimple_groupswhengroups_by_enemy_countis given: beside levels they never take off (ticket 03, measured intest_veafQraManager.lua).- Surface check:
add_group,create_combat_zone,add_farpandset_group_properties' move warn about a vehicle, static or FARP at 0 m (in the sea) and a ship above 0 m (on land), where the theatre has an elevation grid; otherwise "surface not checked". 3 m is land: DCS never reads below 0 (ticket 04). add_air_group(task="AFAC")— a laser drone like GermanyCW-v6's: first point withSetUnlimitedFuelthen aCircleorbit at the group's altitude and speed. The lasing is CTLD's, from themodules.ASSETSentry (jtac,freq,mod); CTLD moves it toJTAC_droneAltitude, it designates vehicles only, within 10 km (ticket 06).geocode: one request a second at most (Nominatim); a 429 waits whatRetry-Afterasks (30 s at most) once, then the refusal comes back asfound: false, said plainly. Five candidates asked for, a named place preferred to a road or a region;osm_class/osm_typein the answer, and a warning when it is a road or a region (« Al-Kiswah » → a street of Amman) (ticket 07).list_unit_typesgivesthreat_range_m/detection_range_m(metres, the datamine'sThreatRange/DetectionRange, the editor's circles) for every unit that has them;dcsUnits.yamlcarries them, regenerated byveaf-build update-dcs-data --units(ticket 08).- DCS loadouts by name:
add_air_group,create_qra(per group) andcreate_cap_missiontakepayload, the name of a loadout the editor offers (list_payloads), besidepylonsandloadout_from— one of the three. Source: an install'sMissionEditor/data/scripts/UnitPayloads(613 loadouts, 45 types), the datamine not carrying those files (ticket 09). repair_static_shapes(target)— fills theshape_nameof every static placed without one (before 6.26) from the units database, says what it filled and the statics whose type has no known shape; writes nothing when there is nothing to fill.validate's message names it (ticket 17).- The backups of a file under
src/mission/l10n/DEFAULT/go into.veaf-backups/(they stayed beside it and went into the.miz), and a folder save rewritesmapResource(tickets 15, 16).
Marianas Open Training (FIX-OPEN-TRAINING-MARIANAS-FINDINGS)¶
create_qra: a group carries no task on its point, whatever itstask(CAPorIntercept). Withtask: "CAP", the editor's automaticEngageTargetsmade the build fly the route as written: one point, no orbit, and the interceptor landed instead of defending its zone (ticket 01).add_carrier_group: the recovery tanker takes theShellfamily from flight 9 down (Shell91, thenShell81for the next carrier), andtanker_callsign("Arco 5") imposes one; the result says which intanker_callsign. The tankers namedTexaco 1,Arco 1placed afterwards keep theirs (ticket 02).add_air_grouptakesloadout_from, as the composites do: a group of the mission, then of the folder's catalogues, then of the shipped ones (aveafSpawn-*template). It is the only sourced loadout of a module aircraft with no DCS named payload (J-11A, F-16C); one ofpylons,payloadandloadout_fromat most (ticket 03).- Shipped dynamic-slot catalogue: the J-11A templates carry the air-to-air loadout of the Marianas Open Training v5 (
veafSpawn-J-11A - FOX3 - ECM ON - Radar ON - HARD X1), soloadout_from: "J-11A Template Red"arms a flight. TheA-4E-C,OV-10AandT-45templates, three mods, leave it, and the build warns when a template names a type the DCS unit database does not know (ticket 04). describe_missionreads a mission folder;describe_unitsgives each group'sgroup_idand each unit'sunit_id(edit_route add_task attack_grouptakes the former);terrain_elevationtakesmission_pathand reads the theatre from it (theatrestill works) (ticket 05).geocodewarns when it answers an island or an area for a place inside it: « Umatac, Guam » gave the centre of Guam without a word (ticket 05).add_groupand each group ofcreate_combat_zonetakeroe(weapons_free,return_fire,weapons_hold) anddisperse, written on the first point as the editor writes them (Option0;Option8 with no value for "does not disperse").roe: "weapons_hold"anddisperse: falsemake an inert target (ticket 06).- With no elevation grid or no clear-ground catalogue, the placement warning says what was not checked (the sea, the trees, the buildings) and how to check it:
veaf-tools dcs clear-ground-checkon the built mission. On MarianaIslands nothing is checked until the sweeps are done (known limitationmarianas-placement-checks-nothing, ticket 07).
Mission settings (FIX-SCRATCH-MISSION-FINDINGS ticket 07)¶
What the editor sets outside any group, and GermanyCW-v6 had to patch through a Lua serializer. Each
targets a mission folder (durable) or a .miz, backed up first.
set_mission_date(target, date?, start_time?)—mission.date(YYYY-MM-DD) andmission.start_time(HH:MM[:SS], the theatre's clock). The weather variants still override both.set_bullseye(target, coalition, position)—mission.coalition.<side>.bullseye, the source of every flight plan's BULLSEYE waypoint.set_route(mission_path, plan, points, coalition?, category?, aircraft_type?, country?)— a players' flight plan in the folder'ssrc/waypoints.yaml: the points in order (x/y or lat/lon), each set on the ground from the theatre's grid, a repeated name refused, the plan replaced with its old points; the file's comments are kept. Returns each leg's heading (magnetic, at the mission's date) and distance (on the ellipsoid), and names a plan that would still win over it (WaypointsManager.get_flight_plan_for: the most specific, then the first declared).set_briefing(target, sortie?, situation?, blue_task?, red_task?, neutrals_task?)— the briefing texts. Where the mission table holds aDictKey_…reference, the text goes intol10n/DEFAULT/dictionarybehind it, so the reference stays valid;write_mission_foldernow writes that dictionary back, only when it changes.set_weather(target, metar?, temperature?, wind_speed?, wind_direction?, visibility?, cloud_type?, cloud_height?, precipitation?, fog_enabled?, clearsky?, reference_airfield?, wind_gust?, turbulence?, wind_veer?, wind_speed_2000?, wind_direction_2000?, wind_speed_8000?, wind_direction_8000?)— the base mission's weather, in the fields DCS reads (ticket 19: the blank mission has its clouds on the ground,Preset1at 0 m). Same vocabulary and converter asversions[].weather, so a METAR works too; the variants still override it at build.wind_speedis the wind wanted 10 m above the reference airfield, not a DCS setting (FEAT-MISSION-WIND-CHECK): the answer givesreference_airfield(name, elevation, how it was chosen),simulated_wind(the wind DCS will simulate above it, by layer: surface, 1,000 and 3,000 ft above the ground, FL100, FL250) andwarnings— sea level used for want of an airfield, a jet in the low layer, and with a helicopter slot, every place where the simulated wind exceeds 20 kt 0-600 m above the ground (the wind).
Password hashes (
veafSecurity.password_L9[...]/password_MM[...]) — a multi-line case — are not covered yet: only theSecurityDisabledflag is.
VMCT actions on mission.yaml (wave 4)¶
The fourth family — the first genuinely VMCT one: edit the declarative source
mission.yaml (what the build consumes to generate the .miz), rather than patching a built
artifact. Shared brick: mission_tools.mission_yaml_editor (ruamel.yaml round-trip mode)
which preserves comments, key order and formatting — essential for a heavily-commented source
file edited by hand and kept in lockstep with the shipped default. Timestamped backup before
every write.
describe_mission_config¶
Read-only. Lists the modules: block and, per module, its state: mandatory (bare key),
scalar (boolean MODULE: true/false) or extended (nested config block such as
COMBATZONE/CTLD). The VMCT counterpart of describe_mission.
{"mission_yaml_path": "path/to/mission.yaml"}
set_mission_module¶
Write. Enable/disable a module or set its extended config block, comments preserved. value is
either a boolean (scalar form) or an object (extended block). The key is replaced if present,
inserted otherwise. No deduplication.
{
"mission_yaml_path": "path/to/mission.yaml",
"module_id": "COMBATZONE",
"value": {"enabled": true, "combat_zones": [{"type": "zone", "zone_name": "CZ-Alpha"}]}
}
Deliberately generic (toggle + mapping setter) — no per-module schema validator: the shape of the config block passed stays the caller's (LLM's) responsibility, like unit types for
add_group.
Recipe / built parity (wave 7)¶
Every setting editable on the built veaf-config.lua (wave 3) gets its source mission.yaml
counterpart, so both targets are reachable. Separate actions (consistent with set_mission_module),
on the mission_yaml_editor brick:
| Setting | Recipe (mission.yaml) |
Built (veaf-config.lua) |
|---|---|---|
| Log level | set_mission_log_level → global_log_level |
set_log_level |
| Security | set_mission_security → security: block (+ password hashes) |
set_security_disabled |
| Arbitrary setting | set_mission_setting → settings.<key> |
set_veaf_config → veaf.config.<key> |
| Module enable | set_mission_module (wave 4) |
set_module_enabled |
| Options enforced on players | set_forced_options → mission.forced_options |
— (the build writes them into the mission's forcedOptions) |
Domain-knowledge oracle (wave 5)¶
The actions above are the LLM's hands (writes) and eyes (describe_*). Wave 5 gives it a
brain: read-only actions exposing the DCS + VEAF knowledge needed to author correctly. All
read from the canonical sources the build already uses, so they cannot drift:
- generated DCS data (
update-dcs-data→veaf_libs/data/dcsUnits.yaml, published on the VEAF GitHub); - VEAF aliases (
veaf_libs/data/veaf-units.yaml); - vendored artifacts (
vendored.yaml,check-vendored); - upstream datamining repos (provenance).
Implementation: veaf_mission_mcp/oracle.py. The "prose / how to reason" half is the authoring guide,
veaf_mission_mcp/data/authoring-guide.md, served by describe_authoring_guide — the server = hands, the guide =
brain.
list_unit_types¶
Read-only. DCS unit types from the generated database, filterable by category and/or
name_contains, so the LLM can pick concrete types.
{"category": "Plane", "name_contains": "su-27"}
list_countries (FIX-OPENCODE-TEST-FINDINGS lot)¶
Read-only, no mission needed. The DCS countries as {id, name, short?}, in id order, from veaf_libs/data/dcs-countries.yaml: the country_id and country_name that add_group, add_air_group, add_farp and the composites take.
name_contains matches the name, the Mission Editor's name and the short code.
An assistant with no such action read the file out of the executable's unpacked folder.
{"name_contains": "turk"}
list_payloads¶
Read-only. The default loadouts the Mission Editor offers an AI aircraft type, by name
(« R-40T2,R-334 » for a MiG-31), with their pylons — what payload takes in add_air_group,
create_qra and create_cap_mission. Without unit_type, the types that have some. Read from
veaf_libs/data/payloads.yaml, generated from a DCS install
(veaf-build update-dcs-data --payloads --dcs-path <DCS>): the datamine does not carry these files.
The generator reads the editor's own UnitPayloads folder and every module's
(CoreMods/**/UnitPayloads): the module aircraft (P-51D, P-47D, Fw 190, F-16C, FA-18C…) are in. It lists
the few files that are real Lua rather than data (a CLSID computed from a local) and leaves their types
out — the Spitfire has no file at all in the install. The two AI bombers of a WW2 mission have no file
either: their default loads (Ju-88A4 « SC5002,SC5010 », B-17G « M64*12 ») are written by hand in
veaf_libs/data/payloads-ai-bombers.yaml, read from the install's db.Units.lua.
{"unit_type": "MiG-31"}
list_shortcuts¶
Read-only. The VEAF alias vocabulary (shilka, sa8…) — unit aliases (_spawn unit <alias>)
and composite group aliases (_spawn group <alias>: SAM sites, convoys), plus the #command
shortcuts (-samLR, -armor…) with randomParameters: the {min, max} range of each parameter
they draw on every use — -samLR and -samSR run the same command and differ only by their
defense range. Filterable by name_contains.
Every entry carries an era, read from what it spawns and never typed beside it
(veaf_libs/shortcut_eras.py): follows_era (-aaa, -samSR, -samLR, -samVLR, -armor, -infantry,
-transport, -combat, -convoy, -cas: the units come from tables keyed by mission.era), ww2
(-freya, -wurzburg, -flak88, -lefh, -pak40, -wespe), post_ww2 (fixed equipment of a later
age: -ewr, -arty, -sa6…), era_free (spawns nothing) or unknown (-jtac, -afac, -cargo: not
measured, so not claimed). era (WW2, COLD_WAR, MODERN) keeps what is worth offering in a mission of
that era. validate warns about a #command or #veafInterpreter of a post_ww2 shortcut in a WW2 mission.
describe_known_limitations¶
Read-only. For the running veaf-tools version, the entries of
veaf_libs/data/known-limitations.yaml: the tool limitations not yet fixed (kind: tool, dropped
from the version in their fixed_in) and the DCS behaviours that raise no error (kind: dcs,
always returned, with the date they were measured). Each entry: id, kind, area, title,
symptom, workaround, optionally cost and theatres. The file is the one source:
docs/agents/dcs-runtime-traps.md is generated from it.
The whole text is about 74 000 characters, more than a tool result carries. With no argument the action
returns a short listing — id, kind, area and theatres when set, no title, about 7 000 characters — and the count of each
area. area (spawning, air-defence, players, radio, ground-ai, scripting, mcp, pipeline, runtime), kind and
theatre narrow that listing and keep it short; theatre drops the entries that concern other theatres only (an entry with no
theatres applies everywhere). Read the entries you need in full with id or ids (a list); full: true returns everything selected.
An area used to come back in full: three of them were 46 000 characters in one call, carried to the end of the session (FIX-OPENCODE-TEST-FINDINGS).
{"ids": ["ai-ground-start-needs-a-parking-capture"]}
describe_authoring_guide (FEAT-AI-ASSISTANT-ANY-CLIENT lot)¶
Read-only, no parameter. Returns {"guide": …}: the authoring guide, veaf_mission_mcp/data/authoring-guide.md, verbatim.
The build bundles the whole veaf_mission_mcp/data/ folder into the exe; in development the action reads the package's file.
The server instructions (the MCP protocol's instructions, sent to the client on connection) ask it to call this action first, then describe_known_limitations.
They are a few lines on purpose: a client puts them in every session's prompt, so they point at the guide rather than carry it.
Not every client honours them; the install page therefore opens the conversation with the explicit request.
{}
read_mission_file / edit_mission_file (FEAT-AI-ZERO-SETUP lot)¶
A mission folder's YAML sources — mission.yaml and src/*.yaml, nothing else, no .., no absolute path — for an assistant with no direct file access.
In Claude Desktop's chat mode, an assistant asked to rename a mission answered that no action could; mission.name, mission.era and the src/*.yaml files the guide's checklist asks it to adapt had no action at all.
read_mission_file returns {file, first_line, last_line, total_lines, content}, 400 lines by default (start_line, line_count): the slot catalogue runs to 11 000.
Its lines end in \n whatever the file uses.
Without mission_path it reads the scaffold shipped with the tools — what scaffold_mission lays down: the dynamic-slot templates, the presets plan, mission.yaml with each module's commented example — and says shipped: true, so a plan can be checked before any folder exists.
edit_mission_file replaces one exact passage (search / replace), refused when it occurs zero or several times or when the result no longer reads as YAML; backed up first.
Line ends are compared normalised, and the file keeps its own: a passage copied back with \n from a CRLF file was "not found" (FIX-OPENCODE-TEST-FINDINGS).
Implementation: veaf_mission_mcp/mission_files.py.
{"mission_path": "C:/VEAF/missions/convoi", "file": "mission.yaml", "search": "name: My-Mission", "replace": "name: helico_convoi_Montagnes"}
describe_mission_prompt (FEAT-AI-ZERO-SETUP lot)¶
Read-only. Returns {"kind", "lang", "prompt"}: the instructions that build one kind of mission end to end — the questions to ask, the design rules, the build order.
kind is objective (a mission played once, in one session) or open-training (a VEAF training mission on a whole map); lang is en (default) or fr.
The prompts live in veaf_mission_mcp/data/mission-prompts/, bundled with the guide; they used to sit in the repository's .prompts/, for the user to paste.
The authoring guide sends a user who asks for a new mission here, so "create a mission" is enough.
The objective prompt comes in two parts, cut at a <!-- part: build --> line: part: scenario (the default, about 13 000 characters, which ends by saying how to load the rest) and part: build, once the user has approved a scenario; part: all returns both.
Sections 4 to 9, 21 000 of its 34 000 characters, serve only after approval, and were carried through every turn of the scenario discussion (FIX-OPENCODE-TEST-FINDINGS).
{"kind": "objective", "lang": "fr"}
offer_clear_ground_check (FEAT-CLEAR-GROUND-AT-AUTHORING lot)¶
Read-only, and it launches nothing. On a built .miz, returns what it takes to offer the user
an in-game check: the veaf-tools dcs clear-ground-check command to run (it needs DCS), what it will
do, the number of vehicles and markers, and the counting criterion. The check probes each vehicle's
position on the empty survey mission: no vehicle exists there, so none is counted as blocked by its
neighbours — the trap that made three published figures wrong. It then compares each answer with what
the catalogue predicted.
{"miz_path": "path/to/mission.miz"}
offer_scenery_lookup (FEAT-OBJECTIVE-MISSION-PROMPT lot)¶
Read-only, and launches nothing. To use a map object (a bridge, a building that is part of the map)
as a combat zone objective, you need its DCS id — the zone's scenery_targets takes nothing else, and
those ids exist only inside DCS. The action returns what it takes to offer the user the
veaf-tools dcs scenery-objects command (it needs DCS), which lists the objects around each point with
their id, type and distance. Default radius: 150 m.
{"theatre": "Syria", "points": [{"x": -64230.0, "y": 352140.0, "radius": 100}]}
terrain_elevation (lot FEAT-TERRAIN-ELEVATION)¶
Read-only, no DCS: answers from the theatre's elevation grid, swept once by
veaf-tools dcs terrain-sweep or downloaded from the published ones on first use (terrain data). Four questions, combinable in
one call:
points— the ground height at each point, in metres and feet (a target's altitude);route— the highest ground of each leg (the floor of a low-level route);route+observers— how many metres of each leg each radar or SAM sees over the terrain, within its range, 4/3-Earth radar horizon. Every route point then needs itsalt: a leg between twoRADIOpoints (above the ground) follows the ground, any other is straight between the two altitudes;area— the highest ground per 10 km MGRS square (the F10 grid) or 30′ quadrangle.
Terrain only — no buildings, pylons or trees — and interpolated between two samples: every answer
carries the grid spacing and that caveat. A point off the grid is null, never 0. With no grid for the
theatre, the answer is available: false with the command that sweeps one.
{"theatre": "Caucasus",
"route": [{"x": -281000, "y": 647000, "alt": 60, "alt_type": "RADIO"},
{"x": -262000, "y": 690000, "alt": 60, "alt_type": "RADIO"}],
"observers": [{"name": "SA-6 Kutaisi", "x": -284500, "y": 683500, "range": 25000}]}
describe_naming_conventions¶
Read-only. The 8 reserved naming patterns (combat-zone membership, veafSpawn-/OnDemand-
prefixes, #veafInterpreter[…]/#command= markers, QRA deploy entries, fixed CAS names…), each
with its rule and the consuming module. Check a proposed name against these before add_group.
describe_module¶
Read-only. A locator (not a schema validator): confirms a VEAF module exists (via the
canonical lua_module_scanner list), returns its doc page, and — when mission_yaml_path is
given — its enabled state. Each module's config keys live in its doc page.
It knows the community scripts the build ships too (CTLD, CSAR, AIEN, STTS, TUM, MIST): kind is veaf or community.
{"module_id": "QRA", "mission_yaml_path": "path/to/mission.yaml"}
Composites — one pass, both worlds (wave 8)¶
High-level actions that lay down a complete feature in one call, on a mission folder: they
edit the durable source (the exploded src/mission/ — zones/groups — via mission_folder,
and mission.yaml), without triggering a build (a later veaf-tools mission build produces the
.miz). They orchestrate the wave-1..7 primitives (insert_trigger_zone,
insert_group_into_content, the mission.yaml editor). Implementation: veaf_mission_mcp/composites.py.
create_combat_zone¶
Trigger zone + groups placed inside (names auto-prefixed with the zone → captured at runtime,
coalition-agnostic) + an appended modules.COMBATZONE.combat_zones[] yaml block. Each group takes
route and patrol, shaped like add_group's: a convoy moving through the zone is one of its groups,
not a separate call. In category ship, ships are placed 600 m apart (a vehicle's 20 m made them
collide as they spawned).
add_combat_operation¶
Writes mission.yaml only: a combat_zones[] entry of type operation — tasking_orders (each task, its dependencies), friendly_name, briefing, active_at_start.
An operation's name is a label, not a trigger zone: nothing is written to src/mission/.
Refuses a task or a dependency that names no declared combat zone (or names another operation): the generated GetZone() would be nil at runtime (FIX-DEMO-MISSION-FINDINGS 07).
Activating the operation spawns all its zones at once; dependencies only decide when a task becomes the current objective.
create_qra¶
Trigger zone + Late-Activation interceptors (coalition-significant) + a
modules.QRA.definitions[] entry referencing the groups by exact name (simple_groups).
Coalition is lower-cased for placement, upper-cased in the YAML definition. Each interceptor has a single waypoint and no task, on purpose: when it scrambles, the QRA module
gives a CAP/Intercept group whose route engages no aircraft a patrol across the zone
(what a scrambled group does).
Its description asks for tiers by intruder count up to the squadron's expected size — never a single fixed pair against five players or more — and names scale_with_opposition and rearm_while_occupied (opposition level).
scale_with_opposition: true is written by default (the caller's qra may set it false), and the root opposition: {level: 0} block is added when the mission has none, so the Opposition menu exists and the mission starts unchanged.
An interceptor takes off from the runway by default: airfield's, else its coalition's airfield nearest the zone; start: air puts it in the air at position, only when asked. With no airfield of its coalition, or no parking data for the theatre, the default falls back to the air with a warning; a start or an airfield asked for and impossible is refused.
create_cap_mission¶
A Late-Activation template group named OnDemand-<name> + a cap_missions[] entry
(group_name: <name>, un-prefixed — the build resolves it to the OnDemand- group). The first point
carries the EngageTargets task (Air targets) the editor adds on its own to a CAP task, numbered
before the orbit: without it the flight patrols and never engages.
menu_name is the name the F10 MISSIONS menu shows (the mission name when omitted: an empty one made a blank entry whose "Infos" failed), briefing what its Infos say.
A cap key the build does not read (friendly_name, the combat zones' word) comes back in warnings, and validate_mission warns about it too.
Scaffolding a mission folder (wave 9)¶
Every action above assumes a mission folder already exists. Wave 9 provides the upstream piece: create that folder from an empty one, driving the real VEAF binaries the way a Mission Maker would on first install.
scaffold_mission¶
Write. On an empty target folder:
- Resolve the current OS's updater asset (
veaf-tools-updater.exeon Windows,veaf-tools-updater-<os>-<arch>on Unix) and download it from the stable release-download URL (…/releases/download/<tag>/<asset>— no GitHub API, no rate limit). - Run the updater in the folder (it fetches and installs the VEAF tools +
published/). - Run
veaf-tools mission prepare --template <tier> --forcein the folder.
{
"target_folder": "path/to/empty-folder",
"template": "standard",
"github_token": "…",
"tag": "published-latest"
}
- Refuses a non-empty folder — scaffolding only initializes an empty one.
template—minimal/standard/full. The interactivecustomtier is not supported here (its TUI picker has no TTY under a subprocess); the calling LLM must ask the Mission Maker which template and pass it as a parameter.theatre— optional; relayed toprepare --theatreto lay down a synthetic blank mission for that DCS map intosrc/mission/(no DCS round-trip). Omitted →src/mission/stays empty (the maker supplies their own.miz).github_token— optional, relayed to the updater (--token) to bypass the API rate limit.- A non-zero exit from the updater or
prepare, or a missingveaf-tools/published/after the updater, surfaces as an explicit error.
This is step 0 of a from-scratch mission, before the wave-8 composites.
Map & coordinates (wave 10)¶
Placement actions take DCS local coordinates (x/y, metres in the theatre's own projection);
a Mission Maker thinks in lat/long off a map. Wave 10 gives the LLM map awareness and conversion,
design-time (no running DCS).
Foundation: veaf_libs.coordinates — pure-Python Transverse Mercator WGS84 (no pyproj), whose
per-theatre constants come from the vendored data/dcs-maps.yaml (MIT export of
VEAF/dcs-maps, see ADR 0015)
— all DCS theatres (Caucasus, Syria, PersianGulf, Marianas, Normandy, Nevada, SinaiMap,
GermanyCW, Kola, TheChannel, Falklands, Afghanistan, Iraq). Since DCS theatres are the real world
projected, this bridges DCS x/y ↔ real-world lat/lon.
describe_map¶
Read-only. From a .miz or a mission folder: returns the theatre, per-coalition
bullseyes, and existing zones/groups as reference points — so the LLM can orient without DCS.
Each zone carries its x/y/radius, each group its x/y and its number of units.
{"mission_path": "path/to/mission.miz-or-folder"}
list_airfields¶
Read-only. Lists a theatre's airbases — name, DCS airdrome id, and DCS x/y in whole metres when the
theatre's projection is known (lat/lon otherwise), plus each base's coalition with a mission — from the data shipped with the tools
(veaf_libs/data/airdrome-positions.yaml, generated with airdromes.yaml from the dcs-world-schema reference database by
veaf-build update-dcs-data --airdromes). The position is the airbase's reference point, the runways' centre. With mission_path, the mission's theatre; without a
mission, theatre.
name_contains keeps the bases whose name holds a text; details: true adds lat/lon, parking (the stands usable by planes and by helicopters) and atc (uhf, vhf, fm MHz and tacan, as DCS gives them).
Short by default: every field for Syria was 37 000 characters, 21 000 now (FIX-OPENCODE-TEST-FINDINGS).
{"theatre": "Syria", "name_contains": "incirlik", "details": true}
describe_airfield_channels¶
Read-only. Lists, for a mission folder, the airfields it uses — side (from warehouses), dynamic
slots once src/warehouses.yaml is applied, player slots parked there — with the ATC frequencies and
TACAN DCS itself gives them (veaf_libs/data/airfield-frequencies.yaml), and the alias each already
has in the bases collection. Ranked: held with slots, held without, then (include_neutral) neutral.
It is the proposal to put to the mission author before set_airfield_channels: a DCS radio holds
about twenty channels. Same code as veaf-tools content airfield-channels.
{"folder_path": "…", "include_neutral": false}
set_airfield_channels¶
Writes the chosen airfields into the bases channel collection of the folder's src/presets.yaml,
with DCS's frequencies; refuses an airfield DCS does not declare. An airfield already in bases keeps
its alias and the spelling of its title (Büchel for DCS's Buchel), a new one is aliased Base-<DCS name>, and an entry matching no chosen airfield (a FARP, a ship) is
left as it is and reported in untouched. Only bases changes; not_on_a_radio lists the written
channels no channel_lists entry uses yet. Idempotent.
{"folder_path": "…", "airfields": ["Batumi", "Kutaisi", 31]}
resolve_coordinates¶
Utility. Converts a position between {x, y} (DCS local) and {lat, lon} (decimal degrees) for the
mission's theatre (read from the mission — the caller never supplies projection parameters), or for a theatre named
before there is a mission.
{"mission_path": "…", "position": {"lat": 42.18, "lon": 41.68}}
To convert several points in one call, pass positions (a list) instead of position: the answer
is {theatre, points}, in the order given, and an incomplete position is named by its index.
geocode¶
Read-only (FEAT-GEO-PLACEMENT lot). Resolves a real place name to DCS coordinates for the
mission's theatre — DCS theatres being the real world projected. Pluggable geocoder: OpenStreetMap
Nominatim by default (free, no key; © OpenStreetMap attribution required), Google Maps when
GOOGLE_MAPS_API_KEY is set. Optional bearing+distance_km ("10 km north of X"). Returns
{found, display_name, latlon, xy, in_theatre_bounds, warnings} — approximate, confirm visually;
named places work, vague terrain does not. Without a mission, theatre names the map.
{"mission_path": "…", "query": "Kobuleti", "bearing": 0, "distance_km": 10}
Build & validate (wave 11)¶
The earlier actions create, orient and edit a mission folder, but nothing produced the playable
.miz — the maker still ran veaf-tools mission build by hand. Wave 11 makes the server self-sufficient
end-to-end: empty folder → scaffold → theatre blank → composites/placement → validate → build →
playable .miz, without leaving the assistant.
validate_mission¶
Read-only. Lints a folder before build: reuses veaf_libs.mission_validator in-process. Returns
{ok, errors[], warnings[]} (ok = false on any error). Run it before build_mission.
{"folder_path": "path/to/mission-folder"}
inspect_built_mission (FIX-OPENCODE-TEST-FINDINGS lot)¶
Read-only. What a built mission holds, for the post-build check, compactly — one line per item, never a raw table:
dynamic_slot_templates (the template types of each side, once), airbases and farps_and_ships (side, dynamic slots, hot start, the templates linked — "every blue template linked (61)" rather than the list again — and how many types are stocked with no template), presets (per side and radio layout, the types that carry it, or no presets), bullseye, weather (time, sky, ground wind and visibility of the mission and of each variant in missions/), support (tankers and AWACS, frequency, TACAN, late activation) and counters (presets_injected, routes_on_player_groups, warehouse_links, dead_links, variants), so a zero shows.
A section the mission lacks reads absent. mission_path is the folder (its latest build) or a .miz.
The guide used to ask to "open the built .miz and check": with only the actions, an assistant unzipped it and parsed its Lua tables in PowerShell.
The shipped default mission, built, reads about 5 000 characters.
Implementation: veaf_mission_mcp/inspect_built.py.
{"mission_path": "path/to/mission-folder"}
build_mission¶
Write. Builds the folder into a playable .miz by driving veaf-tools mission build in the folder (the
binary scaffold_mission installed, or veaf-tools on PATH). The build pipeline lives in the CLI
command and is re-run as-is. A build failure is surfaced (RuntimeError). profile (optional) is
passed as --profile — LOCAL_TEST for a local test build.
{"folder_path": "path/to/mission-folder", "profile": "LOCAL_TEST"}
The result lists in missions the .miz the build wrote: the night's, and its training versions when mission.yaml holds a training: block.
propose_training_plan¶
Read. Drafts the training: block of a mission folder (training versions, one per role): enemy AI flights tasked for air combat → enemy_cap, cap_missions → enemy_cap, the enemy side's QRA definitions → qra, groups holding a type DCS rates LR SAM → sam_lr, combat zones → a2g_target, other air defences inside a combat zone or within 15 km of one → sam_threat.
Everything is proposed as placed, each element with its reason: sorting what is stated from what is vague is done by reading the briefing, not in the action.
The enemy side is the one opposite the side holding the most player slots (blue players by default).
{"mission_path": "path/to/mission-folder"}
set_training_plan¶
Write. Writes the training: block of mission.yaml (comments preserved, backed up first), after the build's own checks (mission_builder.training_variants.check_training): refused with every problem listed, otherwise the next build also produces _TRAINING_A2G, _CAP, _SEAD and _ALL.
{"mission_path": "path/to/mission-folder", "training": {"elements": [{"groups": ["SA-10 *"], "kind": "sam_lr"}], "randomize": []}}
Next waves (out of scope)¶
- A generic IF/THEN trigger editor (arbitrary DCS conditions/actions) — wave 2 is limited to startup script-loading / Lua-execution triggers.
- A per-module schema validator for
set_mission_module(wave 4 stays generic). - CAS composites (pure runtime, no authoring) and end-to-end generation from a prompt (the NL-MISSION-GEN goal beyond this lot).
See .backlog/archive/FEAT-MCP-MISSION-EDITOR.md for details.