feat(beacon): let a caller request a beacon frequency per band - #128
Merged
Conversation
…BEACON-REQUESTED-FREQS)
createAtPoint drew all three frequencies (VHF/UHF/FM) at random, with no way
for the caller to ask for one. A mission that briefs a frequency -- an FM
channel given to a helicopter crew, a NDB printed on a kneeboard -- could only
read back whatever the pool picked and tell the pilots afterwards. VMCT's
-beacon marker command is the first caller to hit it.
opts.frequencies now takes any subset of { vhfKHz, uhfMHz, fmMHz }. Bands left
out keep drawing at random, so dropBeacon, createAtZone and every caller
passing only name / isFOB / batteryMinutes behave exactly as before.
The unit is in the key name: a mission maker reads VHF in kHz and UHF/FM in MHz
(what freqText() prints) while the module stores Hz. Each band's range is then
narrow enough that no value expressed in Hz, nor in kHz where MHz was meant or
the reverse, falls inside any band -- so the range check is the unit check. A
fractional request is rounded to the nearest Hz, because 45.2 MHz has no exact
double and the pool holds 45200000 exactly.
A request that cannot be granted refuses the whole call (nil plus a reason,
nothing spawned, no frequency consumed). Substituting a random pick was
rejected: a beacon answering on something other than the briefed frequency is
invisible to the mission maker and inaudible to the pilot who tuned it. Four
refusals: an unknown key (a typo must not quietly get a random frequency); a
value outside the band; a value in range but absent from the pool (off its step,
or an _ndbSkip map NDB -- granting it would break the invariant that every
frequency in circulation came out of the pool, since _freeFrequencies puts all
three back); and a value a live beacon already holds.
Validate first, consume second: _resolveFreqRequest checks the whole request and
returns the granted entries with their index in the free pool without mutating
it, so a refusal on the third band cannot leave the first two consumed.
_pickFreq and the new _takeFreq share the one place a frequency leaves the pool.
Also fixes a pre-existing leak the feature walks into: a failed spawn kept the
three drawn frequencies, which would tell a retrying caller its own frequency
was taken by a beacon that does not exist.
15 new busted specs in beacon_scripted_api_spec.lua, reusing its newManager()
fixture: the unchanged default path, the granted paths, each refusal case, that
a refusal costs nothing, the spawn-failure release, and a guard that _bands'
declared ranges still agree with the pools _buildFreqPools builds. Docs updated
in EN and FR (api-reference, subsystems/beacons).
Reviewer's GuideExtends Sequence diagram for atomic requested beacon frequency creationsequenceDiagram
participant Caller
participant Manager as CTLDBeaconManager
participant Pools as FrequencyPools
participant DCS as DCS
Caller->>Manager: createAtPoint(point, coalitionId, countryId, opts)
Manager->>Manager: _resolveFreqRequest(opts.frequencies)
Manager->>Pools: _poolIndexOf(freePool, requestedHz)
alt request refused
Manager-->>Caller: nil, reason
else request accepted
Manager->>Manager: _assignFrequencies(granted)
Manager->>Pools: _takeFreq(free, used, index)
Manager->>Pools: _pickFreq(free, used) for omitted bands
Manager->>DCS: _spawnBeaconUnit(...)
alt spawn failed
Manager->>Pools: _freeFrequencies(freqs)
Manager-->>Caller: nil, beacon spawn failed
else spawn succeeded
Manager-->>Caller: CTLDBeacon
end
end
Flow diagram for requested beacon frequency validationflowchart TD
A[createAtPoint] --> B[_resolveFreqRequest]
B --> C{frequencies is valid?}
C -- No --> R[Return nil, reason]
C -- Yes --> D{Each requested value is valid for its band?}
D -- No --> R
D -- Yes --> E{Requested Hz is in free pool?}
E -- No --> R
E -- Yes --> F[_assignFrequencies]
F --> G[Requested bands use _takeFreq]
F --> H[Omitted bands use _pickFreq]
G --> I[Spawn beacon]
H --> I
I --> J{Spawn succeeded?}
J -- No --> K[_freeFrequencies]
K --> S[Return nil, beacon spawn failed]
J -- Yes --> L[Return CTLDBeacon]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
davidp57
added a commit
to VEAF/VEAF-Mission-Creation-Tools
that referenced
this pull request
Aug 24, 2026
VEAF/CTLD#128 adds opts.frequencies to createAtPoint, per David's decision to take the request upstream rather than work around it on the VEAF side. Recorded in FEAT-RADIO-BEACONS so the dependency is traceable rather than remembered. The lot does not wait for it: -beacon ships reporting whatever frequencies CTLD drew, and a freq parameter becomes worth adding here only once the option arrives through a vendored update. Two findings from building it, both recorded because they outlive the PR: CTLD had a pre-existing bug where createAtPoint drew its frequencies before spawning and never released them on spawn failure — invisible while everything was random, and fixed there. And the FM pool holds only 300 of the 460 possible 100-kHz steps between 30.0 and 75.9 MHz, with four gaps enumerated from the generator, so briefing 38.00 MHz is refused. That belongs to the pool rather than to the new option, and widening it would change every existing random draw, so it is asked separately as VEAF/CTLD#127 instead of riding along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
davidp57
added a commit
to VEAF/VEAF-Mission-Creation-Tools
that referenced
this pull request
Aug 24, 2026
* wip(beacon): the -beacon command, before its tests Committed early on purpose: mutation-testing the previous lot meant a git checkout, and it destroyed uncommitted i18n keys. Three times today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(spawn): a marker command spawns a radio beacon, FM included _spawn beacon, with the alias -beacon. Issues #38 (FM beacons) and #192 (-beacon through CTLD), open since 2021 and 2023. One command places three beacons at the marker — ADF (VHF), UHF and FM — because CTLD lights all three whether you ask or not. So #38's FM request is answered without an option for it. Placed exactly where the marker was dropped: radius defaults to 0, unlike every command that spawns a group. A beacon's position is the reason for dropping it there. THE MESSAGE IS THE FEATURE CTLD draws each frequency from an internal pool and exposes no way to request one, so the command's whole job is to report what it got: Radio beacon up — ADF 245.00 kHz · UHF 251.00 MHz · FM 40.50 MHz -tacan was the model for the plumbing — descriptor, parameter rules, handler, alias — and deliberately not for this. It emits no message at all: it has none of its own, falls through to spawn.unit_spawned which names the unit and the country and never the channel, and does not even emit that, because its alias sets setBypassSecurity(true) and the handler passes bypassSecurity into spawnUnit's silent parameter. This handler passes options.silent instead. See FIX-SPAWN-BYPASSSECURITY-AS-SILENT, filed separately. A freq option is proposed upstream (VEAF/CTLD#128) rather than faked here. A beacon reporting a frequency VEAF cannot choose would be a command that lies. THE HANDLER RETURNS NIL, DELIBERATELY The dispatcher reads a handler's return as a group name and then runs its own post-processing on it: alarm state, MFD hiding, platform registration. A beacon is three groups with CTLD's battery timer, removal and map draw layer on top; handing it one of them would let VEAF reconfigure what it does not own. A test pins it. Two refusals rather than silence: no CTLD started — the state a mission built before FIX-CTLD-NEVER-INITIALIZED is in — and a createAtPoint that declines. The pilot dropped a marker and is waiting for something, and reporting success on a failed spawn would leave him tuning a frequency nothing transmits on. A FALSE ALARM CHECKED BEFORE IT WAS REPORTED The existing FOB beacon passes a country name string where createAtPoint documents a countryId number. Read through: ctld.utils.dynAdd resolves either a name or an id (CTLD.lua:5290-5311), so the existing call is correct. This one passes a name too, consistent with its neighbour. 16 Lua tests, split between test_veafSpawn.lua for behaviour and test_veafSpawnParser.lua for the descriptor, and four mutations run against them: dropping the frequencies from the message kills 2, removing the CTLD-not-ready guard kills 1, returning a group name kills 1, dropping radius = 0 kills 1. One mutation was written wrong first — a return followed by a comment, which Lua 5.1 refuses — and produced an empty result rather than a pass; re-run properly it killed its test. Documented on the spawn page under {#beacon} in both languages, with the option table and why the frequency is CTLD's choice, and listed in both alias tables. _spawn tacan is documented nowhere, so there was no section to mirror. Version 6.15.40 rather than .39: #799 holds .39 and merges first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
davidp57
added a commit
to VEAF/VEAF-Mission-Creation-Tools
that referenced
this pull request
Aug 24, 2026
… vendored yet VEAF/CTLD#128 merged 2026-08-24 17:21. Recorded precisely because the gap between merged and available is where someone implements against a method the shipped CTLD.lua does not have: vendored.yaml pins 2.0.0-rc7, and the option arrives with the next release. Also corrected in my own reporting: #127 is an ISSUE, not a PR — the question about the four gaps in the FM pool, which closes with an answer rather than a merge. I had listed it beside #128 under one instruction, which made it read as something to merge. David caught it. The only open PR on CTLD is #126, a Dependabot bump. The pin-consistency guard shipped earlier today will report the next CTLD release against the right baseline, which is what makes the wait safe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
CTLDBeaconManager:createAtPointis the script-facing way for a mission's own code to place a beacon,and it draws all three frequencies at random. A mission that wants a beacon on an agreed frequency —
a briefed FM channel, something that matches a printed kneeboard — cannot have one.
Raised from the VEAF Mission Creation Tools side, which is building a
-beaconmarker command oncreateAtPointand would otherwise have to tell pilots "here is the frequency CTLD happened to pick".What this adds
opts.frequencies— optional, any subset of{ vhfKHz, uhfMHz, fmMHz }. Bands left out keep drawing atrandom. Every existing caller is untouched.
One key per band, not a
freq+bandpair: three bands mean a request has to name one, and a keyper band gives "request one, leave two random" for free.
The unit is in the key name because a mission designer reads VHF in kHz and UHF/FM in MHz — which is
what
freqText()prints — while the module stores Hz. There is a second benefit that turned out tomatter more: each band's range is narrow enough that no value expressed in the wrong unit lands inside
any band, so the range check is the unit check and every plausible unit mistake becomes a loud refusal.
Fractional requests are rounded to the nearest Hz, since
45.2 * 1e6is not exactly45200000in adouble while the pool holds the integer.
Refusals are total, and that is a deliberate choice
createAtPointreturnsnil, reason. Nothing spawns, no frequency is consumed, the counter does not move.Falling back to a random pick with a warning was considered and rejected: it is the one failure nobody can
see. The kneeboard still says 250 kHz, the pilot tunes 250 kHz, hears silence, and the explanation is a
line in
dcs.logthat nobody will read.vhf = 250)frequenciesnot a table_freeFrequenciespushes a beacon's frequencies back into the free pools, so an off-grid grant would be added to the pool on removal and later drawn for someone else_resolveFreqRequestvalidates the whole request without mutating the pools, so a refusal on thethird band cannot leave the first two consumed.
_pickFreqnow delegates to_takeFreq, so the randomand the requested path leave the pool through one place.
A pre-existing bug fixed on the way
createAtPointdrew its frequencies before spawning and, on spawn failure, never gave them back.Invisible while everything was random. With a requested frequency it would tell a caller retrying the same
request that its own frequency is held by a beacon that does not exist. The three now go back, and that
path gains
nil, "beacon spawn failed".The orphaned DCS groups a partial spawn failure leaves behind are pre-existing and untouched — noted in
the PRD rather than fixed here.
The known cost, and where it is being discussed
The "must be in the pool" rule refuses frequencies a DCS radio tunes perfectly well. Measured from the
generator: the FM pool holds 300 of the 460 possible 100-kHz steps in 30.0–75.9 MHz, with four gaps
(36.0–39.9, 46.0–49.9, 56.0–59.9, 66.0–69.9). So briefing 38.00 MHz gets refused.
That is a property of the pool, not of this change, and widening the pool would alter what every existing
random draw can produce — so it is deliberately not in this PR. Raised separately as #127.
Refusing what the pool does not hold is correct given the pool. If #127 concludes the gaps are
accidental, closing it makes this option strictly more useful with no change here.
Tests
15 specs appended to
tests/ci/unit/beacon_scripted_api_spec.lua, reusing that file'snewManager()fixture: the unchanged default path;
frequencies = {}treated as no request; three bands granted; onegranted with two random; the free→used bookkeeping; fractional exactness; release on
removeBeaconso afrequency can be asked for again; one test per refusal case (the out-of-range one sweeps six unit-mistake
shapes, the not-in-pool one four); a refusal costs nothing (pool sizes, counter, spawn count and
_beaconsall unchanged); spawn-failure release then a successful retry.Plus a drift guard:
_bands' declared min/max must still equal the extremes of the pools_buildFreqPoolsactually builds. That mirroring is the only duplicated knowledge the feature introduces,and it exists so a refusal message can name the range.
Gates
lua-lint), 32src/files + the mergedCTLD.luatools/lua-test/run_specs.ps1build)generate_i18n_dicts.ps1dry run (i18n-guard) +pre-pushctld.tr()keysluacheckon the two changed filesCHANGELOG.md[Unreleased](changelog-guard)Two things left for a reviewer with more than a local runner:
bustedcould not be installed here, so the specs ran under the repo's owntools/lua-testrunner —which its README calls a fast local pre-check rather than a second source of truth. The new specs use
only assertions inside that runner's supported subset.
COVERAGE_FLOOR: 59) is measured by busted over unit and functional specs, adifferent denominator from anything measurable locally. Left alone rather than guessed upward, which
could turn CI red — worth reading the job's figure on this PR and bumping then.
Also noticed
CLAUDE.mdnamesluacheck --config .luacheckrc src/a gate and says to rely on CI when it is notinstalled locally — but there is no luacheck job in
.github/workflows/. So that gate currently runsnowhere. Either the job is missing or the instruction is stale; not touched here.
Summary by Sourcery
Enable mission scripts to place beacons on briefed frequencies while preserving random allocation for unspecified bands and maintaining safe frequency-pool bookkeeping.
New Features:
createAtPointto request specific VHF, UHF, and/or FM frequencies while leaving unspecified bands random.Bug Fixes:
Enhancements:
Documentation:
Tests:
Chores: