Skip to content

feat(beacon): let a caller request a beacon frequency per band - #128

Merged
davidp57 merged 1 commit into
developfrom
feature/beacon-requested-frequencies
Aug 24, 2026
Merged

feat(beacon): let a caller request a beacon frequency per band#128
davidp57 merged 1 commit into
developfrom
feature/beacon-requested-frequencies

Conversation

@davidp57

@davidp57 davidp57 commented Aug 24, 2026

Copy link
Copy Markdown
Member

Why

CTLDBeaconManager:createAtPoint is 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 -beacon marker command on
createAtPoint and 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 at
random. Every existing caller is untouched.

mgr:createAtPoint(point, coalition.side.BLUE, country.id.USA, {
    name = "FARP Alpha NDB",
    frequencies = { vhfKHz = 250, fmMHz = 40.5 },   -- UHF stays random
})

One key per band, not a freq + band pair: three bands mean a request has to name one, and a key
per 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 to
matter 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 * 1e6 is not exactly 45200000 in a
double while the pool holds the integer.

Refusals are total, and that is a deliberate choice

createAtPoint returns nil, 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.log that nobody will read.

Case Why refused
unknown key (vhf = 250) a typo would otherwise silently get a random frequency — the exact failure this option removes
non-number, or frequencies not a table caller error
outside the band's range the shape a unit mistake takes; the message names the band and its unit
in range but not in the pool the pool is the bookkeeping: _freeFrequencies pushes 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
in the pool but held by a live beacon the collision the pool exists to prevent — and removing the first of two sharers would free a frequency the second is still transmitting on

_resolveFreqRequest validates the whole request without mutating the pools, so a refusal on the
third band cannot leave the first two consumed. _pickFreq now delegates to _takeFreq, so the random
and the requested path leave the pool through one place.

A pre-existing bug fixed on the way

createAtPoint drew 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's newManager()
fixture: the unchanged default path; frequencies = {} treated as no request; three bands granted; one
granted with two random; the free→used bookkeeping; fractional exactness; release on removeBeacon so a
frequency 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
_beacons all unchanged); spawn-failure release then a successful retry.

Plus a drift guard: _bands' declared min/max must still equal the extremes of the pools
_buildFreqPools actually builds. That mirroring is the only duplicated knowledge the feature introduces,
and it exists so a refusal message can name the range.

Gates

Gate Result
Lua 5.1 syntax (lua-lint), 32 src/ files + the merged CTLD.lua pass
Unit specs via tools/lua-test/run_specs.ps1 1100 passed, 0 failed (1085 before)
Merge build (build) pass, 32 files, no BOM
generate_i18n_dicts.ps1 dry run (i18n-guard) + pre-push OK on all four dictionaries, no new ctld.tr() keys
luacheck on the two changed files 9 warnings, all pre-existing, none from new lines
CHANGELOG.md [Unreleased] (changelog-guard) Added + Fixed written

Two things left for a reviewer with more than a local runner:

  • busted could not be installed here, so the specs ran under the repo's own tools/lua-test runner —
    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.
  • The coverage floor (COVERAGE_FLOOR: 59) is measured by busted over unit and functional specs, a
    different 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.md names luacheck --config .luacheckrc src/ a gate and says to rely on CI when it is not
installed locally — but there is no luacheck job in .github/workflows/. So that gate currently runs
nowhere. 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:

  • Allow scripted beacons created with createAtPoint to request specific VHF, UHF, and/or FM frequencies while leaving unspecified bands random.
  • Reject invalid, unavailable, or conflicting frequency requests without spawning a beacon or consuming pool entries.

Bug Fixes:

  • Return frequencies to the pools when beacon spawning fails and report the failure reason, allowing retries to succeed.

Enhancements:

  • Centralize requested and random frequency allocation and document the new scripted API behavior in English and French.

Documentation:

  • Document the requested-frequency options, validation rules, failure results, and frequency pool behavior in the developer API and beacon subsystem guides.

Tests:

  • Add coverage for requested frequencies, partial requests, bookkeeping, validation refusals, no-op failures, spawn-failure recovery, and pool-range consistency.

Chores:

  • Record the requested-frequency feature and related spawn cleanup in the unreleased changelog and backlog.

…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).

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @davidp57, you have reached your weekly rate limit of 250000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Extends createAtPoint with atomic, per-band requested frequencies using unit-specific keys and pool-backed validation, while preserving random behavior for omitted bands and fixing frequency leakage on spawn failure; documentation, changelog, backlog records, and 15 focused specs are included.

Sequence diagram for atomic requested beacon frequency creation

sequenceDiagram
    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
Loading

Flow diagram for requested beacon frequency validation

flowchart 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]
Loading

File-Level Changes

Change Details Files
Add per-band requested-frequency support to the scripted beacon creation API with all-or-nothing validation.
  • Accept optional opts.frequencies keys vhfKHz, uhfMHz, and fmMHz, leaving omitted bands random.
  • Define band metadata and convert rounded caller units to integer Hz.
  • Reject unknown keys, invalid types, out-of-range values, unavailable pool entries, and frequencies held by live beacons without mutating state.
  • Return nil, reason for refused requests and route random/requested allocation through shared pool-consumption logic.
src/CTLD_beacon.lua
docs/developer/api-reference.md
docs/developer/api-reference.fr.md
docs/developer/subsystems/beacons.md
docs/developer/subsystems/beacons.fr.md
Repair frequency bookkeeping when scripted beacon spawning fails.
  • Return all three allocated frequencies to free pools after a failed spawn.
  • Return the explicit reason beacon spawn failed while preserving existing creation paths.
src/CTLD_beacon.lua
CHANGELOG.md
docs/developer/api-reference.md
docs/developer/api-reference.fr.md
docs/developer/subsystems/beacons.md
docs/developer/subsystems/beacons.fr.md
Add comprehensive unit coverage and documentation for requested frequencies and failure semantics.
  • Cover default and partial requests, empty requests, fractional conversion, allocation/release bookkeeping, all refusal categories, atomic refusal behavior, and spawn-failure retry.
  • Guard declared band limits against the ranges actually generated by frequency pools.
  • Record the feature and spawn-leak fix in the unreleased changelog and track the implementation PRD/backlog.
tests/ci/unit/beacon_scripted_api_spec.lua
CHANGELOG.md
.backlog/FEAT-BEACON-REQUESTED-FREQS/PRD.md
.backlog/FEAT-BEACON-REQUESTED-FREQS/tickets/01-requested-frequencies-on-createatpoint.md
.backlog/README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

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
davidp57 merged commit fbc93ad into develop Aug 24, 2026
9 checks passed
@davidp57
davidp57 deleted the feature/beacon-requested-frequencies branch August 24, 2026 17:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant